From 6e46b4e0e65127e67db649abb483b054d16baffc Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 10 Jun 2026 17:45:27 -0700 Subject: [PATCH 01/13] Support OpenAI form elicitation requests --- .../McpServerElicitationRequestParams.json | 21 +++ .../schema/json/ServerRequest.json | 21 +++ .../codex_app_server_protocol.schemas.json | 21 +++ .../v2/McpServerElicitationRequestParams.ts | 2 +- .../src/protocol/v2/mcp.rs | 18 +++ .../src/protocol/v2/tests.rs | 29 ++++ codex-rs/app-server/README.md | 5 + .../tests/suite/v2/mcp_server_elicitation.rs | 133 ++++++++++++++++-- .../codex-mcp/src/connection_manager_tests.rs | 40 +++--- codex-rs/codex-mcp/src/elicitation.rs | 53 ++++--- codex-rs/codex-mcp/src/rmcp_client.rs | 7 + codex-rs/core/src/session/mcp.rs | 33 ++++- codex-rs/core/src/session/mcp_tests.rs | 48 ++++--- codex-rs/protocol/src/approvals.rs | 13 +- .../src/elicitation_client_service.rs | 98 +++++++++++-- .../rmcp-client/src/logging_client_handler.rs | 3 +- codex-rs/rmcp-client/src/rmcp_client.rs | 10 +- codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/chatwidget/tool_requests.rs | 3 +- 19 files changed, 474 insertions(+), 89 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json index aa7fa817ae92..3fc697133078 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -557,6 +557,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 34ccaa47e9c7..c0001076ce62 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1382,6 +1382,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index eb659fda3be9..aac161574453 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3647,6 +3647,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts index 90d60f77c7b7..a4f1e732c64c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -13,4 +13,4 @@ export type McpServerElicitationRequestParams = { threadId: string, * context is app-server correlation rather than part of the protocol identity of the * elicitation itself. */ -turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); +turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 37197a223768..cdfeb524f569 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -632,6 +632,15 @@ pub enum McpServerElicitationRequest { message: String, requested_schema: McpElicitationSchema, }, + #[serde(rename = "openai/form", rename_all = "camelCase")] + #[ts(rename = "openai/form", rename_all = "camelCase")] + OpenAiForm { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Url { @@ -658,6 +667,15 @@ impl TryFrom for McpServerElicitationRequest { message, requested_schema: serde_json::from_value(requested_schema)?, }), + CoreElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => Ok(Self::OpenAiForm { + meta, + message, + requested_schema, + }), CoreElicitationRequest::Url { meta, message, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2567e386c839..cd8d890c0a81 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1899,6 +1899,35 @@ fn mcp_server_elicitation_request_from_core_form_request() { ); } +#[test] +fn mcp_server_elicitation_request_from_core_openai_form_request() { + let requested_schema = json!({ + "type": "object", + "properties": { + "path": { + "type": "openai/file", + "title": "Source file", + }, + }, + "required": ["path"], + }); + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema: requested_schema.clone(), + }) + .expect("OpenAI form request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema, + } + ); +} + #[test] fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { let schema: McpElicitationSchema = serde_json::from_value(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d7155..4e5dbbcac6be 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1462,12 +1462,17 @@ Order of messages: 1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either: - a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }` + - an OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }` - a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }` 2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. 3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The +client owns validation and rendering of OpenAI field types such as +`openai/file`, `openai/choice`, and `openai/connector`. + For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, `persist: "always"`, or `persist: ["session", "always"]` to advertise whether diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index b4bd1d377a92..8f36d9b1ca11 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -41,6 +41,7 @@ use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::Content; use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::CustomRequest; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::JsonObject; @@ -49,6 +50,7 @@ use rmcp::model::Meta; use rmcp::model::PrimitiveSchema; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; +use rmcp::model::ServerRequest as McpServerRequest; use rmcp::model::Tool; use rmcp::model::ToolAnnotations; use rmcp::service::RequestContext; @@ -71,6 +73,7 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const OPENAI_FORM_MESSAGE: &str = "Choose a template"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_elicitation_round_trip() -> Result<()> { @@ -213,7 +216,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { } ); - let resolved_request_id = request_id.clone(); + let standard_request_id = request_id.clone(); mcp.send_response( request_id, serde_json::to_value(McpServerElicitationRequestResponse { @@ -226,7 +229,55 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { ) .await?; - let mut saw_resolved = false; + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + let requested_schema = json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/choice", + "presentation": "cards", + "options": [{ + "value": "monthly-business-review", + "title": "Monthly business review", + }], + }, + }, + "required": ["template"], + }); + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id.clone(), + turn_id: Some(turn.id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema, + }, + } + ); + let openai_form_request_id = request_id.clone(); + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "template": "monthly-business-review", + })), + meta: None, + })?, + ) + .await?; + + let mut resolved_request_ids = Vec::new(); loop { let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; let JSONRPCMessage::Notification(notification) = message else { @@ -242,19 +293,26 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { .expect("serverRequest/resolved params"), )?; assert_eq!( - resolved, - ServerRequestResolvedNotification { - thread_id: thread.id.clone(), - request_id: resolved_request_id.clone(), - } + resolved.thread_id, thread.id, + "resolved request should belong to the active thread" ); - saw_resolved = true; + assert!( + resolved.request_id == standard_request_id + || resolved.request_id == openai_form_request_id, + "unexpected resolved request id: {:?}", + resolved.request_id + ); + resolved_request_ids.push(resolved.request_id); } "turn/completed" => { let completed: TurnCompletedNotification = serde_json::from_value( notification.params.clone().expect("turn/completed params"), )?; - assert!(saw_resolved, "serverRequest/resolved should arrive first"); + assert!( + resolved_request_ids.contains(&standard_request_id) + && resolved_request_ids.contains(&openai_form_request_id), + "both server requests should resolve before turn completion" + ); assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.id, turn.id); assert_eq!(completed.turn.status, TurnStatus::Completed); @@ -290,7 +348,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::from_str::(payload)?, json!([{ "type": "text", - "text": "accepted" + "text": "accepted monthly-business-review" }]) ); @@ -380,7 +438,60 @@ impl ServerHandler for ElicitationAppsMcpServer { ElicitationAction::Cancel => "cancelled", }; - Ok(CallToolResult::success(vec![Content::text(output)])) + if output != "accepted" { + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + + let result = context + .peer + .send_request(McpServerRequest::CustomRequest(CustomRequest::new( + "openai/form", + Some(json!({ + "message": OPENAI_FORM_MESSAGE, + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/choice", + "presentation": "cards", + "options": [{ + "value": "monthly-business-review", + "title": "Monthly business review", + }], + }, + }, + "required": ["template"], + }, + })), + ))) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = match result { + rmcp::model::ClientResult::CustomResult(result) => result.0, + rmcp::model::ClientResult::CreateElicitationResult(result) => { + serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? + } + result => { + return Err(rmcp::ErrorData::internal_error( + format!("unexpected OpenAI form response: {result:?}"), + None, + )); + } + }; + assert_eq!( + result, + json!({ + "action": "accept", + "content": { + "template": "monthly-business-review", + }, + }) + ); + + Ok(CallToolResult::success(vec![Content::text( + "accepted monthly-business-review", + )])) } } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index bfbc09c473c0..82647a68a9fb 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -251,13 +251,15 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Confirm?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Confirm?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto accept"); @@ -284,17 +286,19 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "What should I say?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .required_property( - "message", - rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), - ) - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .required_property( + "message", + rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + ) + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto decline"); diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index a51cd7c62353..abd5a46899e6 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -22,11 +22,11 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rmcp_client::Elicitation; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::SendElicitation; use futures::future::BoxFuture; use futures::future::FutureExt; -use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::RequestId; use tokio::sync::Mutex; @@ -36,7 +36,7 @@ use tokio::sync::oneshot; pub struct ElicitationReviewRequest { pub server_name: String, pub request_id: RequestId, - pub elicitation: CreateElicitationRequestParams, + pub elicitation: Elicitation, } pub trait ElicitationReviewer: Send + Sync { @@ -172,11 +172,13 @@ impl ElicitationRequestManager { } let request = match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } => ElicitationRequest::Form { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }, + ) => ElicitationRequest::Form { meta: meta .map(serde_json::to_value) .transpose() @@ -185,12 +187,14 @@ impl ElicitationRequestManager { requested_schema: serde_json::to_value(requested_schema) .context("failed to serialize MCP elicitation schema")?, }, - CreateElicitationRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - } => ElicitationRequest::Url { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }, + ) => ElicitationRequest::Url { meta: meta .map(serde_json::to_value) .transpose() @@ -199,6 +203,15 @@ impl ElicitationRequestManager { url, elicitation_id, }, + Elicitation::OpenAiForm { + meta, + message, + requested_schema, + } => ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, }; let (tx, rx) = oneshot::channel(); { @@ -243,14 +256,18 @@ pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; -fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { +fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool { match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - requested_schema, .. - } => { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + requested_schema, + .. + }) => { // Auto-accept confirm/approval elicitations without schema requirements. requested_schema.properties.is_empty() } - CreateElicitationRequestParams::UrlElicitationParams { .. } => false, + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + .. + }) + | Elicitation::OpenAiForm { .. } => false, } } diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3ba1adcc94f7..61fad8d5c5a2 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -7,6 +7,7 @@ //! [`crate::connection_manager`]. use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -62,6 +63,7 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; +use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio_util::sync::CancellationToken; @@ -70,6 +72,7 @@ use tracing::warn; /// MCP server capability indicating that Codex should include [`SandboxState`] /// in tool-call request `_meta` under this key. pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = @@ -486,6 +489,10 @@ async fn start_server_task( } = params; let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); + capabilities.experimental = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])); let params = InitializeRequestParams::new( capabilities, Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cc6e42f85222..cf6051892163 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -16,7 +16,7 @@ use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_T use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY; use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY; use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY; -use rmcp::model::CreateElicitationRequestParams; +use codex_rmcp_client::Elicitation; use rmcp::model::ElicitationAction; use rmcp::model::Meta; use serde_json::Map; @@ -143,6 +143,15 @@ impl Session { requested_schema, } } + McpServerElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, McpServerElicitationRequest::Url { meta, message, @@ -509,12 +518,15 @@ fn guardian_elicitation_review_request( request: &ElicitationReviewRequest, ) -> GuardianElicitationReview { let (meta, requested_schema) = match &request.elicitation { - CreateElicitationRequestParams::FormElicitationParams { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { meta, requested_schema, .. - } => (meta, Some(requested_schema)), - CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => { + }) => (meta, Some(requested_schema)), + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + .. + }) => { return if meta_requests_approval_request(meta) { GuardianElicitationReview::Decline( "guardian MCP elicitation review only supports form elicitations", @@ -523,6 +535,7 @@ fn guardian_elicitation_review_request( GuardianElicitationReview::NotRequested }; } + Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested, }; let Some(meta) = meta.as_ref().map(|meta| &meta.0) else { @@ -584,12 +597,18 @@ fn guardian_elicitation_review_request( )) } -fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> { +fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> { match elicitation { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } - | CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { meta, .. } + | rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { meta, .. }, + ) => meta .as_ref() .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), + Elicitation::OpenAiForm { meta, .. } => meta + .as_ref() + .and_then(Value::as_object) + .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)), } } diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 0e3664019b84..e5d0184bb7e4 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -30,13 +30,15 @@ fn form_request(meta: Option) -> ElicitationReviewRequest { ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(7), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta, - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), } } @@ -171,12 +173,14 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let url_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(8), - elicitation: CreateElicitationRequestParams::UrlElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Open URL".to_string(), - url: "https://example.com".to_string(), - elicitation_id: "elicit-1".to_string(), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Open URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicit-1".to_string(), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&url_request), @@ -186,14 +190,16 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let non_empty_schema_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(9), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .expect("schema should build"), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&non_empty_schema_request), diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c2b..5808c127b905 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -332,6 +332,15 @@ pub enum ElicitationRequest { message: String, requested_schema: JsonValue, }, + #[serde(rename = "openai/form")] + #[ts(rename = "openai/form")] + OpenAiForm { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, Url { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] #[ts(optional, rename = "_meta")] @@ -345,7 +354,9 @@ pub enum ElicitationRequest { impl ElicitationRequest { pub fn message(&self) -> &str { match self { - Self::Form { message, .. } | Self::Url { message, .. } => message, + Self::Form { message, .. } + | Self::OpenAiForm { message, .. } + | Self::Url { message, .. } => message, } } } diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 49f11f0a7637..a50e0d630df9 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use rmcp::RoleClient; use rmcp::model::ClientInfo; use rmcp::model::ClientResult; +use rmcp::model::CustomRequest; use rmcp::model::CustomResult; use rmcp::model::ElicitationAction; use rmcp::model::Meta; @@ -12,7 +13,9 @@ use rmcp::model::ServerRequest; use rmcp::service::NotificationContext; use rmcp::service::RequestContext; use rmcp::service::Service; +use serde::Deserialize; use serde::Serialize; +use serde_json::Map; use serde_json::Value; use crate::logging_client_handler::LoggingClientHandler; @@ -22,6 +25,16 @@ use crate::rmcp_client::ElicitationResponse; use crate::rmcp_client::SendElicitation; const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken"; +const OPENAI_FORM_METHOD: &str = "openai/form"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenAiFormRequestParams { + #[serde(rename = "_meta")] + meta: Option, + message: String, + requested_schema: Value, +} #[derive(Clone)] pub(crate) struct ElicitationClientService { @@ -73,11 +86,21 @@ impl Service for ElicitationClientService { ) -> Result { match request { ServerRequest::CreateElicitationRequest(request) => { - let response = self.create_elicitation(request.params, context).await?; + let response = self + .create_elicitation(Elicitation::Mcp(request.params), context) + .await?; // RMCP's typed CreateElicitationResult does not model result-level `_meta`. let result = elicitation_response_result(response)?; Ok(ClientResult::CustomResult(result)) } + ServerRequest::CustomRequest(request) if request.method == OPENAI_FORM_METHOD => { + let response = self + .create_elicitation(openai_form_elicitation(request)?, context) + .await?; + Ok(ClientResult::CustomResult(elicitation_response_result( + response, + )?)) + } request => { >::handle_request( &self.handler, @@ -107,6 +130,18 @@ impl Service for ElicitationClientService { } } +fn openai_form_elicitation(request: CustomRequest) -> Result { + let params = request + .params_as::() + .map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))? + .ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?; + Ok(Elicitation::OpenAiForm { + meta: params.meta, + message: params.message, + requested_schema: params.requested_schema, + }) +} + fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation { // RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services. context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY); @@ -114,10 +149,20 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli return request; } - request - .meta_mut() - .get_or_insert_with(Meta::new) - .extend(context_meta); + match &mut request { + Elicitation::Mcp(request) => request + .meta_mut() + .get_or_insert_with(Meta::new) + .extend(context_meta), + Elicitation::OpenAiForm { meta, .. } => { + let meta = meta + .get_or_insert_with(|| Value::Object(Map::new())) + .as_object_mut(); + if let Some(meta) = meta { + meta.extend(context_meta.0); + } + } + } request } @@ -165,7 +210,7 @@ mod tests { #[test] fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() { let request = restore_context_meta( - form_request(/*meta*/ None), + Elicitation::Mcp(form_request(/*meta*/ None)), meta(json!({ "progressToken": "progress-token", "persist": ["session", "always"], @@ -174,9 +219,46 @@ mod tests { assert_eq!( request, - form_request(Some(meta(json!({ + Elicitation::Mcp(form_request(Some(meta(json!({ "persist": ["session", "always"], - })))) + }))))) + ); + } + + #[test] + fn parses_openai_form_custom_requests() { + let elicitation = openai_form_elicitation(CustomRequest::new( + OPENAI_FORM_METHOD, + Some(json!({ + "message": "Choose a template", + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/choice", + "options": [] + } + } + } + })), + )) + .expect("valid openai/form request"); + + assert_eq!( + elicitation, + Elicitation::OpenAiForm { + meta: None, + message: "Choose a template".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/choice", + "options": [] + } + } + }), + } ); } diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs index 0c3da0fe2cd1..e575966cffff 100644 --- a/codex-rs/rmcp-client/src/logging_client_handler.rs +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -17,6 +17,7 @@ use tracing::error; use tracing::info; use tracing::warn; +use crate::rmcp_client::Elicitation; use crate::rmcp_client::SendElicitation; #[derive(Clone)] @@ -40,7 +41,7 @@ impl ClientHandler for LoggingClientHandler { request: CreateElicitationRequestParams, context: RequestContext, ) -> Result { - (self.send_elicitation)(context.id, request) + (self.send_elicitation)(context.id, Elicitation::Mcp(request)) .await .map(Into::into) .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None)) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index e9b01486228e..32f53b34b38a 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -251,7 +251,15 @@ fn remaining_operation_timeout( } } -pub type Elicitation = CreateElicitationRequestParams; +#[derive(Debug, Clone, PartialEq)] +pub enum Elicitation { + Mcp(CreateElicitationRequestParams), + OpenAiForm { + meta: Option, + message: String, + requested_schema: serde_json::Value, + }, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c83920556c07..2d2e62abf446 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -291,7 +291,10 @@ impl App { message: message.clone(), }, )), - codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { + .. + } + | codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name.clone(), diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 3001563451aa..a05102a302f4 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -375,7 +375,8 @@ impl ChatWidget { self.bottom_pane .push_approval_request(request, &self.config.features); } - McpServerElicitationRequest::Url { .. } => { + McpServerElicitationRequest::OpenAiForm { .. } + | McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name, From 211e23934a75f2b2dee32cab4ff2734ddd671ef4 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 10 Jun 2026 23:34:06 -0700 Subject: [PATCH 02/13] Negotiate MCP client extensions through app-server --- .../analytics/src/analytics_client_tests.rs | 7 +++ codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server-client/src/remote.rs | 1 + codex-rs/app-server-daemon/src/client.rs | 1 + .../schema/json/ClientRequest.json | 27 ++++++++ .../codex_app_server_protocol.schemas.json | 27 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 27 ++++++++ .../schema/json/v1/InitializeParams.json | 27 ++++++++ .../schema/typescript/InitializeParams.ts | 7 ++- .../typescript/McpClientCapabilities.ts | 9 +++ .../schema/typescript/index.ts | 1 + codex-rs/app-server-protocol/src/lib.rs | 1 + .../src/protocol/common.rs | 32 ++++++++++ .../app-server-protocol/src/protocol/v1.rs | 14 +++++ codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 24 ++++++- codex-rs/app-server/src/in_process.rs | 1 + codex-rs/app-server/src/message_processor.rs | 14 +++++ .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 + .../initialize_processor.rs | 2 + .../request_processors/thread_processor.rs | 35 ++++++++++- .../tests/common/test_app_server.rs | 21 +++++++ .../tests/suite/conversation_summary.rs | 1 + .../suite/v2/connection_handling_websocket.rs | 1 + .../app-server/tests/suite/v2/mcp_resource.rs | 1 + .../tests/suite/v2/mcp_server_elicitation.rs | 63 ++++++++++++------- .../tests/suite/v2/remote_thread_store.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 3 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 3 + .../codex-mcp/src/connection_manager_tests.rs | 1 + codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 8 +++ codex-rs/codex-mcp/src/rmcp_client.rs | 61 +++++++++++++----- codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 7 +++ codex-rs/core/src/session/session.rs | 7 +++ codex-rs/core/src/thread_manager.rs | 47 +++++++++++++- 40 files changed, 445 insertions(+), 45 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7c634ce370ad..e00419f5cacb 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -516,6 +516,7 @@ async fn ingest_rejected_turn_steer( version: "1.0.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, product_client_id: "codex-web".to_string(), runtime: sample_runtime_metadata(), @@ -577,6 +578,7 @@ async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec AnalyticsFact { request_attestation: false, opt_out_notification_methods: None, }), + mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1661,6 +1664,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize request_attestation: false, opt_out_notification_methods: None, }), + mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1810,6 +1814,7 @@ async fn compaction_event_ingests_custom_fact() { request_attestation: false, opt_out_notification_methods: None, }), + mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: sample_runtime_metadata(), @@ -1938,6 +1943,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { request_attestation: false, opt_out_notification_methods: None, }), + mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: sample_runtime_metadata(), @@ -2753,6 +2759,7 @@ async fn subagent_events_use_inherited_connection_unless_turn_connection_is_expl version: "1.0.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, product_client_id: "parent-client".to_string(), runtime: sample_runtime_metadata(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d842..88e5fde6d14b 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -383,6 +383,7 @@ impl InProcessClientStartArgs { version: self.client_version.clone(), }, capabilities: Some(capabilities), + mcp_client_capabilities: None, } } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index e1c9f16c4a8d..a25d56215643 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -108,6 +108,7 @@ impl RemoteAppServerConnectArgs { version: self.client_version.clone(), }, capabilities: Some(capabilities), + mcp_client_capabilities: None, } } } diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs index 8c771ba1f2a2..2a85d821ebbf 100644 --- a/codex-rs/app-server-daemon/src/client.rs +++ b/codex-rs/app-server-daemon/src/client.rs @@ -94,6 +94,7 @@ where } else { None }, + mcp_client_capabilities: None, })?), trace: None, }); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ed2791c8551..2c6635136587 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1296,6 +1296,17 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" + }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -1546,6 +1557,22 @@ }, "type": "object" }, + "McpClientCapabilities": { + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "description": "MCP extension identifiers mapped to their client-provided settings.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, "McpResourceReadParams": { "properties": { "server": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index aac161574453..9093587a3c3d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2925,6 +2925,17 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" + }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -3082,6 +3093,22 @@ "title": "JSONRPCResponse", "type": "object" }, + "McpClientCapabilities": { + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "description": "MCP extension identifiers mapped to their client-provided settings.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e0562760f6b7..28968577f98e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7458,6 +7458,17 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" + }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -8224,6 +8235,22 @@ ], "type": "string" }, + "McpClientCapabilities": { + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "description": "MCP extension identifiers mapped to their client-provided settings.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, "McpResourceReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index af5c509249a2..fdb558c0955e 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -47,6 +47,22 @@ } }, "type": "object" + }, + "McpClientCapabilities": { + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "description": "MCP extension identifiers mapped to their client-provided settings.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" } }, "properties": { @@ -62,6 +78,17 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" + }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts index e48c5ee7b52c..7f58e5238df0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts @@ -3,5 +3,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ClientInfo } from "./ClientInfo"; import type { InitializeCapabilities } from "./InitializeCapabilities"; +import type { McpClientCapabilities } from "./McpClientCapabilities"; -export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, }; +export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, +/** + * MCP client capabilities that app-server may advertise to downstream MCP servers. + */ +mcpClientCapabilities?: McpClientCapabilities | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts new file mode 100644 index 000000000000..e7ab22c0f703 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpClientCapabilities = { +/** + * MCP extension identifiers mapped to their client-provided settings. + */ +extensions?: Record>, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index c3bc3ec761a1..a718e6a07082 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -46,6 +46,7 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; +export type { McpClientCapabilities } from "./McpClientCapabilities"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index f6d7670e10a2..b3d305e0c209 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -36,6 +36,7 @@ pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; pub use protocol::v1::InterruptConversationResponse; pub use protocol::v1::LoginApiKeyParams; +pub use protocol::v1::McpClientCapabilities; pub use protocol::v1::SandboxSettings; pub use protocol::v1::Tools; pub use protocol::v1::UserSavedConfig; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7611c27ff1fc..98a88fa240a3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1687,6 +1687,7 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; + use std::collections::BTreeMap; use std::path::PathBuf; fn absolute_path_string(path: &str) -> String { @@ -2007,6 +2008,7 @@ mod tests { version: "0.1.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, }; assert_eq!(initialize.serialization_scope(), None); @@ -2167,6 +2169,14 @@ mod tests { "item/agentMessage/delta".to_string(), ]), }), + mcp_client_capabilities: Some(v1::McpClientCapabilities { + extensions: Some(BTreeMap::from([( + "openai/form".to_string(), + serde_json::from_value(json!({ + "fieldTypes": ["openai/file"] + }))?, + )])), + }), }, }; @@ -2187,6 +2197,13 @@ mod tests { "thread/started", "item/agentMessage/delta" ] + }, + "mcpClientCapabilities": { + "extensions": { + "openai/form": { + "fieldTypes": ["openai/file"] + } + } } } }), @@ -2213,6 +2230,13 @@ mod tests { "thread/started", "item/agentMessage/delta" ] + }, + "mcpClientCapabilities": { + "extensions": { + "openai/form": { + "fieldTypes": ["openai/file"] + } + } } } }))?; @@ -2235,6 +2259,14 @@ mod tests { "item/agentMessage/delta".to_string(), ]), }), + mcp_client_capabilities: Some(v1::McpClientCapabilities { + extensions: Some(BTreeMap::from([( + "openai/form".to_string(), + serde_json::from_value(json!({ + "fieldTypes": ["openai/file"] + }))?, + )])), + }), }, } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c37d..3d9935980f9d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; @@ -30,6 +31,10 @@ pub struct InitializeParams { pub client_info: ClientInfo, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, + /// MCP client capabilities that app-server may advertise to downstream MCP servers. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub mcp_client_capabilities: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] @@ -56,6 +61,15 @@ pub struct InitializeCapabilities { pub opt_out_notification_methods: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpClientCapabilities { + /// MCP extension identifiers mapped to their client-provided settings. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable, type = "Record>")] + pub extensions: Option>>, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index b4e09d3e1ed6..9b64687bb540 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1603,6 +1603,7 @@ impl CodexClient { .collect(), ), }), + mcp_client_capabilities: None, }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e5dbbcac6be..dff52928c412 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,6 +86,27 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. +Clients that render server-initiated MCP extensions can provide +`initialize.params.mcpClientCapabilities`. App-server merges its `extensions` +map into the MCP client capabilities advertised to servers started for that +connection's threads. For example, a client that renders OpenAI file fields +declares: + +```json +{ + "mcpClientCapabilities": { + "extensions": { + "openai/form": { + "fieldTypes": ["openai/file"] + } + } + } +} +``` + +Clients must omit extensions they cannot handle. App-server does not infer MCP +UI support from `experimentalApi` or other app-server capabilities. + Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If @@ -1470,8 +1491,7 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The -client owns validation and rendering of OpenAI field types such as -`openai/file`, `openai/choice`, and `openai/connector`. +client owns validation and rendering of the `openai/file` field type. For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f69..feac7f822cc5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -791,6 +791,7 @@ mod tests { version: "0.0.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, channel_capacity, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c32b92c4ea60..bc9f2804ee8b 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -63,6 +63,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; @@ -220,6 +221,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, + pub(crate) mcp_client_capabilities: Option, } impl Default for ConnectionSessionState { @@ -271,6 +273,12 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } + pub(crate) fn mcp_client_capabilities(&self) -> Option { + self.initialized + .get() + .and_then(|session| session.mcp_client_capabilities.clone()) + } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -875,6 +883,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); + let mcp_client_capabilities = session.mcp_client_capabilities(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -890,6 +899,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, + mcp_client_capabilities, ) .await; if let Err(error) = result { @@ -919,6 +929,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1071,6 +1082,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), request_context, ) .await @@ -1087,6 +1099,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), ) .await } @@ -1097,6 +1110,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), ) .await } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 771b1fd128b8..f3b7de9e0fd7 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -146,6 +146,7 @@ impl TracingHarness { experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, }, /*trace*/ None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cfc411e13080..8edb2a50dcdb 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -99,6 +99,7 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpResourceReadResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a40007db115a..03c3f5e26870 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,6 +67,7 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); + let mcp_client_capabilities = params.mcp_client_capabilities.clone(); let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = match params.capabilities { Some(capabilities) => ( @@ -101,6 +102,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, + mcp_client_capabilities, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 09ff0dd6981f..72eaf61036e9 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -418,6 +418,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -425,6 +426,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, request_context, ) .await @@ -447,12 +449,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .map(|()| None) @@ -464,12 +468,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .map(|()| None) @@ -875,6 +881,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -945,6 +952,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, + mcp_client_capabilities, config, typesafe_overrides, dynamic_tools, @@ -1018,6 +1026,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1123,6 +1132,7 @@ impl ThreadRequestProcessor { thread_extension_init.insert(selected_capability_roots); codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init); } + insert_mcp_client_capabilities(&mut thread_extension_init, mcp_client_capabilities); let create_thread_started_at = std::time::Instant::now(); let NewThread { thread_id, @@ -2519,6 +2529,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2658,11 +2669,12 @@ impl ThreadRequestProcessor { match self .thread_manager - .resume_thread_with_history( + .resume_thread_with_history_and_extension_data( config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, + mcp_client_extension_data(mcp_client_capabilities), ) .await { @@ -3280,6 +3292,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3379,7 +3392,7 @@ impl ThreadRequestProcessor { .. } = self .thread_manager - .fork_thread_from_history( + .fork_thread_from_history_with_extension_data( ForkSnapshot::Interrupted, config, InitialHistory::Resumed(ResumedHistory { @@ -3389,6 +3402,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, + mcp_client_extension_data(mcp_client_capabilities), ) .await .map_err(|err| match err { @@ -3694,6 +3708,23 @@ impl ThreadRequestProcessor { } } +fn mcp_client_extension_data(capabilities: Option) -> ExtensionDataInit { + let mut extension_data = ExtensionDataInit::new(); + insert_mcp_client_capabilities(&mut extension_data, capabilities); + extension_data +} + +fn insert_mcp_client_capabilities( + extension_data: &mut ExtensionDataInit, + capabilities: Option, +) { + if let Some(extensions) = capabilities.and_then(|capabilities| capabilities.extensions) + && !extensions.is_empty() + { + extension_data.insert(codex_mcp::McpClientCapabilities { extensions }); + } +} + fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index e940d9a42b07..c5727df01e87 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -53,6 +53,7 @@ use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; @@ -314,6 +315,26 @@ impl TestAppServer { self.initialize_with_params(InitializeParams { client_info, capabilities, + mcp_client_capabilities: None, + }) + .await + } + + pub async fn initialize_with_mcp_client_capabilities( + &mut self, + mcp_client_capabilities: McpClientCapabilities, + ) -> anyhow::Result { + self.initialize_with_params(InitializeParams { + client_info: ClientInfo { + name: DEFAULT_CLIENT_NAME.to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + mcp_client_capabilities: Some(mcp_client_capabilities), }) .await } diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 6ad9f1c633a7..00bf703a7ffe 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -170,6 +170,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 0e00bc630951..3257ac9a97bd 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -599,6 +599,7 @@ pub(super) async fn send_initialize_request( version: "0.1.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }; send_request( stream, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index dc3b6d577037..eaff6f01992b 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -523,6 +523,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { version: "0.1.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 8f36d9b1ca11..0d422ebb3737 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -16,6 +16,7 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; @@ -73,7 +74,8 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; -const OPENAI_FORM_MESSAGE: &str = "Choose a template"; +const OPENAI_FORM_MESSAGE: &str = "Choose a report"; +const SELECTED_FILE_PATH: &str = "/tmp/report.csv"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_elicitation_round_trip() -> Result<()> { @@ -120,7 +122,17 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_mcp_client_capabilities(McpClientCapabilities { + extensions: Some(serde_json::from_value(json!({ + "openai/form": { + "fieldTypes": ["openai/file"] + } + }))?), + }), + ) + .await??; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { @@ -240,16 +252,12 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let requested_schema = json!({ "type": "object", "properties": { - "template": { - "type": "openai/choice", - "presentation": "cards", - "options": [{ - "value": "monthly-business-review", - "title": "Monthly business review", - }], + "path": { + "type": "openai/file", + "title": "Report", }, }, - "required": ["template"], + "required": ["path"], }); assert_eq!( params, @@ -270,7 +278,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::to_value(McpServerElicitationRequestResponse { action: McpServerElicitationAction::Accept, content: Some(json!({ - "template": "monthly-business-review", + "path": SELECTED_FILE_PATH, })), meta: None, })?, @@ -348,7 +356,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::from_str::(payload)?, json!([{ "type": "text", - "text": "accepted monthly-business-review" + "text": format!("accepted {SELECTED_FILE_PATH}") }]) ); @@ -442,6 +450,17 @@ impl ServerHandler for ElicitationAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + let openai_form_capabilities = context + .peer + .peer_info() + .and_then(|info| info.capabilities.extensions.as_ref()) + .and_then(|extensions| extensions.get("openai/form")) + .expect("Codex should advertise the negotiated openai/form extension"); + assert_eq!( + openai_form_capabilities.get("fieldTypes"), + Some(&json!(["openai/file"])) + ); + let result = context .peer .send_request(McpServerRequest::CustomRequest(CustomRequest::new( @@ -451,16 +470,12 @@ impl ServerHandler for ElicitationAppsMcpServer { "requestedSchema": { "type": "object", "properties": { - "template": { - "type": "openai/choice", - "presentation": "cards", - "options": [{ - "value": "monthly-business-review", - "title": "Monthly business review", - }], + "path": { + "type": "openai/file", + "title": "Report", }, }, - "required": ["template"], + "required": ["path"], }, })), ))) @@ -484,14 +499,14 @@ impl ServerHandler for ElicitationAppsMcpServer { json!({ "action": "accept", "content": { - "template": "monthly-business-review", + "path": SELECTED_FILE_PATH, }, }) ); - Ok(CallToolResult::success(vec![Content::text( - "accepted monthly-business-review", - )])) + Ok(CallToolResult::success(vec![Content::text(format!( + "accepted {SELECTED_FILE_PATH}" + ))])) } } diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index baf11aff5ee8..e769eda31f42 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -314,6 +314,7 @@ async fn start_in_process_client( version: "0.1.0".to_string(), }, capabilities: None, + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index a6ed5e4a636c..ba4829c07c70 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -395,6 +395,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -461,6 +462,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -547,6 +549,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 7267ab073ac9..336c75bc9b15 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -267,6 +267,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { experimental_api: true, ..Default::default() }), + mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 359648656807..92114eb95345 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -14,6 +14,7 @@ use std::time::Duration; use std::time::Instant; use crate::McpAuthStatusEntry; +use crate::McpClientCapabilities; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; @@ -133,6 +134,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, + mcp_client_capabilities: McpClientCapabilities, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -209,6 +211,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), + mcp_client_capabilities.clone(), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 82647a68a9fb..3df76df0cc16 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1266,6 +1266,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), + crate::McpClientCapabilities::default(), ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index b56a4f9072c9..9199e33bab6c 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -22,6 +22,7 @@ pub use catalog::ResolvedMcpCatalog; pub use catalog::ResolvedMcpServer; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; +pub use mcp::McpClientCapabilities; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; pub use server::EffectiveMcpServer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7a585cb5d828..7f969ec29dd5 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -34,6 +34,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; use rmcp::model::ElicitationCapability; +use rmcp::model::ExtensionCapabilities; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -50,6 +51,11 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; +#[derive(Clone, Debug, Default, PartialEq)] +pub struct McpClientCapabilities { + pub extensions: ExtensionCapabilities, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum McpSnapshotDetail { #[default] @@ -305,6 +311,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + McpClientCapabilities::default(), tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -379,6 +386,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + McpClientCapabilities::default(), tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 61fad8d5c5a2..d2d34c690684 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -7,7 +7,6 @@ //! [`crate::connection_manager`]. use std::borrow::Cow; -use std::collections::BTreeMap; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -29,6 +28,7 @@ use crate::codex_apps::normalize_codex_apps_tool_title; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::McpClientCapabilities; use crate::mcp::ToolPluginProvenance; use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; @@ -63,7 +63,6 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; -use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio_util::sync::CancellationToken; @@ -72,7 +71,6 @@ use tracing::warn; /// MCP server capability indicating that Codex should include [`SandboxState`] /// in tool-call request `_meta` under this key. pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; -pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = @@ -154,6 +152,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + mcp_client_capabilities: McpClientCapabilities, ) -> Self { let tool_filter = server .configured_config() @@ -207,6 +206,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + mcp_client_capabilities, }, ) .await @@ -486,18 +486,10 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + mcp_client_capabilities, } = params; - let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(client_elicitation_capability); - capabilities.experimental = Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])); - let params = InitializeRequestParams::new( - capabilities, - Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), - ) - .with_protocol_version(ProtocolVersion::V_2025_06_18); + let params = + mcp_initialize_request_params(client_elicitation_capability, mcp_client_capabilities); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -557,6 +549,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_initialize_request_params( + client_elicitation_capability: ElicitationCapability, + mcp_client_capabilities: McpClientCapabilities, +) -> InitializeRequestParams { + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + if !mcp_client_capabilities.extensions.is_empty() { + capabilities.extensions = Some(mcp_client_capabilities.extensions); + } + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) +} + fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { McpServerInfo { name: server_info.name, @@ -581,6 +589,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, + mcp_client_capabilities: McpClientCapabilities, } async fn make_rmcp_client( @@ -675,6 +684,30 @@ mod tests { use rmcp::model::JsonObject; use rmcp::model::Meta; + #[test] + fn mcp_initialize_advertises_only_negotiated_extensions() { + let without_extensions = mcp_initialize_request_params( + ElicitationCapability::default(), + McpClientCapabilities::default(), + ); + assert_eq!(without_extensions.capabilities.extensions, None); + + let openai_form: rmcp::model::ExtensionCapabilities = + serde_json::from_value(serde_json::json!({ + "openai/form": { + "fieldTypes": ["openai/file"] + } + })) + .expect("valid extension capabilities"); + let with_extensions = mcp_initialize_request_params( + ElicitationCapability::default(), + McpClientCapabilities { + extensions: openai_form.clone(), + }, + ); + assert_eq!(with_extensions.capabilities.extensions, Some(openai_form)); + } + fn tool_with_connector_meta() -> RmcpTool { RmcpTool::new( "capture_file_upload", diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f1c8c5607bd2..ce6742dfae01 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -290,6 +290,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + codex_mcp::McpClientCapabilities::default(), ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9a8a9174a12c..d1944bb8ecba 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1279,6 +1279,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), + codex_mcp::McpClientCapabilities::default(), codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cf6051892163..d61bd35d76e0 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -345,6 +345,12 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; + let mcp_client_capabilities = self + .services + .thread_extension_data + .get::() + .map(|capabilities| capabilities.as_ref().clone()) + .unwrap_or_default(); let refreshed_manager = McpConnectionManager::new( &mcp_servers, store_mode, @@ -361,6 +367,7 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + mcp_client_capabilities, tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 46f20eaf53de..af2aedd7adac 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1110,6 +1110,12 @@ impl Session { } else { ElicitationCapability::default() }; + let mcp_client_capabilities = sess + .services + .thread_extension_data + .get::() + .map(|capabilities| capabilities.as_ref().clone()) + .unwrap_or_default(); let mcp_startup_cancellation_token = { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -1144,6 +1150,7 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, + mcp_client_capabilities, tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2adb713a0e51..754f81833e74 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -709,6 +709,24 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, + ) -> CodexResult { + self.resume_thread_with_history_and_extension_data( + config, + initial_history, + auth_manager, + parent_trace, + ExtensionDataInit::default(), + ) + .await + } + + pub async fn resume_thread_with_history_and_extension_data( + &self, + config: Config, + initial_history: InitialHistory, + auth_manager: Arc, + parent_trace: Option, + thread_extension_init: ExtensionDataInit, ) -> CodexResult { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -732,7 +750,7 @@ impl ThreadManager { /*inherited_exec_policy*/ None, parent_trace, environments, - /*thread_extension_init*/ ExtensionDataInit::default(), + thread_extension_init, /*user_shell_override*/ None, )) .await @@ -907,6 +925,29 @@ impl ThreadManager { thread_source: Option, parent_trace: Option, ) -> CodexResult + where + S: Into, + { + self.fork_thread_from_history_with_extension_data( + snapshot, + config, + history, + thread_source, + parent_trace, + ExtensionDataInit::default(), + ) + .await + } + + pub async fn fork_thread_from_history_with_extension_data( + &self, + snapshot: S, + config: Config, + history: InitialHistory, + thread_source: Option, + parent_trace: Option, + thread_extension_init: ExtensionDataInit, + ) -> CodexResult where S: Into, { @@ -916,6 +957,7 @@ impl ThreadManager { history, thread_source, parent_trace, + thread_extension_init, ) .await } @@ -927,6 +969,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + thread_extension_init: ExtensionDataInit, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -964,7 +1007,7 @@ impl ThreadManager { /*metrics_service_name*/ None, parent_trace, environments, - /*thread_extension_init*/ ExtensionDataInit::default(), + thread_extension_init, /*user_shell_override*/ None, )) .await From 8af05f3753aa2a376f8be2b4ad46bae55f55ec63 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 11 Jun 2026 00:03:02 -0700 Subject: [PATCH 03/13] Avoid panicking in MCP capability test --- .../tests/suite/v2/mcp_server_elicitation.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 0d422ebb3737..a1ce964f1e75 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -450,15 +450,17 @@ impl ServerHandler for ElicitationAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } - let openai_form_capabilities = context - .peer - .peer_info() - .and_then(|info| info.capabilities.extensions.as_ref()) - .and_then(|extensions| extensions.get("openai/form")) - .expect("Codex should advertise the negotiated openai/form extension"); assert_eq!( - openai_form_capabilities.get("fieldTypes"), - Some(&json!(["openai/file"])) + context + .peer + .peer_info() + .and_then(|info| info.capabilities.extensions.as_ref()) + .and_then(|extensions| extensions.get("openai/form")) + .cloned() + .map(Value::Object), + Some(json!({ + "fieldTypes": ["openai/file"] + })) ); let result = context From f8a27acd264f3be9fc1fb9276dd15b70ae40a28b Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 11 Jun 2026 23:11:42 -0700 Subject: [PATCH 04/13] Revert "Negotiate MCP client extensions through app-server" This reverts commit 442a648050ab3aef94afd819f02ad6a725c428d6. --- .../analytics/src/analytics_client_tests.rs | 7 --- codex-rs/app-server-client/src/lib.rs | 1 - codex-rs/app-server-client/src/remote.rs | 1 - codex-rs/app-server-daemon/src/client.rs | 1 - .../schema/json/ClientRequest.json | 27 -------- .../codex_app_server_protocol.schemas.json | 27 -------- .../codex_app_server_protocol.v2.schemas.json | 27 -------- .../schema/json/v1/InitializeParams.json | 27 -------- .../schema/typescript/InitializeParams.ts | 7 +-- .../typescript/McpClientCapabilities.ts | 9 --- .../schema/typescript/index.ts | 1 - codex-rs/app-server-protocol/src/lib.rs | 1 - .../src/protocol/common.rs | 32 ---------- .../app-server-protocol/src/protocol/v1.rs | 14 ----- codex-rs/app-server-test-client/src/lib.rs | 1 - codex-rs/app-server/README.md | 24 +------- codex-rs/app-server/src/in_process.rs | 1 - codex-rs/app-server/src/message_processor.rs | 14 ----- .../src/message_processor_tracing_tests.rs | 1 - codex-rs/app-server/src/request_processors.rs | 1 - .../initialize_processor.rs | 2 - .../request_processors/thread_processor.rs | 35 +---------- .../tests/common/test_app_server.rs | 21 ------- .../tests/suite/conversation_summary.rs | 1 - .../suite/v2/connection_handling_websocket.rs | 1 - .../app-server/tests/suite/v2/mcp_resource.rs | 1 - .../tests/suite/v2/mcp_server_elicitation.rs | 53 ++++++++-------- .../tests/suite/v2/remote_thread_store.rs | 1 - .../app-server/tests/suite/v2/thread_read.rs | 3 - .../tests/suite/v2/thread_unarchive.rs | 1 - codex-rs/codex-mcp/src/connection_manager.rs | 3 - .../codex-mcp/src/connection_manager_tests.rs | 1 - codex-rs/codex-mcp/src/lib.rs | 1 - codex-rs/codex-mcp/src/mcp/mod.rs | 8 --- codex-rs/codex-mcp/src/rmcp_client.rs | 61 +++++-------------- codex-rs/core/src/connectors.rs | 1 - codex-rs/core/src/mcp_tool_call_tests.rs | 1 - codex-rs/core/src/session/mcp.rs | 7 --- codex-rs/core/src/session/session.rs | 7 --- codex-rs/core/src/thread_manager.rs | 47 +------------- 40 files changed, 45 insertions(+), 435 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e00419f5cacb..7c634ce370ad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -516,7 +516,6 @@ async fn ingest_rejected_turn_steer( version: "1.0.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, product_client_id: "codex-web".to_string(), runtime: sample_runtime_metadata(), @@ -578,7 +577,6 @@ async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec AnalyticsFact { request_attestation: false, opt_out_notification_methods: None, }), - mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1664,7 +1661,6 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize request_attestation: false, opt_out_notification_methods: None, }), - mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1814,7 +1810,6 @@ async fn compaction_event_ingests_custom_fact() { request_attestation: false, opt_out_notification_methods: None, }), - mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: sample_runtime_metadata(), @@ -1943,7 +1938,6 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { request_attestation: false, opt_out_notification_methods: None, }), - mcp_client_capabilities: None, }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: sample_runtime_metadata(), @@ -2759,7 +2753,6 @@ async fn subagent_events_use_inherited_connection_unless_turn_connection_is_expl version: "1.0.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, product_client_id: "parent-client".to_string(), runtime: sample_runtime_metadata(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 88e5fde6d14b..bfcf5522d842 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -383,7 +383,6 @@ impl InProcessClientStartArgs { version: self.client_version.clone(), }, capabilities: Some(capabilities), - mcp_client_capabilities: None, } } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index a25d56215643..e1c9f16c4a8d 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -108,7 +108,6 @@ impl RemoteAppServerConnectArgs { version: self.client_version.clone(), }, capabilities: Some(capabilities), - mcp_client_capabilities: None, } } } diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs index 2a85d821ebbf..8c771ba1f2a2 100644 --- a/codex-rs/app-server-daemon/src/client.rs +++ b/codex-rs/app-server-daemon/src/client.rs @@ -94,7 +94,6 @@ where } else { None }, - mcp_client_capabilities: None, })?), trace: None, }); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2c6635136587..3ed2791c8551 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1296,17 +1296,6 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" - }, - "mcpClientCapabilities": { - "anyOf": [ - { - "$ref": "#/definitions/McpClientCapabilities" - }, - { - "type": "null" - } - ], - "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -1557,22 +1546,6 @@ }, "type": "object" }, - "McpClientCapabilities": { - "properties": { - "extensions": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "description": "MCP extension identifiers mapped to their client-provided settings.", - "type": [ - "object", - "null" - ] - } - }, - "type": "object" - }, "McpResourceReadParams": { "properties": { "server": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9093587a3c3d..aac161574453 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2925,17 +2925,6 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" - }, - "mcpClientCapabilities": { - "anyOf": [ - { - "$ref": "#/definitions/McpClientCapabilities" - }, - { - "type": "null" - } - ], - "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -3093,22 +3082,6 @@ "title": "JSONRPCResponse", "type": "object" }, - "McpClientCapabilities": { - "properties": { - "extensions": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "description": "MCP extension identifiers mapped to their client-provided settings.", - "type": [ - "object", - "null" - ] - } - }, - "type": "object" - }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 28968577f98e..e0562760f6b7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7458,17 +7458,6 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" - }, - "mcpClientCapabilities": { - "anyOf": [ - { - "$ref": "#/definitions/McpClientCapabilities" - }, - { - "type": "null" - } - ], - "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ @@ -8235,22 +8224,6 @@ ], "type": "string" }, - "McpClientCapabilities": { - "properties": { - "extensions": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "description": "MCP extension identifiers mapped to their client-provided settings.", - "type": [ - "object", - "null" - ] - } - }, - "type": "object" - }, "McpResourceReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index fdb558c0955e..af5c509249a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -47,22 +47,6 @@ } }, "type": "object" - }, - "McpClientCapabilities": { - "properties": { - "extensions": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "description": "MCP extension identifiers mapped to their client-provided settings.", - "type": [ - "object", - "null" - ] - } - }, - "type": "object" } }, "properties": { @@ -78,17 +62,6 @@ }, "clientInfo": { "$ref": "#/definitions/ClientInfo" - }, - "mcpClientCapabilities": { - "anyOf": [ - { - "$ref": "#/definitions/McpClientCapabilities" - }, - { - "type": "null" - } - ], - "description": "MCP client capabilities that app-server may advertise to downstream MCP servers." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts index 7f58e5238df0..e48c5ee7b52c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeParams.ts @@ -3,10 +3,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ClientInfo } from "./ClientInfo"; import type { InitializeCapabilities } from "./InitializeCapabilities"; -import type { McpClientCapabilities } from "./McpClientCapabilities"; -export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, -/** - * MCP client capabilities that app-server may advertise to downstream MCP servers. - */ -mcpClientCapabilities?: McpClientCapabilities | null, }; +export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts deleted file mode 100644 index e7ab22c0f703..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type McpClientCapabilities = { -/** - * MCP extension identifiers mapped to their client-provided settings. - */ -extensions?: Record>, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index a718e6a07082..c3bc3ec761a1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -46,7 +46,6 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; -export type { McpClientCapabilities } from "./McpClientCapabilities"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index b3d305e0c209..f6d7670e10a2 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -36,7 +36,6 @@ pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; pub use protocol::v1::InterruptConversationResponse; pub use protocol::v1::LoginApiKeyParams; -pub use protocol::v1::McpClientCapabilities; pub use protocol::v1::SandboxSettings; pub use protocol::v1::Tools; pub use protocol::v1::UserSavedConfig; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 98a88fa240a3..7611c27ff1fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1687,7 +1687,6 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; - use std::collections::BTreeMap; use std::path::PathBuf; fn absolute_path_string(path: &str) -> String { @@ -2008,7 +2007,6 @@ mod tests { version: "0.1.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, }; assert_eq!(initialize.serialization_scope(), None); @@ -2169,14 +2167,6 @@ mod tests { "item/agentMessage/delta".to_string(), ]), }), - mcp_client_capabilities: Some(v1::McpClientCapabilities { - extensions: Some(BTreeMap::from([( - "openai/form".to_string(), - serde_json::from_value(json!({ - "fieldTypes": ["openai/file"] - }))?, - )])), - }), }, }; @@ -2197,13 +2187,6 @@ mod tests { "thread/started", "item/agentMessage/delta" ] - }, - "mcpClientCapabilities": { - "extensions": { - "openai/form": { - "fieldTypes": ["openai/file"] - } - } } } }), @@ -2230,13 +2213,6 @@ mod tests { "thread/started", "item/agentMessage/delta" ] - }, - "mcpClientCapabilities": { - "extensions": { - "openai/form": { - "fieldTypes": ["openai/file"] - } - } } } }))?; @@ -2259,14 +2235,6 @@ mod tests { "item/agentMessage/delta".to_string(), ]), }), - mcp_client_capabilities: Some(v1::McpClientCapabilities { - extensions: Some(BTreeMap::from([( - "openai/form".to_string(), - serde_json::from_value(json!({ - "fieldTypes": ["openai/file"] - }))?, - )])), - }), }, } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 3d9935980f9d..f83674d4c37d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; @@ -31,10 +30,6 @@ pub struct InitializeParams { pub client_info: ClientInfo, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, - /// MCP client capabilities that app-server may advertise to downstream MCP servers. - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub mcp_client_capabilities: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] @@ -61,15 +56,6 @@ pub struct InitializeCapabilities { pub opt_out_notification_methods: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct McpClientCapabilities { - /// MCP extension identifiers mapped to their client-provided settings. - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable, type = "Record>")] - pub extensions: Option>>, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 9b64687bb540..b4e09d3e1ed6 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1603,7 +1603,6 @@ impl CodexClient { .collect(), ), }), - mcp_client_capabilities: None, }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dff52928c412..4e5dbbcac6be 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,27 +86,6 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. -Clients that render server-initiated MCP extensions can provide -`initialize.params.mcpClientCapabilities`. App-server merges its `extensions` -map into the MCP client capabilities advertised to servers started for that -connection's threads. For example, a client that renders OpenAI file fields -declares: - -```json -{ - "mcpClientCapabilities": { - "extensions": { - "openai/form": { - "fieldTypes": ["openai/file"] - } - } - } -} -``` - -Clients must omit extensions they cannot handle. App-server does not infer MCP -UI support from `experimentalApi` or other app-server capabilities. - Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If @@ -1491,7 +1470,8 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The -client owns validation and rendering of the `openai/file` field type. +client owns validation and rendering of OpenAI field types such as +`openai/file`, `openai/choice`, and `openai/connector`. For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index feac7f822cc5..938d539a9f69 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -791,7 +791,6 @@ mod tests { version: "0.0.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, channel_capacity, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index bc9f2804ee8b..c32b92c4ea60 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -63,7 +63,6 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; @@ -221,7 +220,6 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, - pub(crate) mcp_client_capabilities: Option, } impl Default for ConnectionSessionState { @@ -273,12 +271,6 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } - pub(crate) fn mcp_client_capabilities(&self) -> Option { - self.initialized - .get() - .and_then(|session| session.mcp_client_capabilities.clone()) - } - pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -883,7 +875,6 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); - let mcp_client_capabilities = session.mcp_client_capabilities(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -899,7 +890,6 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, - mcp_client_capabilities, ) .await; if let Err(error) = result { @@ -929,7 +919,6 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, - mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1082,7 +1071,6 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - mcp_client_capabilities.clone(), request_context, ) .await @@ -1099,7 +1087,6 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - mcp_client_capabilities.clone(), ) .await } @@ -1110,7 +1097,6 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - mcp_client_capabilities.clone(), ) .await } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index f3b7de9e0fd7..771b1fd128b8 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -146,7 +146,6 @@ impl TracingHarness { experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, }, /*trace*/ None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8edb2a50dcdb..cfc411e13080 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -99,7 +99,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpResourceReadResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index 03c3f5e26870..a40007db115a 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,7 +67,6 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let mcp_client_capabilities = params.mcp_client_capabilities.clone(); let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = match params.capabilities { Some(capabilities) => ( @@ -102,7 +101,6 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, - mcp_client_capabilities, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 72eaf61036e9..09ff0dd6981f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -418,7 +418,6 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -426,7 +425,6 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, - mcp_client_capabilities, request_context, ) .await @@ -449,14 +447,12 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, - mcp_client_capabilities, ) .await .map(|()| None) @@ -468,14 +464,12 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, - mcp_client_capabilities, ) .await .map(|()| None) @@ -881,7 +875,6 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -952,7 +945,6 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, - mcp_client_capabilities, config, typesafe_overrides, dynamic_tools, @@ -1026,7 +1018,6 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1132,7 +1123,6 @@ impl ThreadRequestProcessor { thread_extension_init.insert(selected_capability_roots); codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init); } - insert_mcp_client_capabilities(&mut thread_extension_init, mcp_client_capabilities); let create_thread_started_at = std::time::Instant::now(); let NewThread { thread_id, @@ -2529,7 +2519,6 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2669,12 +2658,11 @@ impl ThreadRequestProcessor { match self .thread_manager - .resume_thread_with_history_and_extension_data( + .resume_thread_with_history( config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, - mcp_client_extension_data(mcp_client_capabilities), ) .await { @@ -3292,7 +3280,6 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3392,7 +3379,7 @@ impl ThreadRequestProcessor { .. } = self .thread_manager - .fork_thread_from_history_with_extension_data( + .fork_thread_from_history( ForkSnapshot::Interrupted, config, InitialHistory::Resumed(ResumedHistory { @@ -3402,7 +3389,6 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, - mcp_client_extension_data(mcp_client_capabilities), ) .await .map_err(|err| match err { @@ -3708,23 +3694,6 @@ impl ThreadRequestProcessor { } } -fn mcp_client_extension_data(capabilities: Option) -> ExtensionDataInit { - let mut extension_data = ExtensionDataInit::new(); - insert_mcp_client_capabilities(&mut extension_data, capabilities); - extension_data -} - -fn insert_mcp_client_capabilities( - extension_data: &mut ExtensionDataInit, - capabilities: Option, -) { - if let Some(extensions) = capabilities.and_then(|capabilities| capabilities.extensions) - && !extensions.is_empty() - { - extension_data.insert(codex_mcp::McpClientCapabilities { extensions }); - } -} - fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index c5727df01e87..e940d9a42b07 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -53,7 +53,6 @@ use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceUpgradeParams; -use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; @@ -315,26 +314,6 @@ impl TestAppServer { self.initialize_with_params(InitializeParams { client_info, capabilities, - mcp_client_capabilities: None, - }) - .await - } - - pub async fn initialize_with_mcp_client_capabilities( - &mut self, - mcp_client_capabilities: McpClientCapabilities, - ) -> anyhow::Result { - self.initialize_with_params(InitializeParams { - client_info: ClientInfo { - name: DEFAULT_CLIENT_NAME.to_string(), - title: None, - version: "0.1.0".to_string(), - }, - capabilities: Some(InitializeCapabilities { - experimental_api: true, - ..Default::default() - }), - mcp_client_capabilities: Some(mcp_client_capabilities), }) .await } diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 00bf703a7ffe..6ad9f1c633a7 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -170,7 +170,6 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 3257ac9a97bd..0e00bc630951 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -599,7 +599,6 @@ pub(super) async fn send_initialize_request( version: "0.1.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }; send_request( stream, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index eaff6f01992b..dc3b6d577037 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -523,7 +523,6 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { version: "0.1.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index a1ce964f1e75..438a7c2e2feb 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -16,7 +16,6 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; @@ -74,8 +73,7 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; -const OPENAI_FORM_MESSAGE: &str = "Choose a report"; -const SELECTED_FILE_PATH: &str = "/tmp/report.csv"; +const OPENAI_FORM_MESSAGE: &str = "Choose a template"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_elicitation_round_trip() -> Result<()> { @@ -122,17 +120,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.initialize_with_mcp_client_capabilities(McpClientCapabilities { - extensions: Some(serde_json::from_value(json!({ - "openai/form": { - "fieldTypes": ["openai/file"] - } - }))?), - }), - ) - .await??; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { @@ -252,12 +240,16 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let requested_schema = json!({ "type": "object", "properties": { - "path": { - "type": "openai/file", - "title": "Report", + "template": { + "type": "openai/choice", + "presentation": "cards", + "options": [{ + "value": "monthly-business-review", + "title": "Monthly business review", + }], }, }, - "required": ["path"], + "required": ["template"], }); assert_eq!( params, @@ -278,7 +270,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::to_value(McpServerElicitationRequestResponse { action: McpServerElicitationAction::Accept, content: Some(json!({ - "path": SELECTED_FILE_PATH, + "template": "monthly-business-review", })), meta: None, })?, @@ -356,7 +348,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::from_str::(payload)?, json!([{ "type": "text", - "text": format!("accepted {SELECTED_FILE_PATH}") + "text": "accepted monthly-business-review" }]) ); @@ -462,7 +454,6 @@ impl ServerHandler for ElicitationAppsMcpServer { "fieldTypes": ["openai/file"] })) ); - let result = context .peer .send_request(McpServerRequest::CustomRequest(CustomRequest::new( @@ -472,12 +463,16 @@ impl ServerHandler for ElicitationAppsMcpServer { "requestedSchema": { "type": "object", "properties": { - "path": { - "type": "openai/file", - "title": "Report", + "template": { + "type": "openai/choice", + "presentation": "cards", + "options": [{ + "value": "monthly-business-review", + "title": "Monthly business review", + }], }, }, - "required": ["path"], + "required": ["template"], }, })), ))) @@ -501,14 +496,14 @@ impl ServerHandler for ElicitationAppsMcpServer { json!({ "action": "accept", "content": { - "path": SELECTED_FILE_PATH, + "template": "monthly-business-review", }, }) ); - Ok(CallToolResult::success(vec![Content::text(format!( - "accepted {SELECTED_FILE_PATH}" - ))])) + Ok(CallToolResult::success(vec![Content::text( + "accepted monthly-business-review", + )])) } } diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index e769eda31f42..baf11aff5ee8 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -314,7 +314,6 @@ async fn start_in_process_client( version: "0.1.0".to_string(), }, capabilities: None, - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index ba4829c07c70..a6ed5e4a636c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -395,7 +395,6 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -462,7 +461,6 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -549,7 +547,6 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 336c75bc9b15..7267ab073ac9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -267,7 +267,6 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { experimental_api: true, ..Default::default() }), - mcp_client_capabilities: None, }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 92114eb95345..359648656807 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -14,7 +14,6 @@ use std::time::Duration; use std::time::Instant; use crate::McpAuthStatusEntry; -use crate::McpClientCapabilities; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; @@ -134,7 +133,6 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, - mcp_client_capabilities: McpClientCapabilities, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -211,7 +209,6 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), - mcp_client_capabilities.clone(), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 3df76df0cc16..82647a68a9fb 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1266,7 +1266,6 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), - crate::McpClientCapabilities::default(), ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9199e33bab6c..b56a4f9072c9 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -22,7 +22,6 @@ pub use catalog::ResolvedMcpCatalog; pub use catalog::ResolvedMcpServer; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; -pub use mcp::McpClientCapabilities; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; pub use server::EffectiveMcpServer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7f969ec29dd5..7a585cb5d828 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -34,7 +34,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; use rmcp::model::ElicitationCapability; -use rmcp::model::ExtensionCapabilities; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -51,11 +50,6 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; -#[derive(Clone, Debug, Default, PartialEq)] -pub struct McpClientCapabilities { - pub extensions: ExtensionCapabilities, -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum McpSnapshotDetail { #[default] @@ -311,7 +305,6 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - McpClientCapabilities::default(), tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -386,7 +379,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - McpClientCapabilities::default(), tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index d2d34c690684..61fad8d5c5a2 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -7,6 +7,7 @@ //! [`crate::connection_manager`]. use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -28,7 +29,6 @@ use crate::codex_apps::normalize_codex_apps_tool_title; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp::McpClientCapabilities; use crate::mcp::ToolPluginProvenance; use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; @@ -63,6 +63,7 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; +use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio_util::sync::CancellationToken; @@ -71,6 +72,7 @@ use tracing::warn; /// MCP server capability indicating that Codex should include [`SandboxState`] /// in tool-call request `_meta` under this key. pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = @@ -152,7 +154,6 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, - mcp_client_capabilities: McpClientCapabilities, ) -> Self { let tool_filter = server .configured_config() @@ -206,7 +207,6 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - mcp_client_capabilities, }, ) .await @@ -486,10 +486,18 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - mcp_client_capabilities, } = params; - let params = - mcp_initialize_request_params(client_elicitation_capability, mcp_client_capabilities); + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + capabilities.experimental = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -549,22 +557,6 @@ async fn start_server_task( Ok(managed) } -fn mcp_initialize_request_params( - client_elicitation_capability: ElicitationCapability, - mcp_client_capabilities: McpClientCapabilities, -) -> InitializeRequestParams { - let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(client_elicitation_capability); - if !mcp_client_capabilities.extensions.is_empty() { - capabilities.extensions = Some(mcp_client_capabilities.extensions); - } - InitializeRequestParams::new( - capabilities, - Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), - ) - .with_protocol_version(ProtocolVersion::V_2025_06_18) -} - fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { McpServerInfo { name: server_info.name, @@ -589,7 +581,6 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, - mcp_client_capabilities: McpClientCapabilities, } async fn make_rmcp_client( @@ -684,30 +675,6 @@ mod tests { use rmcp::model::JsonObject; use rmcp::model::Meta; - #[test] - fn mcp_initialize_advertises_only_negotiated_extensions() { - let without_extensions = mcp_initialize_request_params( - ElicitationCapability::default(), - McpClientCapabilities::default(), - ); - assert_eq!(without_extensions.capabilities.extensions, None); - - let openai_form: rmcp::model::ExtensionCapabilities = - serde_json::from_value(serde_json::json!({ - "openai/form": { - "fieldTypes": ["openai/file"] - } - })) - .expect("valid extension capabilities"); - let with_extensions = mcp_initialize_request_params( - ElicitationCapability::default(), - McpClientCapabilities { - extensions: openai_form.clone(), - }, - ); - assert_eq!(with_extensions.capabilities.extensions, Some(openai_form)); - } - fn tool_with_connector_meta() -> RmcpTool { RmcpTool::new( "capture_file_upload", diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index ce6742dfae01..f1c8c5607bd2 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -290,7 +290,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - codex_mcp::McpClientCapabilities::default(), ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index d1944bb8ecba..9a8a9174a12c 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1279,7 +1279,6 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), - codex_mcp::McpClientCapabilities::default(), codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d61bd35d76e0..cf6051892163 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -345,12 +345,6 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; - let mcp_client_capabilities = self - .services - .thread_extension_data - .get::() - .map(|capabilities| capabilities.as_ref().clone()) - .unwrap_or_default(); let refreshed_manager = McpConnectionManager::new( &mcp_servers, store_mode, @@ -367,7 +361,6 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - mcp_client_capabilities, tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index af2aedd7adac..46f20eaf53de 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1110,12 +1110,6 @@ impl Session { } else { ElicitationCapability::default() }; - let mcp_client_capabilities = sess - .services - .thread_extension_data - .get::() - .map(|capabilities| capabilities.as_ref().clone()) - .unwrap_or_default(); let mcp_startup_cancellation_token = { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -1150,7 +1144,6 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, - mcp_client_capabilities, tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 754f81833e74..2adb713a0e51 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -709,24 +709,6 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, - ) -> CodexResult { - self.resume_thread_with_history_and_extension_data( - config, - initial_history, - auth_manager, - parent_trace, - ExtensionDataInit::default(), - ) - .await - } - - pub async fn resume_thread_with_history_and_extension_data( - &self, - config: Config, - initial_history: InitialHistory, - auth_manager: Arc, - parent_trace: Option, - thread_extension_init: ExtensionDataInit, ) -> CodexResult { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -750,7 +732,7 @@ impl ThreadManager { /*inherited_exec_policy*/ None, parent_trace, environments, - thread_extension_init, + /*thread_extension_init*/ ExtensionDataInit::default(), /*user_shell_override*/ None, )) .await @@ -925,29 +907,6 @@ impl ThreadManager { thread_source: Option, parent_trace: Option, ) -> CodexResult - where - S: Into, - { - self.fork_thread_from_history_with_extension_data( - snapshot, - config, - history, - thread_source, - parent_trace, - ExtensionDataInit::default(), - ) - .await - } - - pub async fn fork_thread_from_history_with_extension_data( - &self, - snapshot: S, - config: Config, - history: InitialHistory, - thread_source: Option, - parent_trace: Option, - thread_extension_init: ExtensionDataInit, - ) -> CodexResult where S: Into, { @@ -957,7 +916,6 @@ impl ThreadManager { history, thread_source, parent_trace, - thread_extension_init, ) .await } @@ -969,7 +927,6 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, - thread_extension_init: ExtensionDataInit, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -1007,7 +964,7 @@ impl ThreadManager { /*metrics_service_name*/ None, parent_trace, environments, - thread_extension_init, + /*thread_extension_init*/ ExtensionDataInit::default(), /*user_shell_override*/ None, )) .await From 9740580b82fabb004ce9abe6442a247d1ae2f075 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 11 Jun 2026 23:41:43 -0700 Subject: [PATCH 05/13] Advertise OpenAI form support through app-server capability --- .../analytics/src/analytics_client_tests.rs | 4 + codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server-client/src/remote.rs | 1 + .../schema/json/ClientRequest.json | 5 + .../codex_app_server_protocol.schemas.json | 5 + .../codex_app_server_protocol.v2.schemas.json | 5 + .../schema/json/v1/InitializeParams.json | 5 + .../typescript/InitializeCapabilities.ts | 4 + .../src/protocol/common.rs | 8 +- .../app-server-protocol/src/protocol/v1.rs | 3 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 10 +- codex-rs/app-server/src/message_processor.rs | 16 +++ .../initialize_processor.rs | 37 ++++-- .../request_processors/thread_processor.rs | 38 ++++++- .../app-server/tests/suite/v2/attestation.rs | 1 + .../tests/suite/v2/experimental_api.rs | 8 ++ .../app-server/tests/suite/v2/initialize.rs | 1 + .../tests/suite/v2/mcp_server_elicitation.rs | 19 +++- .../tests/suite/v2/thread_status.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 3 + .../codex-mcp/src/connection_manager_tests.rs | 1 + codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 9 ++ codex-rs/codex-mcp/src/rmcp_client.rs | 69 ++++++++++-- codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 7 ++ codex-rs/core/src/session/session.rs | 7 ++ codex-rs/core/src/thread_manager.rs | 47 +++++++- .../src/elicitation_client_service.rs | 11 +- codex-rs/rmcp-client/src/rmcp_client.rs | 106 ++++++++++++++++++ 32 files changed, 404 insertions(+), 32 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7c634ce370ad..a49724ece824 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -766,6 +766,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1660,6 +1661,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1809,6 +1811,7 @@ async fn compaction_event_ingests_custom_fact() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1937,6 +1940,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d842..8d4378711b03 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -374,6 +374,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_open_ai_form_elicitation: false, }; InitializeParams { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index e1c9f16c4a8d..57de1ebb45bd 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -99,6 +99,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_open_ai_form_elicitation: false, }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ed2791c8551..862194121a78 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1264,6 +1264,11 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenAiFormElicitation": { + "default": false, + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index aac161574453..dad79f2e633c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2892,6 +2892,11 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenAiFormElicitation": { + "default": false, + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e0562760f6b7..255d4ac43085 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7425,6 +7425,11 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenAiFormElicitation": { + "default": false, + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index af5c509249a2..257f4ec1f3d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,6 +30,11 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenAiFormElicitation": { + "default": false, + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index c5043e3b64fc..6da7dd5671d0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -14,6 +14,10 @@ experimentalApi: boolean, * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. */ requestAttestation: boolean, +/** + * Allow downstream MCP servers to request OpenAI extended form elicitations. + */ +mcpServerOpenAiFormElicitation: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7611c27ff1fc..f8a350806512 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2150,7 +2150,7 @@ mod tests { } #[test] - fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn serialize_initialize_capabilities() -> Result<()> { let request = ClientRequest::Initialize { request_id: RequestId::Integer(42), params: v1::InitializeParams { @@ -2162,6 +2162,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_open_ai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2183,6 +2184,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenAiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2196,7 +2198,7 @@ mod tests { } #[test] - fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn deserialize_initialize_capabilities() -> Result<()> { let request: ClientRequest = serde_json::from_value(json!({ "method": "initialize", "id": 42, @@ -2209,6 +2211,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenAiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2230,6 +2233,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_open_ai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c37d..b622fb61f3b5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -50,6 +50,9 @@ pub struct InitializeCapabilities { /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. #[serde(default)] pub request_attestation: bool, + /// Allow downstream MCP servers to request OpenAI extended form elicitations. + #[serde(default)] + pub mcp_server_open_ai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index b4e09d3e1ed6..3251bf49de84 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1602,6 +1602,7 @@ impl CodexClient { .map(|method| (*method).to_string()) .collect(), ), + mcp_server_open_ai_form_elicitation: false, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e5dbbcac6be..f397b5caff7b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,6 +86,13 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. +Clients that render OpenAI extended MCP forms set +`initialize.params.capabilities.mcpServerOpenAiFormElicitation` to `true`. +App-server then advertises the downstream `openai/form` MCP extension with the +`openai/file` field type for threads started, resumed, or forked by that +connection. Clients that cannot render these forms omit the field or set it to +`false`. + Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If @@ -1470,8 +1477,7 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The -client owns validation and rendering of OpenAI field types such as -`openai/file`, `openai/choice`, and `openai/connector`. +client owns validation and rendering of the `openai/file` field type. For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c32b92c4ea60..df47be3b067e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -220,6 +220,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, + pub(crate) open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, } impl Default for ConnectionSessionState { @@ -271,6 +272,15 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } + pub(crate) fn open_ai_form_elicitation_capability( + &self, + ) -> codex_mcp::OpenAiFormElicitationCapability { + self.initialized + .get() + .map(|session| session.open_ai_form_elicitation_capability) + .unwrap_or_default() + } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -875,6 +885,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); + let open_ai_form_elicitation_capability = session.open_ai_form_elicitation_capability(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -890,6 +901,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, + open_ai_form_elicitation_capability, ) .await; if let Err(error) = result { @@ -919,6 +931,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1071,6 +1084,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + open_ai_form_elicitation_capability, request_context, ) .await @@ -1087,6 +1101,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + open_ai_form_elicitation_capability, ) .await } @@ -1097,6 +1112,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + open_ai_form_elicitation_capability, ) .await } diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a40007db115a..306d02cd6cfb 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,17 +67,31 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = - match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities.request_attestation, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, false, Vec::new()), - }; + let ( + experimental_api_enabled, + request_attestation, + open_ai_form_elicitation_capability, + opt_out_notification_methods, + ) = match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities.request_attestation, + if capabilities.mcp_server_open_ai_form_elicitation { + codex_mcp::OpenAiFormElicitationCapability::Supported + } else { + codex_mcp::OpenAiFormElicitationCapability::Unsupported + }, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => ( + false, + false, + codex_mcp::OpenAiFormElicitationCapability::Unsupported, + Vec::new(), + ), + }; let ClientInfo { name, title: _title, @@ -101,6 +115,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, + open_ai_form_elicitation_capability, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 09ff0dd6981f..f99a6bec2f66 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -418,6 +418,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -425,6 +426,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, + open_ai_form_elicitation_capability, request_context, ) .await @@ -447,12 +449,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, + open_ai_form_elicitation_capability, ) .await .map(|()| None) @@ -464,12 +468,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, + open_ai_form_elicitation_capability, ) .await .map(|()| None) @@ -875,6 +881,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -945,6 +952,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, + open_ai_form_elicitation_capability, config, typesafe_overrides, dynamic_tools, @@ -1018,6 +1026,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1123,6 +1132,10 @@ impl ThreadRequestProcessor { thread_extension_init.insert(selected_capability_roots); codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init); } + insert_open_ai_form_elicitation_capability( + &mut thread_extension_init, + open_ai_form_elicitation_capability, + ); let create_thread_started_at = std::time::Instant::now(); let NewThread { thread_id, @@ -2519,6 +2532,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2658,11 +2672,12 @@ impl ThreadRequestProcessor { match self .thread_manager - .resume_thread_with_history( + .resume_thread_with_history_and_extension_data( config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, + open_ai_form_elicitation_extension_data(open_ai_form_elicitation_capability), ) .await { @@ -3280,6 +3295,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3379,7 +3395,7 @@ impl ThreadRequestProcessor { .. } = self .thread_manager - .fork_thread_from_history( + .fork_thread_from_history_with_extension_data( ForkSnapshot::Interrupted, config, InitialHistory::Resumed(ResumedHistory { @@ -3389,6 +3405,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, + open_ai_form_elicitation_extension_data(open_ai_form_elicitation_capability), ) .await .map_err(|err| match err { @@ -3694,6 +3711,23 @@ impl ThreadRequestProcessor { } } +fn open_ai_form_elicitation_extension_data( + capability: codex_mcp::OpenAiFormElicitationCapability, +) -> ExtensionDataInit { + let mut extension_data = ExtensionDataInit::new(); + insert_open_ai_form_elicitation_capability(&mut extension_data, capability); + extension_data +} + +fn insert_open_ai_form_elicitation_capability( + extension_data: &mut ExtensionDataInit, + capability: codex_mcp::OpenAiFormElicitationCapability, +) { + if capability == codex_mcp::OpenAiFormElicitationCapability::Supported { + extension_data.insert(capability); + } +} + fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 567f37397a9a..25f2098ecb4b 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -90,6 +90,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand experimental_api: true, request_attestation: true, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index b12bd43b439e..c4747756db91 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -114,6 +116,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -148,6 +151,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -182,6 +186,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -230,6 +235,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -268,6 +274,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; @@ -305,6 +312,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_open_ai_form_elicitation: false, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 0cf8504d3bb5..c065e3af65d1 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), + mcp_server_open_ai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 438a7c2e2feb..d2e492792b91 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -14,6 +14,8 @@ use axum::http::StatusCode; use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpElicitationSchema; @@ -120,7 +122,22 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + mcp_server_open_ai_form_elicitation: true, + ..Default::default() + }), + ), + ) + .await??; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index b8349a77f192..3db1531a731c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), + mcp_server_open_ai_form_elicitation: false, }), ), ) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 359648656807..711010db003b 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -14,6 +14,7 @@ use std::time::Duration; use std::time::Instant; use crate::McpAuthStatusEntry; +use crate::OpenAiFormElicitationCapability; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; @@ -133,6 +134,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, + open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -209,6 +211,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), + open_ai_form_elicitation_capability, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 82647a68a9fb..3b71d95ccc52 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1266,6 +1266,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), + crate::OpenAiFormElicitationCapability::Unsupported, ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index b56a4f9072c9..af11acd7f8b7 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -23,6 +23,7 @@ pub use catalog::ResolvedMcpServer; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; +pub use mcp::OpenAiFormElicitationCapability; pub use mcp::ToolPluginProvenance; pub use server::EffectiveMcpServer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7a585cb5d828..401cd957f1c3 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -50,6 +50,13 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OpenAiFormElicitationCapability { + #[default] + Unsupported, + Supported, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum McpSnapshotDetail { #[default] @@ -305,6 +312,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + OpenAiFormElicitationCapability::Unsupported, tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -379,6 +387,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + OpenAiFormElicitationCapability::Unsupported, tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 61fad8d5c5a2..141456f24ef1 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -29,6 +29,7 @@ use crate::codex_apps::normalize_codex_apps_tool_title; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::OpenAiFormElicitationCapability; use crate::mcp::ToolPluginProvenance; use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; @@ -154,6 +155,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, ) -> Self { let tool_filter = server .configured_config() @@ -207,6 +209,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + open_ai_form_elicitation_capability, }, ) .await @@ -486,18 +489,12 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + open_ai_form_elicitation_capability, } = params; - let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(client_elicitation_capability); - capabilities.experimental = Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])); - let params = InitializeRequestParams::new( - capabilities, - Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), - ) - .with_protocol_version(ProtocolVersion::V_2025_06_18); + let params = mcp_initialize_request_params( + client_elicitation_capability, + open_ai_form_elicitation_capability, + ); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -557,6 +554,31 @@ async fn start_server_task( Ok(managed) } +fn mcp_initialize_request_params( + client_elicitation_capability: ElicitationCapability, + open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, +) -> InitializeRequestParams { + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + match open_ai_form_elicitation_capability { + OpenAiFormElicitationCapability::Unsupported => {} + OpenAiFormElicitationCapability::Supported => { + capabilities.extensions = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::from_iter([( + "fieldTypes".to_string(), + serde_json::json!(["openai/file"]), + )]), + )])); + } + } + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) +} + fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { McpServerInfo { name: server_info.name, @@ -581,6 +603,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, + open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, } async fn make_rmcp_client( @@ -675,6 +698,30 @@ mod tests { use rmcp::model::JsonObject; use rmcp::model::Meta; + #[test] + fn mcp_initialize_advertises_openai_form_only_when_supported() { + let unsupported = mcp_initialize_request_params( + ElicitationCapability::default(), + OpenAiFormElicitationCapability::Unsupported, + ); + assert_eq!(unsupported.capabilities.extensions, None); + + let supported = mcp_initialize_request_params( + ElicitationCapability::default(), + OpenAiFormElicitationCapability::Supported, + ); + assert_eq!( + supported.capabilities.extensions, + Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::from_iter([( + "fieldTypes".to_string(), + serde_json::json!(["openai/file"]), + )]), + )])) + ); + } + fn tool_with_connector_meta() -> RmcpTool { RmcpTool::new( "capture_file_upload", diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f1c8c5607bd2..72bafbc0039c 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -290,6 +290,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + codex_mcp::OpenAiFormElicitationCapability::Unsupported, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9a8a9174a12c..e0819aada293 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1279,6 +1279,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), + codex_mcp::OpenAiFormElicitationCapability::Unsupported, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cf6051892163..06b33eecc26a 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -345,6 +345,12 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; + let open_ai_form_elicitation_capability = self + .services + .thread_extension_data + .get::() + .map(|capability| *capability.as_ref()) + .unwrap_or_default(); let refreshed_manager = McpConnectionManager::new( &mcp_servers, store_mode, @@ -361,6 +367,7 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + open_ai_form_elicitation_capability, tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 46f20eaf53de..1b33096b9c22 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1110,6 +1110,12 @@ impl Session { } else { ElicitationCapability::default() }; + let open_ai_form_elicitation_capability = sess + .services + .thread_extension_data + .get::() + .map(|capability| *capability.as_ref()) + .unwrap_or_default(); let mcp_startup_cancellation_token = { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -1144,6 +1150,7 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, + open_ai_form_elicitation_capability, tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2adb713a0e51..754f81833e74 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -709,6 +709,24 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, + ) -> CodexResult { + self.resume_thread_with_history_and_extension_data( + config, + initial_history, + auth_manager, + parent_trace, + ExtensionDataInit::default(), + ) + .await + } + + pub async fn resume_thread_with_history_and_extension_data( + &self, + config: Config, + initial_history: InitialHistory, + auth_manager: Arc, + parent_trace: Option, + thread_extension_init: ExtensionDataInit, ) -> CodexResult { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -732,7 +750,7 @@ impl ThreadManager { /*inherited_exec_policy*/ None, parent_trace, environments, - /*thread_extension_init*/ ExtensionDataInit::default(), + thread_extension_init, /*user_shell_override*/ None, )) .await @@ -907,6 +925,29 @@ impl ThreadManager { thread_source: Option, parent_trace: Option, ) -> CodexResult + where + S: Into, + { + self.fork_thread_from_history_with_extension_data( + snapshot, + config, + history, + thread_source, + parent_trace, + ExtensionDataInit::default(), + ) + .await + } + + pub async fn fork_thread_from_history_with_extension_data( + &self, + snapshot: S, + config: Config, + history: InitialHistory, + thread_source: Option, + parent_trace: Option, + thread_extension_init: ExtensionDataInit, + ) -> CodexResult where S: Into, { @@ -916,6 +957,7 @@ impl ThreadManager { history, thread_source, parent_trace, + thread_extension_init, ) .await } @@ -927,6 +969,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + thread_extension_init: ExtensionDataInit, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -964,7 +1007,7 @@ impl ThreadManager { /*metrics_service_name*/ None, parent_trace, environments, - /*thread_extension_init*/ ExtensionDataInit::default(), + thread_extension_init, /*user_shell_override*/ None, )) .await diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index a50e0d630df9..5b9642a84bf2 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -39,6 +39,7 @@ struct OpenAiFormRequestParams { #[derive(Clone)] pub(crate) struct ElicitationClientService { handler: LoggingClientHandler, + supports_openai_form: bool, send_elicitation: Arc, pause_state: ElicitationPauseState, } @@ -49,12 +50,18 @@ impl ElicitationClientService { send_elicitation: SendElicitation, pause_state: ElicitationPauseState, ) -> Self { + let supports_openai_form = client_info + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD)); let send_elicitation = Arc::new(send_elicitation); Self { handler: LoggingClientHandler::new( client_info, clone_send_elicitation(Arc::clone(&send_elicitation)), ), + supports_openai_form, send_elicitation, pause_state, } @@ -93,7 +100,9 @@ impl Service for ElicitationClientService { let result = elicitation_response_result(response)?; Ok(ClientResult::CustomResult(result)) } - ServerRequest::CustomRequest(request) if request.method == OPENAI_FORM_METHOD => { + ServerRequest::CustomRequest(request) + if request.method == OPENAI_FORM_METHOD && self.supports_openai_form => + { let response = self .create_elicitation(openai_form_elicitation(request)?, context) .await?; diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 32f53b34b38a..0857dab5eee6 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -1199,13 +1199,60 @@ async fn create_oauth_transport_and_runtime( #[cfg(test)] mod tests { + use std::sync::atomic::AtomicBool; use std::time::Duration; use pretty_assertions::assert_eq; + use rmcp::ServerHandler; + use rmcp::ServiceExt; + use rmcp::model::ClientCapabilities; + use rmcp::model::ClientResult; + use rmcp::model::ErrorCode; + use rmcp::model::Implementation; + use rmcp::model::ServerRequest; + use rmcp::service::NotificationContext; + use rmcp::service::RoleServer; + use rmcp::service::ServiceError; + use tokio::sync::oneshot; use tokio::time; use super::*; + struct OpenAiFormServer { + response: Mutex>>>, + } + + impl ServerHandler for OpenAiFormServer { + async fn on_initialized(&self, context: NotificationContext) { + let response = self + .response + .lock() + .await + .take() + .expect("response sender should be available"); + tokio::spawn(async move { + let result = context + .peer + .send_request(ServerRequest::CustomRequest(CustomRequest::new( + "openai/form", + Some(serde_json::json!({ + "message": "Choose a file", + "requestedSchema": { + "type": "object", + "properties": { + "path": { "type": "openai/file" } + } + } + })), + ))) + .await; + response + .send(result) + .expect("response receiver should be available"); + }); + } + } + #[tokio::test] async fn active_time_timeout_pauses_while_elicitation_is_pending() { let pause_state = ElicitationPauseState::new(); @@ -1224,4 +1271,63 @@ mod tests { assert_eq!(Ok("done"), result); } + + #[tokio::test] + async fn openai_form_is_rejected_when_not_advertised() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let (response_tx, response_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let server = OpenAiFormServer { + response: Mutex::new(Some(response_tx)), + } + .serve(server_transport) + .await + .expect("server should initialize"); + server.waiting().await.expect("server should shut down"); + }); + + let elicitation_called = Arc::new(AtomicBool::new(false)); + let callback_called = Arc::clone(&elicitation_called); + let client = time::timeout( + Duration::from_secs(5), + service::serve_client( + ElicitationClientService::new( + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test-client", "1.0.0"), + ), + Box::new(move |_, _| { + callback_called.store(true, Ordering::Relaxed); + Box::pin(async { Err(anyhow!("unexpected elicitation")) }) + }), + ElicitationPauseState::new(), + ), + client_transport, + ), + ) + .await + .expect("client initialization should not time out") + .expect("client should initialize"); + + let error = time::timeout(Duration::from_secs(5), response_rx) + .await + .expect("server response should not time out") + .expect("server should send a response") + .expect_err("openai/form should be rejected"); + let ServiceError::McpError(error) = error else { + panic!("expected MCP error, got {error:?}"); + }; + assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); + assert_eq!("openai/form", error.message); + assert!(!elicitation_called.load(Ordering::Relaxed)); + + time::timeout(Duration::from_secs(5), client.cancel()) + .await + .expect("client shutdown should not time out") + .expect("client should shut down"); + time::timeout(Duration::from_secs(5), server_task) + .await + .expect("server shutdown should not time out") + .expect("server task should complete"); + } } From 03c70e9acb8bbe308496707595d176ff868fb201 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 12 Jun 2026 14:00:26 -0700 Subject: [PATCH 06/13] Make OpenAI form capability additive --- .../schema/json/ClientRequest.json | 1 - .../json/codex_app_server_protocol.schemas.json | 1 - .../json/codex_app_server_protocol.v2.schemas.json | 1 - .../schema/json/v1/InitializeParams.json | 1 - .../schema/typescript/InitializeCapabilities.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v1.rs | 2 +- codex-rs/app-server/README.md | 13 +++++++------ .../tests/suite/v2/mcp_server_elicitation.rs | 4 +--- codex-rs/codex-mcp/src/rmcp_client.rs | 10 ++-------- 9 files changed, 12 insertions(+), 23 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 862194121a78..9ae7f3159c1b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1265,7 +1265,6 @@ "type": "boolean" }, "mcpServerOpenAiFormElicitation": { - "default": false, "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index dad79f2e633c..52853c662a12 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2893,7 +2893,6 @@ "type": "boolean" }, "mcpServerOpenAiFormElicitation": { - "default": false, "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 255d4ac43085..f94766dad993 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7426,7 +7426,6 @@ "type": "boolean" }, "mcpServerOpenAiFormElicitation": { - "default": false, "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 257f4ec1f3d9..51a274c18e0a 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -31,7 +31,6 @@ "type": "boolean" }, "mcpServerOpenAiFormElicitation": { - "default": false, "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 6da7dd5671d0..2be794f976fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -17,7 +17,7 @@ requestAttestation: boolean, /** * Allow downstream MCP servers to request OpenAI extended form elicitations. */ -mcpServerOpenAiFormElicitation: boolean, +mcpServerOpenAiFormElicitation?: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index b622fb61f3b5..ff6cc0d8f533 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -51,7 +51,7 @@ pub struct InitializeCapabilities { #[serde(default)] pub request_attestation: bool, /// Allow downstream MCP servers to request OpenAI extended form elicitations. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub mcp_server_open_ai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f397b5caff7b..d7f97ceafe4f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,12 +86,12 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. -Clients that render OpenAI extended MCP forms set +Clients that handle OpenAI extended MCP forms, including a fallback for +unsupported field types, set `initialize.params.capabilities.mcpServerOpenAiFormElicitation` to `true`. -App-server then advertises the downstream `openai/form` MCP extension with the -`openai/file` field type for threads started, resumed, or forked by that -connection. Clients that cannot render these forms omit the field or set it to -`false`. +App-server then advertises the downstream `openai/form` MCP extension for +threads started, resumed, or forked by that connection. Clients that cannot +handle the request envelope omit the field or set it to `false`. Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. @@ -1477,7 +1477,8 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The -client owns validation and rendering of the `openai/file` field type. +client owns validation and rendering of supported field types and must return a +valid `decline` or `cancel` response when it cannot render a form. For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index d2e492792b91..e6588b6831ea 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -467,9 +467,7 @@ impl ServerHandler for ElicitationAppsMcpServer { .and_then(|extensions| extensions.get("openai/form")) .cloned() .map(Value::Object), - Some(json!({ - "fieldTypes": ["openai/file"] - })) + Some(json!({})) ); let result = context .peer diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 141456f24ef1..6ac8c2c47c57 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -565,10 +565,7 @@ fn mcp_initialize_request_params( OpenAiFormElicitationCapability::Supported => { capabilities.extensions = Some(BTreeMap::from([( OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::from_iter([( - "fieldTypes".to_string(), - serde_json::json!(["openai/file"]), - )]), + JsonObject::new(), )])); } } @@ -714,10 +711,7 @@ mod tests { supported.capabilities.extensions, Some(BTreeMap::from([( OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::from_iter([( - "fieldTypes".to_string(), - serde_json::json!(["openai/file"]), - )]), + JsonObject::new(), )])) ); } From 0e9a4ea0dcef60fb72c28c450ad6a711d494d722 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 16 Jun 2026 11:53:14 -0700 Subject: [PATCH 07/13] Keep OpenAI form tests file-only --- .../tests/suite/v2/mcp_server_elicitation.rs | 34 +++++++------------ .../src/elicitation_client_service.rs | 14 ++++---- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index e6588b6831ea..bb4bf0d4685e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -75,7 +75,7 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; -const OPENAI_FORM_MESSAGE: &str = "Choose a template"; +const OPENAI_FORM_MESSAGE: &str = "Choose a file"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_elicitation_round_trip() -> Result<()> { @@ -257,16 +257,12 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let requested_schema = json!({ "type": "object", "properties": { - "template": { - "type": "openai/choice", - "presentation": "cards", - "options": [{ - "value": "monthly-business-review", - "title": "Monthly business review", - }], + "path": { + "type": "openai/file", + "title": "Source file", }, }, - "required": ["template"], + "required": ["path"], }); assert_eq!( params, @@ -287,7 +283,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::to_value(McpServerElicitationRequestResponse { action: McpServerElicitationAction::Accept, content: Some(json!({ - "template": "monthly-business-review", + "path": "/tmp/report.csv", })), meta: None, })?, @@ -365,7 +361,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::from_str::(payload)?, json!([{ "type": "text", - "text": "accepted monthly-business-review" + "text": "accepted /tmp/report.csv" }]) ); @@ -478,16 +474,12 @@ impl ServerHandler for ElicitationAppsMcpServer { "requestedSchema": { "type": "object", "properties": { - "template": { - "type": "openai/choice", - "presentation": "cards", - "options": [{ - "value": "monthly-business-review", - "title": "Monthly business review", - }], + "path": { + "type": "openai/file", + "title": "Source file", }, }, - "required": ["template"], + "required": ["path"], }, })), ))) @@ -511,13 +503,13 @@ impl ServerHandler for ElicitationAppsMcpServer { json!({ "action": "accept", "content": { - "template": "monthly-business-review", + "path": "/tmp/report.csv", }, }) ); Ok(CallToolResult::success(vec![Content::text( - "accepted monthly-business-review", + "accepted /tmp/report.csv", )])) } } diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 5b9642a84bf2..68df173e6ed7 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -239,13 +239,12 @@ mod tests { let elicitation = openai_form_elicitation(CustomRequest::new( OPENAI_FORM_METHOD, Some(json!({ - "message": "Choose a template", + "message": "Choose a file", "requestedSchema": { "type": "object", "properties": { - "template": { - "type": "openai/choice", - "options": [] + "path": { + "type": "openai/file" } } } @@ -257,13 +256,12 @@ mod tests { elicitation, Elicitation::OpenAiForm { meta: None, - message: "Choose a template".to_string(), + message: "Choose a file".to_string(), requested_schema: json!({ "type": "object", "properties": { - "template": { - "type": "openai/choice", - "options": [] + "path": { + "type": "openai/file" } } }), From fc755697d72908c9a2673bb4345cd222c3788954 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 16 Jun 2026 14:02:45 -0700 Subject: [PATCH 08/13] Update OpenAI form fixtures for image picker --- .../src/protocol/v2/tests.rs | 13 +++++-- .../tests/suite/v2/mcp_server_elicitation.rs | 38 ++++++++++++------- .../src/elicitation_client_service.rs | 22 ++++++++--- codex-rs/rmcp-client/src/rmcp_client.rs | 11 +++++- 4 files changed, 59 insertions(+), 25 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index cd8d890c0a81..412c1ca48da8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1904,12 +1904,17 @@ fn mcp_server_elicitation_request_from_core_openai_form_request() { let requested_schema = json!({ "type": "object", "properties": { - "path": { - "type": "openai/file", - "title": "Source file", + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=", + }], }, }, - "required": ["path"], + "required": ["template"], }); let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm { meta: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index bb4bf0d4685e..eaa38db6cb3d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -75,7 +75,9 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; -const OPENAI_FORM_MESSAGE: &str = "Choose a file"; +const OPENAI_FORM_MESSAGE: &str = "Select a template"; +const IMAGE_DATA_URL: &str = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_elicitation_round_trip() -> Result<()> { @@ -257,12 +259,17 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let requested_schema = json!({ "type": "object", "properties": { - "path": { - "type": "openai/file", - "title": "Source file", + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], }, }, - "required": ["path"], + "required": ["template"], }); assert_eq!( params, @@ -283,7 +290,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::to_value(McpServerElicitationRequestResponse { action: McpServerElicitationAction::Accept, content: Some(json!({ - "path": "/tmp/report.csv", + "template": "monthly-review", })), meta: None, })?, @@ -361,7 +368,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { serde_json::from_str::(payload)?, json!([{ "type": "text", - "text": "accepted /tmp/report.csv" + "text": "accepted monthly-review" }]) ); @@ -474,12 +481,17 @@ impl ServerHandler for ElicitationAppsMcpServer { "requestedSchema": { "type": "object", "properties": { - "path": { - "type": "openai/file", - "title": "Source file", + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], }, }, - "required": ["path"], + "required": ["template"], }, })), ))) @@ -503,13 +515,13 @@ impl ServerHandler for ElicitationAppsMcpServer { json!({ "action": "accept", "content": { - "path": "/tmp/report.csv", + "template": "monthly-review", }, }) ); Ok(CallToolResult::success(vec![Content::text( - "accepted /tmp/report.csv", + "accepted monthly-review", )])) } } diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 68df173e6ed7..2227ee4d63c2 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -239,12 +239,17 @@ mod tests { let elicitation = openai_form_elicitation(CustomRequest::new( OPENAI_FORM_METHOD, Some(json!({ - "message": "Choose a file", + "message": "Select a template", "requestedSchema": { "type": "object", "properties": { - "path": { - "type": "openai/file" + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] } } } @@ -256,12 +261,17 @@ mod tests { elicitation, Elicitation::OpenAiForm { meta: None, - message: "Choose a file".to_string(), + message: "Select a template".to_string(), requested_schema: json!({ "type": "object", "properties": { - "path": { - "type": "openai/file" + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] } } }), diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 0857dab5eee6..128b8e1af786 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -1236,11 +1236,18 @@ mod tests { .send_request(ServerRequest::CustomRequest(CustomRequest::new( "openai/form", Some(serde_json::json!({ - "message": "Choose a file", + "message": "Select a template", "requestedSchema": { "type": "object", "properties": { - "path": { "type": "openai/file" } + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] + } } } })), From 7dc91138b054c39deeed2928003b66ce2a357b05 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 16 Jun 2026 15:08:08 -0700 Subject: [PATCH 09/13] Use openai in form capability APIs --- .../analytics/src/analytics_client_tests.rs | 8 ++--- codex-rs/app-server-client/src/lib.rs | 2 +- codex-rs/app-server-client/src/remote.rs | 2 +- .../schema/json/ClientRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../schema/json/v1/InitializeParams.json | 2 +- .../typescript/InitializeCapabilities.ts | 2 +- .../src/protocol/common.rs | 8 ++--- .../app-server-protocol/src/protocol/v1.rs | 2 +- codex-rs/app-server-test-client/src/lib.rs | 2 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/message_processor.rs | 18 +++++----- .../initialize_processor.rs | 6 ++-- .../request_processors/thread_processor.rs | 36 +++++++++---------- .../app-server/tests/suite/v2/attestation.rs | 2 +- .../tests/suite/v2/experimental_api.rs | 16 ++++----- .../app-server/tests/suite/v2/initialize.rs | 2 +- .../tests/suite/v2/mcp_server_elicitation.rs | 2 +- .../tests/suite/v2/thread_status.rs | 2 +- codex-rs/codex-mcp/src/connection_manager.rs | 4 +-- codex-rs/codex-mcp/src/rmcp_client.rs | 14 ++++---- codex-rs/core/src/session/mcp.rs | 4 +-- codex-rs/core/src/session/session.rs | 4 +-- 24 files changed, 73 insertions(+), 73 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a49724ece824..e3371dabc1b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -766,7 +766,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1661,7 +1661,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1811,7 +1811,7 @@ async fn compaction_event_ingests_custom_fact() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1940,7 +1940,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 8d4378711b03..7ccb308cbae2 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -374,7 +374,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }; InitializeParams { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 57de1ebb45bd..439a5c8c18fa 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -99,7 +99,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9ae7f3159c1b..2c335ecfc636 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1264,7 +1264,7 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, - "mcpServerOpenAiFormElicitation": { + "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 52853c662a12..61c1d484025d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2892,7 +2892,7 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, - "mcpServerOpenAiFormElicitation": { + "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f94766dad993..381d7d43d3b6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7425,7 +7425,7 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, - "mcpServerOpenAiFormElicitation": { + "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 51a274c18e0a..75f0860ddda5 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,7 +30,7 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, - "mcpServerOpenAiFormElicitation": { + "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 2be794f976fa..dcc4dffb0b51 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -17,7 +17,7 @@ requestAttestation: boolean, /** * Allow downstream MCP servers to request OpenAI extended form elicitations. */ -mcpServerOpenAiFormElicitation?: boolean, +mcpServerOpenaiFormElicitation?: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f8a350806512..827408c6ad98 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2162,7 +2162,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, - mcp_server_open_ai_form_elicitation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2184,7 +2184,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, - "mcpServerOpenAiFormElicitation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2211,7 +2211,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, - "mcpServerOpenAiFormElicitation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2233,7 +2233,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, - mcp_server_open_ai_form_elicitation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index ff6cc0d8f533..dccea51a3601 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -52,7 +52,7 @@ pub struct InitializeCapabilities { pub request_attestation: bool, /// Allow downstream MCP servers to request OpenAI extended form elicitations. #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub mcp_server_open_ai_form_elicitation: bool, + pub mcp_server_openai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 3251bf49de84..67a0091d4e68 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1602,7 +1602,7 @@ impl CodexClient { .map(|method| (*method).to_string()) .collect(), ), - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d7f97ceafe4f..aaed3826df1d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,7 +88,7 @@ Clients must send a single `initialize` request per transport connection before Clients that handle OpenAI extended MCP forms, including a fallback for unsupported field types, set -`initialize.params.capabilities.mcpServerOpenAiFormElicitation` to `true`. +`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`. App-server then advertises the downstream `openai/form` MCP extension for threads started, resumed, or forked by that connection. Clients that cannot handle the request envelope omit the field or set it to `false`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index df47be3b067e..c1cc01c1088a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -220,7 +220,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, - pub(crate) open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + pub(crate) openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, } impl Default for ConnectionSessionState { @@ -272,12 +272,12 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } - pub(crate) fn open_ai_form_elicitation_capability( + pub(crate) fn openai_form_elicitation_capability( &self, ) -> codex_mcp::OpenAiFormElicitationCapability { self.initialized .get() - .map(|session| session.open_ai_form_elicitation_capability) + .map(|session| session.openai_form_elicitation_capability) .unwrap_or_default() } @@ -885,7 +885,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); - let open_ai_form_elicitation_capability = session.open_ai_form_elicitation_capability(); + let openai_form_elicitation_capability = session.openai_form_elicitation_capability(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -901,7 +901,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ) .await; if let Err(error) = result { @@ -931,7 +931,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1084,7 +1084,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, request_context, ) .await @@ -1101,7 +1101,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ) .await } @@ -1112,7 +1112,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ) .await } diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index 306d02cd6cfb..5d1aca0ef43f 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -70,13 +70,13 @@ impl InitializeRequestProcessor { let ( experimental_api_enabled, request_attestation, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, opt_out_notification_methods, ) = match params.capabilities { Some(capabilities) => ( capabilities.experimental_api, capabilities.request_attestation, - if capabilities.mcp_server_open_ai_form_elicitation { + if capabilities.mcp_server_openai_form_elicitation { codex_mcp::OpenAiFormElicitationCapability::Supported } else { codex_mcp::OpenAiFormElicitationCapability::Unsupported @@ -115,7 +115,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index f99a6bec2f66..3bedf10ce6ed 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -418,7 +418,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -426,7 +426,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, request_context, ) .await @@ -449,14 +449,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ) .await .map(|()| None) @@ -468,14 +468,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ) .await .map(|()| None) @@ -881,7 +881,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -952,7 +952,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, config, typesafe_overrides, dynamic_tools, @@ -1026,7 +1026,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1132,9 +1132,9 @@ impl ThreadRequestProcessor { thread_extension_init.insert(selected_capability_roots); codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init); } - insert_open_ai_form_elicitation_capability( + insert_openai_form_elicitation_capability( &mut thread_extension_init, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ); let create_thread_started_at = std::time::Instant::now(); let NewThread { @@ -2532,7 +2532,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2677,7 +2677,7 @@ impl ThreadRequestProcessor { thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, - open_ai_form_elicitation_extension_data(open_ai_form_elicitation_capability), + openai_form_elicitation_extension_data(openai_form_elicitation_capability), ) .await { @@ -3295,7 +3295,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - open_ai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3405,7 +3405,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, - open_ai_form_elicitation_extension_data(open_ai_form_elicitation_capability), + openai_form_elicitation_extension_data(openai_form_elicitation_capability), ) .await .map_err(|err| match err { @@ -3711,15 +3711,15 @@ impl ThreadRequestProcessor { } } -fn open_ai_form_elicitation_extension_data( +fn openai_form_elicitation_extension_data( capability: codex_mcp::OpenAiFormElicitationCapability, ) -> ExtensionDataInit { let mut extension_data = ExtensionDataInit::new(); - insert_open_ai_form_elicitation_capability(&mut extension_data, capability); + insert_openai_form_elicitation_capability(&mut extension_data, capability); extension_data } -fn insert_open_ai_form_elicitation_capability( +fn insert_openai_form_elicitation_capability( extension_data: &mut ExtensionDataInit, capability: codex_mcp::OpenAiFormElicitationCapability, ) { diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 25f2098ecb4b..168e129dc24e 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -90,7 +90,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand experimental_api: true, request_attestation: true, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index c4747756db91..077ffeffd27e 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -39,7 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -71,7 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -116,7 +116,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -151,7 +151,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -186,7 +186,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -235,7 +235,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -274,7 +274,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -312,7 +312,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil experimental_api: false, request_attestation: false, opt_out_notification_methods: None, - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index c065e3af65d1..6fb7aa3bc314 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -214,7 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index eaa38db6cb3d..34bc10e1989c 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -134,7 +134,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { }, Some(InitializeCapabilities { experimental_api: true, - mcp_server_open_ai_form_elicitation: true, + mcp_server_openai_form_elicitation: true, ..Default::default() }), ), diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 3db1531a731c..e922bd13c14e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -148,7 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), - mcp_server_open_ai_form_elicitation: false, + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 711010db003b..2320e796cd0b 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -134,7 +134,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, - open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, + openai_form_elicitation_capability: OpenAiFormElicitationCapability, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -211,7 +211,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 6ac8c2c47c57..58b19aa5652a 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -155,7 +155,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, - open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, + openai_form_elicitation_capability: OpenAiFormElicitationCapability, ) -> Self { let tool_filter = server .configured_config() @@ -209,7 +209,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, }, ) .await @@ -489,11 +489,11 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, } = params; let params = mcp_initialize_request_params( client_elicitation_capability, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, ); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -556,11 +556,11 @@ async fn start_server_task( fn mcp_initialize_request_params( client_elicitation_capability: ElicitationCapability, - open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, + openai_form_elicitation_capability: OpenAiFormElicitationCapability, ) -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); - match open_ai_form_elicitation_capability { + match openai_form_elicitation_capability { OpenAiFormElicitationCapability::Unsupported => {} OpenAiFormElicitationCapability::Supported => { capabilities.extensions = Some(BTreeMap::from([( @@ -600,7 +600,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, - open_ai_form_elicitation_capability: OpenAiFormElicitationCapability, + openai_form_elicitation_capability: OpenAiFormElicitationCapability, } async fn make_rmcp_client( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 06b33eecc26a..3e0cf5f5c318 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -345,7 +345,7 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; - let open_ai_form_elicitation_capability = self + let openai_form_elicitation_capability = self .services .thread_extension_data .get::() @@ -367,7 +367,7 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1b33096b9c22..a58171bc53b6 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1110,7 +1110,7 @@ impl Session { } else { ElicitationCapability::default() }; - let open_ai_form_elicitation_capability = sess + let openai_form_elicitation_capability = sess .services .thread_extension_data .get::() @@ -1150,7 +1150,7 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, - open_ai_form_elicitation_capability, + openai_form_elicitation_capability, tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), From b1a2fb4d5f1d00432cc866b96afedafaddc79198 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 17 Jun 2026 15:36:36 -0700 Subject: [PATCH 10/13] Address OpenAI form review feedback --- codex-rs/app-server/src/message_processor.rs | 21 +- .../initialize_processor.rs | 34 +- .../request_processors/thread_processor.rs | 62 +- .../src/request_processors/turn_processor.rs | 1 + .../tests/suite/v2/mcp_server_elicitation.rs | 720 +++++++++--------- codex-rs/codex-mcp/src/connection_manager.rs | 5 +- .../codex-mcp/src/connection_manager_tests.rs | 2 +- codex-rs/codex-mcp/src/lib.rs | 1 - codex-rs/codex-mcp/src/mcp/mod.rs | 11 +- codex-rs/codex-mcp/src/rmcp_client.rs | 36 +- codex-rs/core/src/codex_delegate.rs | 4 + codex-rs/core/src/codex_thread.rs | 7 + codex-rs/core/src/connectors.rs | 2 +- codex-rs/core/src/mcp_tool_call_tests.rs | 2 +- codex-rs/core/src/session/mcp.rs | 53 +- codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 14 +- codex-rs/core/src/session/tests.rs | 5 + .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/test_support.rs | 9 +- codex-rs/core/src/thread_manager.rs | 80 +- codex-rs/core/src/thread_manager_tests.rs | 14 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 31 +- codex-rs/core/tests/suite/agents_md.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/fork_thread.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 119 ++- .../tests/suite/subagent_notifications.rs | 1 + .../tests/suite/unstable_features_warning.rs | 2 + codex-rs/memories/write/src/runtime.rs | 1 + .../rmcp-client/src/bin/test_stdio_server.rs | 47 ++ codex-rs/rmcp-client/src/rmcp_client.rs | 123 +-- 36 files changed, 768 insertions(+), 652 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c1cc01c1088a..6908f3451e6d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -220,7 +220,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, - pub(crate) openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + pub(crate) supports_openai_form_elicitation: bool, } impl Default for ConnectionSessionState { @@ -272,13 +272,10 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } - pub(crate) fn openai_form_elicitation_capability( - &self, - ) -> codex_mcp::OpenAiFormElicitationCapability { + pub(crate) fn supports_openai_form_elicitation(&self) -> bool { self.initialized .get() - .map(|session| session.openai_form_elicitation_capability) - .unwrap_or_default() + .is_some_and(|session| session.supports_openai_form_elicitation) } pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { @@ -885,7 +882,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); - let openai_form_elicitation_capability = session.openai_form_elicitation_capability(); + let supports_openai_form_elicitation = session.supports_openai_form_elicitation(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -901,7 +898,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, - openai_form_elicitation_capability, + supports_openai_form_elicitation, ) .await; if let Err(error) = result { @@ -931,7 +928,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1084,7 +1081,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - openai_form_elicitation_capability, + supports_openai_form_elicitation, request_context, ) .await @@ -1101,7 +1098,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - openai_form_elicitation_capability, + supports_openai_form_elicitation, ) .await } @@ -1112,7 +1109,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - openai_form_elicitation_capability, + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index 5d1aca0ef43f..cfdad27f50ff 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,31 +67,13 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let ( - experimental_api_enabled, - request_attestation, - openai_form_elicitation_capability, - opt_out_notification_methods, - ) = match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities.request_attestation, - if capabilities.mcp_server_openai_form_elicitation { - codex_mcp::OpenAiFormElicitationCapability::Supported - } else { - codex_mcp::OpenAiFormElicitationCapability::Unsupported - }, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => ( - false, - false, - codex_mcp::OpenAiFormElicitationCapability::Unsupported, - Vec::new(), - ), - }; + let capabilities = params.capabilities.unwrap_or_default(); + let experimental_api_enabled = capabilities.experimental_api; + let request_attestation = capabilities.request_attestation; + let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation; + let opt_out_notification_methods = capabilities + .opt_out_notification_methods + .unwrap_or_default(); let ClientInfo { name, title: _title, @@ -115,7 +97,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, - openai_form_elicitation_capability, + supports_openai_form_elicitation, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 3bedf10ce6ed..c2ded370f808 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -418,7 +418,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -426,7 +426,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, - openai_form_elicitation_capability, + supports_openai_form_elicitation, request_context, ) .await @@ -449,14 +449,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, - openai_form_elicitation_capability, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -468,14 +468,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, - openai_form_elicitation_capability, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -881,7 +881,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -952,7 +952,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, - openai_form_elicitation_capability, + supports_openai_form_elicitation, config, typesafe_overrides, dynamic_tools, @@ -1026,7 +1026,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1132,10 +1132,6 @@ impl ThreadRequestProcessor { thread_extension_init.insert(selected_capability_roots); codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init); } - insert_openai_form_elicitation_capability( - &mut thread_extension_init, - openai_form_elicitation_capability, - ); let create_thread_started_at = std::time::Instant::now(); let NewThread { thread_id, @@ -1159,6 +1155,7 @@ impl ThreadRequestProcessor { parent_trace: request_trace, environments, thread_extension_init, + supports_openai_form_elicitation, }) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", @@ -2532,7 +2529,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2577,6 +2574,7 @@ impl ThreadRequestProcessor { ¶ms, app_server_client_name.clone(), app_server_client_version.clone(), + supports_openai_form_elicitation, ) .await { @@ -2672,12 +2670,12 @@ impl ThreadRequestProcessor { match self .thread_manager - .resume_thread_with_history_and_extension_data( + .resume_thread_with_history( config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, - openai_form_elicitation_extension_data(openai_form_elicitation_capability), + supports_openai_form_elicitation, ) .await { @@ -2866,6 +2864,7 @@ impl ThreadRequestProcessor { params: &ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -2996,6 +2995,14 @@ impl ThreadRequestProcessor { app_server_client_version, ) .await?; + existing_thread + .set_openai_form_elicitation_support(supports_openai_form_elicitation) + .await + .map_err(|err| { + internal_error(format!( + "failed to update OpenAI form elicitation support: {err}" + )) + })?; let mut summary_source_thread = source_thread; summary_source_thread.history = None; @@ -3295,7 +3302,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - openai_form_elicitation_capability: codex_mcp::OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3395,7 +3402,7 @@ impl ThreadRequestProcessor { .. } = self .thread_manager - .fork_thread_from_history_with_extension_data( + .fork_thread_from_history( ForkSnapshot::Interrupted, config, InitialHistory::Resumed(ResumedHistory { @@ -3405,7 +3412,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, - openai_form_elicitation_extension_data(openai_form_elicitation_capability), + supports_openai_form_elicitation, ) .await .map_err(|err| match err { @@ -3711,23 +3718,6 @@ impl ThreadRequestProcessor { } } -fn openai_form_elicitation_extension_data( - capability: codex_mcp::OpenAiFormElicitationCapability, -) -> ExtensionDataInit { - let mut extension_data = ExtensionDataInit::new(); - insert_openai_form_elicitation_capability(&mut extension_data, capability); - extension_data -} - -fn insert_openai_form_elicitation_capability( - extension_data: &mut ExtensionDataInit, - capability: codex_mcp::OpenAiFormElicitationCapability, -) { - if capability == codex_mcp::OpenAiFormElicitationCapability::Supported { - extension_data.insert(capability); - } -} - fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8d1810d87f3a..58247eb98bdd 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1183,6 +1183,7 @@ impl TurnRequestProcessor { }), /*thread_source*/ None, self.request_trace_context(request_id).await, + /*supports_openai_form_elicitation*/ false, ) .await .map_err(|err| { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 34bc10e1989c..4648f1549b17 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::assert_regex_match; use core_test_support::responses; +use core_test_support::responses::ResponseMock; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; @@ -46,6 +47,8 @@ use rmcp::model::CreateElicitationRequestParams; use rmcp::model::CustomRequest; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; @@ -79,153 +82,27 @@ const OPENAI_FORM_MESSAGE: &str = "Select a template"; const IMAGE_DATA_URL: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="; -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn mcp_server_elicitation_round_trip() -> Result<()> { - let responses_server = responses::start_mock_server().await; - let tool_call_arguments = serde_json::to_string(&json!({}))?; - let response_mock = responses::mount_sse_sequence( - &responses_server, - vec![ - responses::sse(vec![ - responses::ev_response_created("resp-0"), - responses::ev_assistant_message("msg-0", "Warmup"), - responses::ev_completed("resp-0"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace( - TOOL_CALL_ID, - TOOL_NAMESPACE, - CALLABLE_TOOL_NAME, - &tool_call_arguments, - ), - responses::ev_completed("resp-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-2"), - ]), - ], - ) - .await; - - let (apps_server_url, apps_server_handle) = start_apps_server().await?; - - let codex_home = TempDir::new()?; - write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.initialize_with_capabilities( - ClientInfo { - name: "codex-app-server-tests".to_string(), - title: None, - version: "0.1.0".to_string(), - }, - Some(InitializeCapabilities { - experimental_api: true, - mcp_server_openai_form_elicitation: true, - ..Default::default() - }), - ), - ) - .await??; - - let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let thread_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; - - let warmup_turn_start_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Warm up connectors.".to_string(), - text_elements: Vec::new(), - }], - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let warmup_turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), - ) - .await??; - let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; - - let warmup_completed = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - let warmup_completed: TurnCompletedNotification = serde_json::from_value( - warmup_completed - .params - .clone() - .expect("warmup turn/completed params"), - )?; - assert_eq!(warmup_completed.thread_id, thread.id); - assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); - - let turn_start_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), - text_elements: Vec::new(), - }], - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), - ) - .await??; - let TurnStartResponse { turn } = to_response(turn_start_resp)?; +#[derive(Clone, Copy)] +enum ElicitationScenario { + StandardForm, + OpenAiForm, +} - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { - panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); - }; +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::StandardForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( ElicitationSchema::builder() .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) .build() .map_err(anyhow::Error::msg)?, )?)?; - assert_eq!( params, McpServerElicitationRequestParams { - thread_id: thread.id.clone(), - turn_id: Some(turn.id.clone()), + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), server_name: "codex_apps".to_string(), request: McpServerElicitationRequest::Form { meta: None, @@ -235,146 +112,285 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { } ); - let standard_request_id = request_id.clone(); - mcp.send_response( - request_id, - serde_json::to_value(McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - })?, - ) - .await?; + fixture + .accept(request_id.clone(), json!({ "confirmed": true })) + .await?; + fixture.finish(request_id, "accepted").await +} - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { - panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); - }; - let requested_schema = json!({ - "type": "object", - "properties": { - "template": { - "type": "openai/imagePicker", - "title": "Template", - "items": [{ - "id": "monthly-review", - "title": "Monthly review", - "image": IMAGE_DATA_URL, - }], - }, - }, - "required": ["template"], - }); +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; assert_eq!( params, McpServerElicitationRequestParams { - thread_id: thread.id.clone(), - turn_id: Some(turn.id.clone()), + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), server_name: "codex_apps".to_string(), request: McpServerElicitationRequest::OpenAiForm { meta: None, message: OPENAI_FORM_MESSAGE.to_string(), - requested_schema, + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), }, } ); - let openai_form_request_id = request_id.clone(); - mcp.send_response( - request_id, - serde_json::to_value(McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "template": "monthly-review", - })), - meta: None, - })?, - ) - .await?; - let mut resolved_request_ids = Vec::new(); - loop { - let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; - let JSONRPCMessage::Notification(notification) = message else { - continue; + fixture + .accept(request_id.clone(), json!({ "template": "monthly-review" })) + .await?; + fixture.finish(request_id, "accepted monthly-review").await +} + +struct ElicitationRoundTripFixture { + mcp: TestAppServer, + response_mock: ResponseMock, + _responses_server: wiremock::MockServer, + thread_id: String, + turn_id: String, + apps_server_handle: JoinHandle<()>, +} + +impl ElicitationRoundTripFixture { + async fn start(scenario: ElicitationScenario) -> Result { + let responses_server = responses::start_mock_server().await; + let tool_call_arguments = serde_json::to_string(&json!({}))?; + let response_mock = responses::mount_sse_sequence( + &responses_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-0"), + responses::ev_assistant_message("msg-0", "Warmup"), + responses::ev_completed("resp-0"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + TOOL_CALL_ID, + TOOL_NAMESPACE, + CALLABLE_TOOL_NAME, + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?; + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: true, + ..Default::default() + }), + ), + ) + .await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let warmup_turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let warmup_turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; + let warmup_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let warmup_completed: TurnCompletedNotification = serde_json::from_value( + warmup_completed + .params + .clone() + .expect("warmup turn/completed params"), + )?; + assert_eq!(warmup_completed.thread_id, thread.id); + assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_resp)?; + + Ok(Self { + mcp, + response_mock, + _responses_server: responses_server, + thread_id: thread.id, + turn_id: turn.id, + apps_server_handle, + }) + } + + async fn read_elicitation(&mut self) -> Result<(RequestId, McpServerElicitationRequestParams)> { + let request = timeout( + DEFAULT_READ_TIMEOUT, + self.mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + panic!("expected McpServerElicitationRequest request, got: {request:?}"); }; + Ok((request_id, params)) + } - match notification.method.as_str() { - "serverRequest/resolved" => { - let resolved: ServerRequestResolvedNotification = serde_json::from_value( - notification - .params - .clone() - .expect("serverRequest/resolved params"), - )?; - assert_eq!( - resolved.thread_id, thread.id, - "resolved request should belong to the active thread" - ); - assert!( - resolved.request_id == standard_request_id - || resolved.request_id == openai_form_request_id, - "unexpected resolved request id: {:?}", - resolved.request_id - ); - resolved_request_ids.push(resolved.request_id); - } - "turn/completed" => { - let completed: TurnCompletedNotification = serde_json::from_value( - notification.params.clone().expect("turn/completed params"), - )?; - assert!( - resolved_request_ids.contains(&standard_request_id) - && resolved_request_ids.contains(&openai_form_request_id), - "both server requests should resolve before turn completion" - ); - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.id, turn.id); - assert_eq!(completed.turn.status, TurnStatus::Completed); - break; + async fn accept(&mut self, request_id: RequestId, content: Value) -> Result<()> { + self.mcp + .send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(content), + meta: None, + })?, + ) + .await + } + + async fn finish(mut self, request_id: RequestId, expected_text: &str) -> Result<()> { + let mut resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, self.mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let notification: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.request_id, request_id); + resolved = true; + } + "turn/completed" => { + let notification: TurnCompletedNotification = serde_json::from_value( + notification.params.clone().expect("turn/completed params"), + )?; + assert!( + resolved, + "server request should resolve before turn completion" + ); + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.turn.id, self.turn_id); + assert_eq!(notification.turn.status, TurnStatus::Completed); + break; + } + _ => {} } - _ => {} } - } - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); - assert_eq!( - function_call_output.get("type"), - Some(&Value::String("function_call_output".to_string())) - ); - assert_eq!( - function_call_output.get("call_id"), - Some(&Value::String(TOOL_CALL_ID.to_string())) - ); - let output = function_call_output - .get("output") - .and_then(Value::as_str) - .expect("function_call_output output should be a JSON string"); - let payload = assert_regex_match( - r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, - output, - ) - .get(1) - .expect("wall-time wrapped output should include payload") - .as_str(); - assert_eq!( - serde_json::from_str::(payload)?, - json!([{ - "type": "text", - "text": "accepted monthly-review" - }]) - ); + let requests = self.response_mock.requests(); + assert_eq!(requests.len(), 3); + let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); + assert_eq!( + function_call_output.get("type"), + Some(&Value::String("function_call_output".to_string())) + ); + assert_eq!( + function_call_output.get("call_id"), + Some(&Value::String(TOOL_CALL_ID.to_string())) + ); + let output = function_call_output + .get("output") + .and_then(Value::as_str) + .expect("function_call_output output should be a JSON string"); + let payload = assert_regex_match( + r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, + output, + ) + .get(1) + .expect("wall-time wrapped output should include payload") + .as_str(); + assert_eq!( + serde_json::from_str::(payload)?, + json!([{ "type": "text", "text": expected_text }]) + ); - apps_server_handle.abort(); - let _ = apps_server_handle.await; - Ok(()) + self.apps_server_handle.abort(); + let _ = self.apps_server_handle.await; + Ok(()) + } } #[derive(Clone)] @@ -383,10 +399,33 @@ struct AppsServerState { expected_account_id: String, } -#[derive(Clone, Default)] -struct ElicitationAppsMcpServer; +#[derive(Clone)] +struct ElicitationAppsMcpServer { + scenario: ElicitationScenario, +} impl ServerHandler for ElicitationAppsMcpServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + if matches!(self.scenario, ElicitationScenario::OpenAiForm) { + assert_eq!( + request + .capabilities + .extensions + .as_ref() + .and_then(|extensions| extensions.get("openai/form")) + .cloned() + .map(Value::Object), + Some(json!({})) + ); + } + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) @@ -429,104 +468,91 @@ impl ServerHandler for ElicitationAppsMcpServer { _request: CallToolRequestParams, context: RequestContext, ) -> Result { - let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let result = context - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: ELICITATION_MESSAGE.to_string(), - requested_schema, - }) - .await - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let output = match result.action { - ElicitationAction::Accept => { + match self.scenario { + ElicitationScenario::StandardForm => { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; assert_eq!( result.content, Some(json!({ "confirmed": true, })) ); - "accepted" + let output = match result.action { + ElicitationAction::Accept => "accepted", + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + Ok(CallToolResult::success(vec![Content::text(output)])) } - ElicitationAction::Decline => "declined", - ElicitationAction::Cancel => "cancelled", - }; - - if output != "accepted" { - return Ok(CallToolResult::success(vec![Content::text(output)])); - } - - assert_eq!( - context - .peer - .peer_info() - .and_then(|info| info.capabilities.extensions.as_ref()) - .and_then(|extensions| extensions.get("openai/form")) - .cloned() - .map(Value::Object), - Some(json!({})) - ); - let result = context - .peer - .send_request(McpServerRequest::CustomRequest(CustomRequest::new( - "openai/form", - Some(json!({ - "message": OPENAI_FORM_MESSAGE, - "requestedSchema": { - "type": "object", - "properties": { - "template": { - "type": "openai/imagePicker", - "title": "Template", - "items": [{ - "id": "monthly-review", - "title": "Monthly review", - "image": IMAGE_DATA_URL, - }], + ElicitationScenario::OpenAiForm => { + let result = context + .peer + .send_request(McpServerRequest::CustomRequest(CustomRequest::new( + "openai/form", + Some(json!({ + "message": OPENAI_FORM_MESSAGE, + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], }, + })), + ))) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = match result { + rmcp::model::ClientResult::CustomResult(result) => result.0, + rmcp::model::ClientResult::CreateElicitationResult(result) => { + serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? + } + result => { + return Err(rmcp::ErrorData::internal_error( + format!("unexpected OpenAI form response: {result:?}"), + None, + )); + } + }; + assert_eq!( + result, + json!({ + "action": "accept", + "content": { + "template": "monthly-review", }, - "required": ["template"], - }, - })), - ))) - .await - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - let result = match result { - rmcp::model::ClientResult::CustomResult(result) => result.0, - rmcp::model::ClientResult::CreateElicitationResult(result) => { - serde_json::to_value(result) - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? - } - result => { - return Err(rmcp::ErrorData::internal_error( - format!("unexpected OpenAI form response: {result:?}"), - None, - )); + }) + ); + Ok(CallToolResult::success(vec![Content::text( + "accepted monthly-review", + )])) } - }; - assert_eq!( - result, - json!({ - "action": "accept", - "content": { - "template": "monthly-review", - }, - }) - ); - - Ok(CallToolResult::success(vec![Content::text( - "accepted monthly-review", - )])) + } } } -async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { +async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> { let state = Arc::new(AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), @@ -536,7 +562,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { let addr = listener.local_addr()?; let mcp_service = StreamableHttpService::new( - move || Ok(ElicitationAppsMcpServer), + move || Ok(ElicitationAppsMcpServer { scenario }), Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), ); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 2320e796cd0b..545c3b782d51 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -14,7 +14,6 @@ use std::time::Duration; use std::time::Instant; use crate::McpAuthStatusEntry; -use crate::OpenAiFormElicitationCapability; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; @@ -134,7 +133,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, - openai_form_elicitation_capability: OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -211,7 +210,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), - openai_form_elicitation_capability, + supports_openai_form_elicitation, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 3b71d95ccc52..2763e82060c5 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1266,7 +1266,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), - crate::OpenAiFormElicitationCapability::Unsupported, + false, ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index af11acd7f8b7..b56a4f9072c9 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -23,7 +23,6 @@ pub use catalog::ResolvedMcpServer; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; -pub use mcp::OpenAiFormElicitationCapability; pub use mcp::ToolPluginProvenance; pub use server::EffectiveMcpServer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 401cd957f1c3..bc4e02a08144 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -50,13 +50,6 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OpenAiFormElicitationCapability { - #[default] - Unsupported, - Supported, -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum McpSnapshotDetail { #[default] @@ -312,7 +305,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - OpenAiFormElicitationCapability::Unsupported, + false, tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -387,7 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - OpenAiFormElicitationCapability::Unsupported, + false, tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 58b19aa5652a..6bebb7dd11c3 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -29,7 +29,6 @@ use crate::codex_apps::normalize_codex_apps_tool_title; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp::OpenAiFormElicitationCapability; use crate::mcp::ToolPluginProvenance; use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; @@ -155,7 +154,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, - openai_form_elicitation_capability: OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Self { let tool_filter = server .configured_config() @@ -209,7 +208,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - openai_form_elicitation_capability, + supports_openai_form_elicitation, }, ) .await @@ -489,11 +488,11 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, - openai_form_elicitation_capability, + supports_openai_form_elicitation, } = params; let params = mcp_initialize_request_params( client_elicitation_capability, - openai_form_elicitation_capability, + supports_openai_form_elicitation, ); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -556,18 +555,15 @@ async fn start_server_task( fn mcp_initialize_request_params( client_elicitation_capability: ElicitationCapability, - openai_form_elicitation_capability: OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, ) -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); - match openai_form_elicitation_capability { - OpenAiFormElicitationCapability::Unsupported => {} - OpenAiFormElicitationCapability::Supported => { - capabilities.extensions = Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])); - } + if supports_openai_form_elicitation { + capabilities.extensions = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])); } InitializeRequestParams::new( capabilities, @@ -600,7 +596,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, - openai_form_elicitation_capability: OpenAiFormElicitationCapability, + supports_openai_form_elicitation: bool, } async fn make_rmcp_client( @@ -697,16 +693,10 @@ mod tests { #[test] fn mcp_initialize_advertises_openai_form_only_when_supported() { - let unsupported = mcp_initialize_request_params( - ElicitationCapability::default(), - OpenAiFormElicitationCapability::Unsupported, - ); + let unsupported = mcp_initialize_request_params(ElicitationCapability::default(), false); assert_eq!(unsupported.capabilities.extensions, None); - let supported = mcp_initialize_request_params( - ElicitationCapability::default(), - OpenAiFormElicitationCapability::Supported, - ); + let supported = mcp_initialize_request_params(ElicitationCapability::default(), true); assert_eq!( supported.capabilities.extensions, Some(BTreeMap::from([( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d31dc138b57f..eefb4cf3ba32 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -113,6 +113,10 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, environment_selections: parent_ctx.environments.to_selections(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: parent_session + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 451b497827bd..1ef3e98ac3d1 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -333,6 +333,13 @@ impl CodexThread { .await } + pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> { + self.codex + .session + .set_openai_form_elicitation_support(supported) + .await + } + /// Preview persistent thread settings overrides without committing them. pub async fn preview_thread_settings_overrides( &self, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 72bafbc0039c..344525f1e55b 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -290,7 +290,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - codex_mcp::OpenAiFormElicitationCapability::Unsupported, + false, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index e0819aada293..63fae4dc6899 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1279,7 +1279,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), - codex_mcp::OpenAiFormElicitationCapability::Unsupported, + false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 3e0cf5f5c318..b352ece7fd8f 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -345,12 +345,6 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; - let openai_form_elicitation_capability = self - .services - .thread_extension_data - .get::() - .map(|capability| *capability.as_ref()) - .unwrap_or_default(); let refreshed_manager = McpConnectionManager::new( &mcp_servers, store_mode, @@ -367,7 +361,9 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - openai_form_elicitation_capability, + self.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, @@ -434,6 +430,34 @@ impl Session { .await; } + pub(crate) async fn set_openai_form_elicitation_support( + &self, + supported: bool, + ) -> anyhow::Result<()> { + if self + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed) + == supported + { + return Ok(()); + } + + let config = self.get_config().await; + let refresh_config = McpServerRefreshConfig { + mcp_servers: serde_json::to_value(config.mcp_servers.get())?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + )?, + auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?, + }; + self.services + .supports_openai_form_elicitation + .store(supported, std::sync::atomic::Ordering::Relaxed); + *self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config); + Ok(()) + } + pub(crate) async fn refresh_mcp_servers_now( &self, turn_context: &TurnContext, @@ -605,18 +629,9 @@ fn guardian_elicitation_review_request( } fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> { - match elicitation { - Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { meta, .. } - | rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { meta, .. }, - ) => meta - .as_ref() - .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), - Elicitation::OpenAiForm { meta, .. } => meta - .as_ref() - .and_then(Value::as_object) - .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)), - } + elicitation + .meta() + .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)) } fn meta_requests_approval_request(meta: &Option) -> bool { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 242e35f37568..2ab7041b5467 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -432,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) parent_trace: Option, pub(crate) environment_selections: Vec, pub(crate) thread_extension_init: ExtensionDataInit, + pub(crate) supports_openai_form_elicitation: bool, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, @@ -514,6 +515,7 @@ impl Codex { parent_trace: _, environment_selections, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client, thread_store, attestation_provider, @@ -656,6 +658,7 @@ impl Codex { mcp_manager.clone(), extensions, thread_extension_init, + supports_openai_form_elicitation, agent_control, environment_manager, inherited_environments, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index a58171bc53b6..524736393d5b 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -482,6 +482,7 @@ impl Session { mcp_manager: Arc, extensions: Arc>, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, agent_control: AgentControl, environment_manager: Arc, inherited_environments: Option, @@ -1007,6 +1008,9 @@ impl Session { session_extension_data, thread_extension_data, mcp_thread_init, + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new( + supports_openai_form_elicitation, + ), agent_control, network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), network_proxy_audit_metadata, @@ -1110,12 +1114,6 @@ impl Session { } else { ElicitationCapability::default() }; - let openai_form_elicitation_capability = sess - .services - .thread_extension_data - .get::() - .map(|capability| *capability.as_ref()) - .unwrap_or_default(); let mcp_startup_cancellation_token = { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -1150,7 +1148,9 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, - openai_form_elicitation_capability, + sess.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5086fd63bd57..2754abcbd3ae 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4850,6 +4850,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5012,6 +5013,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), @@ -5199,6 +5201,7 @@ async fn make_session_with_config_and_rx( mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5303,6 +5306,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, agent_control, environment_manager, /*inherited_environments*/ None, @@ -7056,6 +7060,7 @@ where ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index e958b39e1f7d..cc03e05db29e 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -732,6 +732,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { parent_trace: None, environment_selections: Vec::new(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: false, analytics_events_client: None, thread_store, attestation_provider: None, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 00380c6d6470..85cb89783c76 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use crate::SkillsManager; use crate::agent::AgentControl; @@ -67,6 +68,7 @@ pub(crate) struct SessionServices { pub(crate) extensions: Arc>, pub(crate) session_extension_data: ExtensionData, pub(crate) thread_extension_data: ExtensionData, + pub(crate) supports_openai_form_elicitation: AtomicBool, pub(crate) mcp_thread_init: ExtensionDataInit, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: ArcSwapOption, diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 60e80ca290f5..1db714fadaa8 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -112,9 +112,14 @@ pub async fn start_thread_with_user_shell_override( thread_manager: &ThreadManager, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager - .start_thread_with_user_shell_override_for_tests(config, user_shell_override) + .start_thread_with_user_shell_override_for_tests( + config, + user_shell_override, + supports_openai_form_elicitation, + ) .await } @@ -124,6 +129,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager .resume_thread_from_rollout_with_user_shell_override_for_tests( @@ -131,6 +137,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path, auth_manager, user_shell_override, + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 754f81833e74..69033b852d60 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -186,6 +186,7 @@ pub struct StartThreadOptions { pub parent_trace: Option, pub environments: Vec, pub thread_extension_init: ExtensionDataInit, + pub supports_openai_form_elicitation: bool, } pub(crate) struct ResumeThreadWithHistoryOptions { @@ -604,6 +605,7 @@ impl ThreadManager { parent_trace: None, environments, thread_extension_init: ExtensionDataInit::default(), + supports_openai_form_elicitation: false, })) .await } @@ -643,6 +645,7 @@ impl ThreadManager { options.parent_trace, options.environments, options.thread_extension_init, + options.supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -691,6 +694,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; Box::pin(self.resume_thread_with_history( @@ -698,6 +702,7 @@ impl ThreadManager { initial_history, auth_manager, parent_trace, + supports_openai_form_elicitation, )) .await } @@ -709,24 +714,7 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, - ) -> CodexResult { - self.resume_thread_with_history_and_extension_data( - config, - initial_history, - auth_manager, - parent_trace, - ExtensionDataInit::default(), - ) - .await - } - - pub async fn resume_thread_with_history_and_extension_data( - &self, - config: Config, - initial_history: InitialHistory, - auth_manager: Arc, - parent_trace: Option, - thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, ) -> CodexResult { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -750,7 +738,8 @@ impl ThreadManager { /*inherited_exec_policy*/ None, parent_trace, environments, - thread_extension_init, + /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -760,6 +749,7 @@ impl ThreadManager { &self, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -778,6 +768,7 @@ impl ThreadManager { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -789,6 +780,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; let environments = default_thread_environment_selections( @@ -814,6 +806,7 @@ impl ThreadManager { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -894,8 +887,15 @@ impl ThreadManager { { let snapshot = snapshot.into(); let history = self.initial_history_from_rollout_path(path).await?; - self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace) - .await + self.fork_thread_from_history( + snapshot, + config, + history, + thread_source, + parent_trace, + /*supports_openai_form_elicitation*/ false, + ) + .await } async fn initial_history_from_rollout_path( @@ -924,29 +924,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, - ) -> CodexResult - where - S: Into, - { - self.fork_thread_from_history_with_extension_data( - snapshot, - config, - history, - thread_source, - parent_trace, - ExtensionDataInit::default(), - ) - .await - } - - pub async fn fork_thread_from_history_with_extension_data( - &self, - snapshot: S, - config: Config, - history: InitialHistory, - thread_source: Option, - parent_trace: Option, - thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, ) -> CodexResult where S: Into, @@ -957,7 +935,7 @@ impl ThreadManager { history, thread_source, parent_trace, - thread_extension_init, + supports_openai_form_elicitation, ) .await } @@ -969,7 +947,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, - thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -1007,7 +985,8 @@ impl ThreadManager { /*metrics_service_name*/ None, parent_trace, environments, - thread_extension_init, + /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -1264,6 +1243,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1301,6 +1281,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1339,6 +1320,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1360,6 +1342,7 @@ impl ThreadManagerState { parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -1378,6 +1361,7 @@ impl ThreadManagerState { parent_trace, environments, thread_extension_init, + supports_openai_form_elicitation, user_shell_override, )) .await @@ -1401,6 +1385,7 @@ impl ThreadManagerState { parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_)); @@ -1468,6 +1453,7 @@ impl ThreadManagerState { parent_trace, environment_selections: environments, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ddfa5e701123..0bceed6e5228 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -325,6 +325,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("internal thread should start"); @@ -463,6 +464,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-a", "env-a"), + supports_openai_form_elicitation: false, }) .await .expect("start first thread"); @@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-b", "env-b"), + supports_openai_form_elicitation: false, }) .await .expect("start second thread"); @@ -567,6 +570,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { parent_trace: None, environments: environments.clone(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -593,6 +597,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -729,6 +734,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume active source thread"); @@ -792,6 +798,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume stopped source thread"); @@ -842,6 +849,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -868,6 +876,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -947,6 +956,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { }), auth_manager.clone(), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("seed rollout path in store"); @@ -963,6 +973,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume from rollout path"); @@ -1254,6 +1265,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from completed history"); @@ -1368,6 +1380,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from explicit partial history"); @@ -1458,6 +1471,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from partial history"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ebb452c1de08..54f1781b0c41 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2805,6 +2805,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("start thread"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index daf6f79b5cd3..b10e26bf0c76 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -15,6 +15,7 @@ use anyhow::Result; use anyhow::anyhow; use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; +use codex_core::StartThreadOptions; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::resolve_installation_id; @@ -39,6 +40,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; @@ -258,6 +260,7 @@ pub struct TestCodexBuilder { exec_server_url: Option, extensions: Arc>, user_instructions_provider: Option>, + supports_openai_form_elicitation: bool, } impl TestCodexBuilder { @@ -354,6 +357,11 @@ impl TestCodexBuilder { self } + pub fn with_openai_form_elicitation(mut self) -> Self { + self.supports_openai_form_elicitation = true; + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -574,6 +582,7 @@ impl TestCodexBuilder { path, auth_manager, user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? @@ -585,6 +594,7 @@ impl TestCodexBuilder { path, auth_manager, /*parent_trace*/ None, + self.supports_openai_form_elicitation, )) .await? } @@ -594,11 +604,29 @@ impl TestCodexBuilder { thread_manager.as_ref(), config.clone(), user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? } - (None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?, + (None, None) => { + let environments = thread_manager.default_environment_selections(&config.cwd); + Box::pin( + thread_manager.start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + parent_trace: None, + environments, + thread_extension_init: Default::default(), + supports_openai_form_elicitation: self.supports_openai_form_elicitation, + }), + ) + .await? + } }; Ok(TestCodex { @@ -1143,6 +1171,7 @@ pub fn test_codex() -> TestCodexBuilder { exec_server_url: None, extensions: empty_extension_registry(), user_instructions_provider: None, + supports_openai_form_elicitation: false, } } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index a4ab404065dc..5b79e9f1e6bf 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -448,6 +448,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); @@ -664,6 +665,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho }, ], thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 909dfb8c82c3..3270a922b050 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -830,6 +830,7 @@ async fn resume_conversation( path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, )) .await .expect("resume conversation") diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a1fdf79a8ea5..d51ba7d2f61e 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -201,6 +201,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }), /*thread_source*/ None, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("fork from stored history"); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 61fc38e8b154..21380089673a 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2219,6 +2219,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge InitialHistory::Forked(history), auth_manager_from_auth(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await?; let codex = resumed_thread.thread; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0ba392298468..4a5956d82235 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -110,6 +110,7 @@ async fn emits_warning_when_resumed_model_differs() { initial_history, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume conversation"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2be014b3f52f..5d05394b8d0c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -50,6 +50,7 @@ use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; @@ -339,13 +340,23 @@ async fn call_cwd_tool( fixture: &TestCodex, server_name: &str, call_id: &str, +) -> anyhow::Result { + call_structured_tool(server, fixture, server_name, "cwd", call_id).await +} + +async fn call_structured_tool( + server: &MockServer, + fixture: &TestCodex, + server_name: &str, + tool_name: &str, + call_id: &str, ) -> anyhow::Result { let namespace = format!("mcp__{server_name}"); mount_sse_once( server, responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#), + responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#), responses::ev_completed("resp-1"), ]), ) @@ -353,7 +364,7 @@ async fn call_cwd_tool( mount_sse_once( server, responses::sse(vec![ - responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."), + responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."), responses::ev_completed("resp-2"), ]), ) @@ -361,7 +372,7 @@ async fn call_cwd_tool( fixture .codex - .submit(read_only_user_turn(fixture, "call the rmcp cwd tool")) + .submit(read_only_user_turn(fixture, "call the requested rmcp tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -378,7 +389,7 @@ async fn call_cwd_tool( let structured_content = end .result .as_ref() - .expect("rmcp cwd tool should return success") + .expect("rmcp tool should return success") .structured_content .as_ref() .expect("structured content") @@ -388,6 +399,106 @@ async fn call_cwd_tool( Ok(structured_content) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(false).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }) + .build(&server) + .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let unsupported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-unsupported", + ) + .await?; + assert_eq!( + unsupported, + json!({ "supportsOpenaiFormElicitation": false }) + ); + + fixture + .codex + .set_openai_form_elicitation_support(true) + .await?; + let supported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-supported", + ) + .await?; + assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true })); + Ok(()) +} + +async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let mut builder = test_codex().with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }); + if expected { + builder = builder.with_openai_form_elicitation(); + } + let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let structured = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities", + ) + .await?; + assert_eq!( + structured, + json!({ "supportsOpenaiFormElicitation": expected }) + ); + Ok(()) +} + fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) { let actual_cwd = structured .get("cwd") diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 764380be7190..915bf25609a3 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -754,6 +754,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index e66c674f92a1..783acc638b90 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -47,6 +47,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); @@ -93,6 +94,7 @@ async fn suppresses_warning_when_configured() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 8dae6ca61704..1d8fcb567b0b 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -313,6 +313,7 @@ impl MemoryStartupContext { parent_trace: None, environments, thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 4407a27eeb3e..9ff36e349dea 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use rmcp::ErrorData as McpError; @@ -11,6 +13,8 @@ use rmcp::ServiceExt; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -38,6 +42,7 @@ struct TestToolServer { tools: Arc>, resources: Arc>, resource_templates: Arc>, + supports_openai_form_elicitation: Arc, } const MEMO_URI: &str = "memo://codex/example-note"; @@ -68,6 +73,7 @@ impl TestToolServer { let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + Self::client_capabilities_tool(), Self::cwd_tool(), Self::sync_tool(), Self::sync_readonly_tool(), @@ -81,6 +87,7 @@ impl TestToolServer { tools: Arc::new(tools), resources: Arc::new(resources), resource_templates: Arc::new(resource_templates), + supports_openai_form_elicitation: Arc::new(AtomicBool::new(false)), } } @@ -166,6 +173,24 @@ impl TestToolServer { tool } + fn client_capabilities_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("client capabilities tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed("client_capabilities"), + Cow::Borrowed("Return capabilities advertised by the MCP client."), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool + } + fn sync_tool() -> Tool { #[expect(clippy::expect_used)] let schema: JsonObject = serde_json::from_value(json!({ @@ -396,6 +421,23 @@ struct ImageScenarioArgs { } impl ServerHandler for TestToolServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + self.supports_openai_form_elicitation.store( + request + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key("openai/form")), + Ordering::Relaxed, + ); + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { let mut capabilities = ServerCapabilities::builder() .enable_tools() @@ -481,6 +523,11 @@ impl ServerHandler for TestToolServer { context: rmcp::service::RequestContext, ) -> Result { match request.name.as_ref() { + "client_capabilities" => Ok(Self::structured_result(json!({ + "supportsOpenaiFormElicitation": self + .supports_openai_form_elicitation + .load(Ordering::Relaxed), + }))), "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( context.meta.0, ))), diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 128b8e1af786..4a9b89de3c32 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -40,6 +40,7 @@ use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; +use rmcp::model::RequestParamsMeta; use rmcp::model::ServerResult; use rmcp::model::Tool; use rmcp::service::RoleClient; @@ -261,6 +262,15 @@ pub enum Elicitation { }, } +impl Elicitation { + pub fn meta(&self) -> Option<&serde_json::Map> { + match self { + Self::Mcp(request) => request.meta().map(|meta| &meta.0), + Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object), + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ElicitationResponse { @@ -1199,67 +1209,13 @@ async fn create_oauth_transport_and_runtime( #[cfg(test)] mod tests { - use std::sync::atomic::AtomicBool; use std::time::Duration; use pretty_assertions::assert_eq; - use rmcp::ServerHandler; - use rmcp::ServiceExt; - use rmcp::model::ClientCapabilities; - use rmcp::model::ClientResult; - use rmcp::model::ErrorCode; - use rmcp::model::Implementation; - use rmcp::model::ServerRequest; - use rmcp::service::NotificationContext; - use rmcp::service::RoleServer; - use rmcp::service::ServiceError; - use tokio::sync::oneshot; use tokio::time; use super::*; - struct OpenAiFormServer { - response: Mutex>>>, - } - - impl ServerHandler for OpenAiFormServer { - async fn on_initialized(&self, context: NotificationContext) { - let response = self - .response - .lock() - .await - .take() - .expect("response sender should be available"); - tokio::spawn(async move { - let result = context - .peer - .send_request(ServerRequest::CustomRequest(CustomRequest::new( - "openai/form", - Some(serde_json::json!({ - "message": "Select a template", - "requestedSchema": { - "type": "object", - "properties": { - "template": { - "type": "openai/imagePicker", - "items": [{ - "id": "monthly-review", - "title": "Monthly review", - "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" - }] - } - } - } - })), - ))) - .await; - response - .send(result) - .expect("response receiver should be available"); - }); - } - } - #[tokio::test] async fn active_time_timeout_pauses_while_elicitation_is_pending() { let pause_state = ElicitationPauseState::new(); @@ -1278,63 +1234,4 @@ mod tests { assert_eq!(Ok("done"), result); } - - #[tokio::test] - async fn openai_form_is_rejected_when_not_advertised() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let (response_tx, response_rx) = oneshot::channel(); - let server_task = tokio::spawn(async move { - let server = OpenAiFormServer { - response: Mutex::new(Some(response_tx)), - } - .serve(server_transport) - .await - .expect("server should initialize"); - server.waiting().await.expect("server should shut down"); - }); - - let elicitation_called = Arc::new(AtomicBool::new(false)); - let callback_called = Arc::clone(&elicitation_called); - let client = time::timeout( - Duration::from_secs(5), - service::serve_client( - ElicitationClientService::new( - InitializeRequestParams::new( - ClientCapabilities::default(), - Implementation::new("test-client", "1.0.0"), - ), - Box::new(move |_, _| { - callback_called.store(true, Ordering::Relaxed); - Box::pin(async { Err(anyhow!("unexpected elicitation")) }) - }), - ElicitationPauseState::new(), - ), - client_transport, - ), - ) - .await - .expect("client initialization should not time out") - .expect("client should initialize"); - - let error = time::timeout(Duration::from_secs(5), response_rx) - .await - .expect("server response should not time out") - .expect("server should send a response") - .expect_err("openai/form should be rejected"); - let ServiceError::McpError(error) = error else { - panic!("expected MCP error, got {error:?}"); - }; - assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); - assert_eq!("openai/form", error.message); - assert!(!elicitation_called.load(Ordering::Relaxed)); - - time::timeout(Duration::from_secs(5), client.cancel()) - .await - .expect("client shutdown should not time out") - .expect("client should shut down"); - time::timeout(Duration::from_secs(5), server_task) - .await - .expect("server shutdown should not time out") - .expect("server task should complete"); - } } From 8144748e01ca56c6110f2d13a1615427e6520ff0 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 17 Jun 2026 17:03:51 -0700 Subject: [PATCH 11/13] Fix argument comments for OpenAI form flag --- codex-rs/core/src/connectors.rs | 2 +- codex-rs/core/src/mcp_tool_call_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 344525f1e55b..20ea8d32b7be 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -290,7 +290,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, - false, + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 63fae4dc6899..08b4b9cf70b6 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1279,7 +1279,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), - false, + /*supports_openai_form_elicitation*/ false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, From ba8cf943c52b85710e50dbdb3ce6f5f3f474b9a8 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 17 Jun 2026 17:13:03 -0700 Subject: [PATCH 12/13] Fix remaining OpenAI form argument comments --- codex-rs/codex-mcp/src/connection_manager_tests.rs | 2 +- codex-rs/codex-mcp/src/mcp/mod.rs | 4 ++-- codex-rs/codex-mcp/src/rmcp_client.rs | 10 ++++++++-- codex-rs/core/tests/suite/rmcp_client.rs | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 2763e82060c5..20ff89ce1dd6 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1266,7 +1266,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), - false, + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index bc4e02a08144..d87602438f28 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -305,7 +305,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - false, + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -380,7 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), - false, + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 6bebb7dd11c3..aa56650e8a0b 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -693,10 +693,16 @@ mod tests { #[test] fn mcp_initialize_advertises_openai_form_only_when_supported() { - let unsupported = mcp_initialize_request_params(ElicitationCapability::default(), false); + let unsupported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, + ); assert_eq!(unsupported.capabilities.extensions, None); - let supported = mcp_initialize_request_params(ElicitationCapability::default(), true); + let supported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ true, + ); assert_eq!( supported.capabilities.extensions, Some(BTreeMap::from([( diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 5d05394b8d0c..f87effd16e8f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -401,12 +401,12 @@ async fn call_structured_tool( #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> { - assert_openai_form_capability_advertisement(true).await + assert_openai_form_capability_advertisement(/*expected*/ true).await } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> { - assert_openai_form_capability_advertisement(false).await + assert_openai_form_capability_advertisement(/*expected*/ false).await } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -447,7 +447,7 @@ async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<() fixture .codex - .set_openai_form_elicitation_support(true) + .set_openai_form_elicitation_support(/*supported*/ true) .await?; let supported = call_structured_tool( &server, From 1e796900c8ee747e9bb89d52de19cea5e16216b7 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 17 Jun 2026 18:09:29 -0700 Subject: [PATCH 13/13] Scope OpenAI form support to active turns --- codex-rs/app-server-client/src/lib.rs | 33 ++- codex-rs/app-server-client/src/remote.rs | 5 +- codex-rs/app-server/src/message_processor.rs | 4 + .../request_processors/thread_processor.rs | 10 - .../src/request_processors/turn_processor.rs | 11 + .../suite/v2/connection_handling_websocket.rs | 2 +- .../tests/suite/v2/mcp_server_elicitation.rs | 273 ++++++++++++++++-- codex-rs/exec/src/lib.rs | 1 + codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 1 + 10 files changed, 298 insertions(+), 44 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 7ccb308cbae2..b7a958eff1f5 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -350,6 +350,8 @@ pub struct InProcessClientStartArgs { pub client_version: String, /// Whether experimental APIs are requested at initialize time. pub experimental_api: bool, + /// Whether MCP servers may send `openai/form` elicitation requests. + pub mcp_server_openai_form_elicitation: bool, /// Notification methods this client opts out of receiving. pub opt_out_notification_methods: Vec, /// Queue capacity for command/event channels (clamped to at least 1). @@ -374,7 +376,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, - mcp_server_openai_form_elicitation: false, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { @@ -1045,6 +1047,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity, }) @@ -1238,11 +1241,25 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, } } + #[test] + fn remote_initialize_params_forward_openai_form_capability() { + let mut args = test_remote_connect_args("ws://localhost/rpc".to_string()); + args.mcp_server_openai_form_elicitation = true; + + assert!( + args.initialize_params() + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -1513,6 +1530,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1601,6 +1619,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1620,6 +1639,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -2190,7 +2210,7 @@ mod tests { } #[tokio::test] - async fn runtime_start_args_forward_environment_manager() { + async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() { let config = Arc::new(build_test_config().await); let environment_manager = Arc::new( EnvironmentManager::create_for_tests( @@ -2223,12 +2243,20 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); assert_eq!(runtime_args.config, config); + assert!( + runtime_args + .initialize + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); assert!(Arc::ptr_eq( &runtime_args.environment_manager, &environment_manager @@ -2264,6 +2292,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 439a5c8c18fa..5575fe4ad46a 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -86,11 +86,12 @@ pub struct RemoteAppServerConnectArgs { pub client_name: String, pub client_version: String, pub experimental_api: bool, + pub mcp_server_openai_form_elicitation: bool, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } impl RemoteAppServerConnectArgs { - fn initialize_params(&self) -> InitializeParams { + pub(crate) fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, @@ -99,7 +100,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, - mcp_server_openai_form_elicitation: false, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6908f3451e6d..3141503ee391 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1098,6 +1098,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await @@ -1109,6 +1110,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await @@ -1309,6 +1311,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index c2ded370f808..84f83097ac90 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2574,7 +2574,6 @@ impl ThreadRequestProcessor { ¶ms, app_server_client_name.clone(), app_server_client_version.clone(), - supports_openai_form_elicitation, ) .await { @@ -2864,7 +2863,6 @@ impl ThreadRequestProcessor { params: &ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -2995,14 +2993,6 @@ impl ThreadRequestProcessor { app_server_client_version, ) .await?; - existing_thread - .set_openai_form_elicitation_support(supports_openai_form_elicitation) - .await - .map_err(|err| { - internal_error(format!( - "failed to update OpenAI form elicitation support: {err}" - )) - })?; let mut summary_source_thread = source_thread; summary_source_thread.history = None; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 58247eb98bdd..16ae22d38497 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -102,12 +102,14 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.turn_start_inner( request_id, params, app_server_client_name, app_server_client_version, + /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await .map(|response| Some(response.into())) @@ -406,6 +408,7 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -432,6 +435,14 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(&request_id, error, /*error_type*/ None); })?; + thread + .set_openai_form_elicitation_support(supports_openai_form_elicitation) + .await + .map_err(|err| { + internal_error(format!( + "failed to update OpenAI form elicitation support: {err}" + )) + })?; let environment_selections = self.parse_environment_selections(params.environments)?; diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 0e00bc630951..648d60f3b594 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -706,7 +706,7 @@ pub(super) async fn send_request( send_jsonrpc(stream, message).await } -async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { +pub(super) async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { let payload = serde_json::to_string(&message)?; stream .send(WebSocketMessage::Text(payload.into())) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 4648f1549b17..113f58f33644 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -16,6 +16,7 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpElicitationSchema; @@ -26,6 +27,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -70,6 +72,15 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::read_notification_for_method; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_jsonrpc; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; @@ -156,6 +167,237 @@ async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { fixture.finish(request_id, "accepted monthly-review").await } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn openai_form_capability_follows_the_turn_starting_connection() -> Result<()> { + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(ElicitationScenario::OpenAiForm).await?; + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut supported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut supported_client, + /*id*/ 1, + "supported-client", + /*supports_openai_form_elicitation*/ true, + ) + .await?; + + send_request( + &mut supported_client, + "thread/start", + /*id*/ 2, + Some(serde_json::to_value(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let ThreadStartResponse { thread, .. } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 2).await?)?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 3, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let _: TurnStartResponse = + to_response(read_response_for_id(&mut supported_client, /*id*/ 3).await?)?; + let _: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? + .params + .expect("turn/completed params"), + )?; + + let mut unsupported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut unsupported_client, + /*id*/ 4, + "unsupported-client", + /*supports_openai_form_elicitation*/ false, + ) + .await?; + send_request( + &mut unsupported_client, + "thread/resume", + /*id*/ 5, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + })?), + ) + .await?; + let _ = read_response_for_id(&mut unsupported_client, /*id*/ 5).await?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 6, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let TurnStartResponse { turn } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 6).await?)?; + + let (request_id, params) = loop { + let JSONRPCMessage::Request(request) = read_jsonrpc_message(&mut supported_client).await? + else { + continue; + }; + let request: ServerRequest = serde_json::from_value(serde_json::to_value(request)?)?; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + continue; + }; + break (request_id, params); + }; + assert_eq!( + params.request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), + } + ); + send_jsonrpc( + &mut supported_client, + JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result: serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ "template": "monthly-review" })), + meta: None, + })?, + }), + ) + .await?; + + let completed: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? + .params + .expect("turn/completed params"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(response_mock.requests().len(), 3); + + process.kill().await?; + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +async fn initialize_websocket_client( + client: &mut WsClient, + id: i64, + name: &str, + supports_openai_form_elicitation: bool, +) -> Result<()> { + send_request( + client, + "initialize", + id, + Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: name.to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: supports_openai_form_elicitation, + ..Default::default() + }), + })?), + ) + .await?; + let _ = read_response_for_id(client, id).await?; + Ok(()) +} + +async fn start_elicitation_services( + scenario: ElicitationScenario, +) -> Result<(wiremock::MockServer, ResponseMock, String, JoinHandle<()>)> { + let responses_server = responses::start_mock_server().await; + let tool_call_arguments = serde_json::to_string(&json!({}))?; + let response_mock = responses::mount_sse_sequence( + &responses_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-0"), + responses::ev_assistant_message("msg-0", "Warmup"), + responses::ev_completed("resp-0"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + TOOL_CALL_ID, + TOOL_NAMESPACE, + CALLABLE_TOOL_NAME, + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?; + Ok(( + responses_server, + response_mock, + apps_server_url, + apps_server_handle, + )) +} + struct ElicitationRoundTripFixture { mcp: TestAppServer, response_mock: ResponseMock, @@ -167,35 +409,8 @@ struct ElicitationRoundTripFixture { impl ElicitationRoundTripFixture { async fn start(scenario: ElicitationScenario) -> Result { - let responses_server = responses::start_mock_server().await; - let tool_call_arguments = serde_json::to_string(&json!({}))?; - let response_mock = responses::mount_sse_sequence( - &responses_server, - vec![ - responses::sse(vec![ - responses::ev_response_created("resp-0"), - responses::ev_assistant_message("msg-0", "Warmup"), - responses::ev_completed("resp-0"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace( - TOOL_CALL_ID, - TOOL_NAMESPACE, - CALLABLE_TOOL_NAME, - &tool_call_arguments, - ), - responses::ev_completed("resp-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-2"), - ]), - ], - ) - .await; - let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?; + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(scenario).await?; let codex_home = TempDir::new()?; write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; write_chatgpt_auth( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f8ac851bf97c..7ea6d234aa0d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -555,6 +555,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_name: "codex_exec".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 002e64726345..f189b231497f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -399,6 +399,7 @@ async fn connect_remote_app_server( client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -562,6 +563,7 @@ where client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fceb7fb059d8..0170ae49d480 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1055,6 +1055,7 @@ mod tests { client_name: "test".to_string(), client_version: "test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, })