Add opt-in MCP user-verification transport (#43352)

## Why

Device-authenticated verification needs an app response outside automated approval and review. Previously, MCP user-verification requests were always cancelled.

## What changed

- Add typed `openai/userVerification` elicitations carrying a title, description, and challenge through core and app-server.
- Route verification to one connection enabled by trusted host activation. Restrict responses to that owner, exclude requests from replay, and cancel on disconnect or authentication changes, including account switches away and back.
- Return proofs in `content`, discard response metadata, and cancel malformed acceptances. Suppress verification response logging and analytics, and send only a generic verification notice to realtime.
- Keep the mode experimental and exclude it from stable schema exports. Capability advertisement remains disabled, and the TUI cancels verification requests.

## Testing

Add coverage for owner isolation, disconnect and authentication races, proof response handling, automated-review bypass, inactive-host cancellation, stable schema filtering, and realtime payload exclusion. Update the MCP integration test to verify that an activated request holds the tool until the client responds.

GitOrigin-RevId: c8c7b6691a3f7756682bbcca9def1df0d1feb906
This commit is contained in:
riley-oai
2026-09-07 04:33:22 +00:00
committed by copyberry
parent 121f91fd5d
commit 555b82afa9
31 changed files with 1244 additions and 64 deletions
+17 -3
View File
@@ -39,6 +39,9 @@ use std::path::PathBuf;
use std::process::Command;
use std::thread;
#[path = "export_user_verification.rs"]
mod user_verification;
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
@@ -271,6 +274,11 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> {
filter_request_ts(out_dir, "ServerRequest.ts", EXPERIMENTAL_SERVER_METHODS)?;
filter_experimental_type_fields_ts(out_dir, &registered_fields)?;
remove_generated_type_files(out_dir, &experimental_method_types, "ts")?;
let elicitation_path = out_dir.join("v2/McpServerElicitationRequestParams.ts");
if elicitation_path.exists() {
let content = fs::read_to_string(&elicitation_path)?;
fs::write(elicitation_path, user_verification::filter_ts(&content))?;
}
Ok(())
}
@@ -295,6 +303,11 @@ pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap<PathBuf, String>)
}
for (path, content) in tree.iter_mut() {
if path.file_stem().and_then(|stem| stem.to_str())
== Some("McpServerElicitationRequestParams")
{
*content = user_verification::filter_ts(content);
}
let Some(type_name) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
@@ -339,7 +352,7 @@ fn filter_request_ts_contents(mut content: String, experimental_methods: &[&str]
let filtered_arms: Vec<String> = arms
.into_iter()
.filter(|arm| {
extract_method_from_arm(arm)
extract_discriminator_from_arm(arm, "method")
.is_none_or(|method| !experimental_methods.contains(method.as_str()))
})
.collect();
@@ -425,6 +438,7 @@ fn filter_experimental_schema(bundle: &mut Value) -> Result<()> {
prune_experimental_methods(bundle, EXPERIMENTAL_CLIENT_METHODS);
prune_experimental_methods(bundle, EXPERIMENTAL_SERVER_METHODS);
remove_experimental_method_type_definitions(bundle);
user_verification::filter_json(bundle);
Ok(())
}
@@ -804,14 +818,14 @@ fn split_top_level_multi(input: &str, delimiters: &[char]) -> Vec<String> {
parts
}
fn extract_method_from_arm(arm: &str) -> Option<String> {
fn extract_discriminator_from_arm(arm: &str, discriminator: &str) -> Option<String> {
let (open, close) = find_top_level_brace_span(arm)?;
let inner = &arm[open + 1..close];
for field in split_top_level(inner, ',') {
let Some((name, value)) = parse_property(field.as_str()) else {
continue;
};
if name != "method" {
if name != discriminator {
continue;
}
let value = value.trim_start();
@@ -0,0 +1,57 @@
//! Removes the opt-in verification mode from the flattened stable elicitation union.
//!
//! `ExperimentalApi` enum annotations currently drive runtime checks only. Keep this new mode
//! out of stable exports without changing the export behavior of existing gated enum variants.
use super::extract_discriminator_from_arm;
use super::split_top_level;
use super::split_type_alias;
use serde_json::Value;
const MODE: &str = "openai/userVerification";
pub(super) fn filter_ts(content: &str) -> String {
let Some((prefix, body, suffix)) = split_type_alias(content) else {
return content.to_string();
};
let parts = split_top_level(&body, '&')
.into_iter()
.map(|part| {
let Some(union) = part
.strip_prefix('(')
.and_then(|part| part.strip_suffix(')'))
else {
return part;
};
let arms = split_top_level(union, '|')
.into_iter()
.filter(|arm| extract_discriminator_from_arm(arm, "mode").as_deref() != Some(MODE))
.collect::<Vec<_>>();
format!("({})", arms.join(" | "))
})
.collect::<Vec<_>>();
format!("{prefix} {}{suffix}", parts.join(" & "))
}
pub(super) fn filter_json(value: &mut Value) {
match value {
Value::Array(items) => {
items.retain(|item| {
item.pointer("/properties/mode/enum") != Some(&serde_json::json!([MODE]))
});
for item in items {
filter_json(item);
}
}
Value::Object(map) => {
for entry in map.values_mut() {
filter_json(entry);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
#[cfg(test)]
#[path = "export_user_verification_tests.rs"]
mod tests;
@@ -0,0 +1,39 @@
//! Checks that filtering the new mode preserves the stable elicitation contracts.
use super::*;
use crate::McpServerElicitationRequestParams;
use crate::TS;
use anyhow::Result;
use pretty_assertions::assert_eq;
#[test]
fn verification_mode_is_removed_from_flattened_typescript_only_for_stable_exports() -> Result<()> {
let generated = McpServerElicitationRequestParams::export_to_string()?;
assert!(generated.contains(MODE));
let filtered = filter_ts(&generated);
assert!(!filtered.contains(MODE));
assert_eq!(filter_ts(&filtered), filtered);
for mode in ["form", "openai/form", "openaiForm", "url"] {
assert!(filtered.contains(&format!("\"mode\": \"{mode}\"")));
}
assert!(filtered.contains("threadId: string"));
assert!(filtered.contains("requestedSchema: McpElicitationSchema"));
Ok(())
}
#[test]
fn verification_mode_is_removed_from_inline_json_without_changing_other_modes() -> Result<()> {
let generated = serde_json::to_value(schemars::schema_for!(McpServerElicitationRequestParams))?;
assert!(generated.to_string().contains(MODE));
let mut expected = generated.clone();
let modes = expected["oneOf"].as_array_mut().expect("flattened modes");
let verification = modes.remove(0);
assert_eq!(
verification["properties"]["mode"]["enum"],
serde_json::json!([MODE])
);
let mut filtered = generated;
filter_json(&mut filtered);
assert_eq!(filtered, expected);
Ok(())
}
@@ -1,6 +1,7 @@
use super::shared::v2_enum_from_core;
use crate::JsonSchema;
use crate::TS;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::items::McpToolCallError as CoreMcpToolCallError;
use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult;
@@ -379,7 +380,7 @@ impl From<rmcp::model::ElicitationAction> for McpServerElicitationAction {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServerElicitationRequestParams {
@@ -393,6 +394,7 @@ pub struct McpServerElicitationRequestParams {
pub turn_id: Option<String>,
pub server_name: String,
#[serde(flatten)]
#[experimental(nested)]
pub request: McpServerElicitationRequest,
// TODO: When core can correlate an elicitation with an MCP tool call, expose the associated
// McpToolCall item id here as an optional field. The current core event does not carry that
@@ -744,11 +746,20 @@ pub struct McpElicitationConstOption {
pub title: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(tag = "mode", rename_all = "camelCase")]
#[ts(tag = "mode")]
#[ts(export_to = "v2/")]
pub enum McpServerElicitationRequest {
/// A device-authenticated approval; accepted responses contain the proof in `content`.
#[experimental("mcpServer/elicitation/request.userVerification")]
#[serde(rename = "openai/userVerification", rename_all = "camelCase")]
#[ts(rename = "openai/userVerification", rename_all = "camelCase")]
UserVerification {
title: String,
description: String,
challenge: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Form {
@@ -794,6 +805,15 @@ impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
fn try_from(value: CoreElicitationRequest) -> Result<Self, Self::Error> {
match value {
CoreElicitationRequest::UserVerification {
title,
description,
challenge,
} => Ok(Self::UserVerification {
title,
description,
challenge,
}),
CoreElicitationRequest::Form {
meta,
message,