From 16f525d099e11fc923962854204ad5eaf2a139ae Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:32:57 -0700 Subject: [PATCH 1/5] Advertise MCP App UI support to Codex Apps --- .../analytics/src/analytics_client_tests.rs | 28 +--- codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server-client/src/remote.rs | 1 + .../schema/json/ClientRequest.json | 8 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../schema/json/v1/InitializeParams.json | 8 + .../typescript/InitializeCapabilities.ts | 10 +- .../src/protocol/common.rs | 26 ++- .../app-server-protocol/src/protocol/v1.rs | 7 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 23 ++- .../initialize_processor.rs | 3 + .../src/request_processors/mcp_processor.rs | 13 +- .../request_processors/thread_processor.rs | 29 +++- .../src/request_processors/turn_processor.rs | 43 ++++- .../app-server/tests/suite/v2/attestation.rs | 3 +- .../tests/suite/v2/experimental_api.rs | 56 +------ .../app-server/tests/suite/v2/initialize.rs | 3 +- .../tests/suite/v2/thread_status.rs | 3 +- codex-rs/codex-mcp/src/client_capabilities.rs | 155 ++++++++++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 11 ++ .../codex-mcp/src/connection_manager_tests.rs | 1 + codex-rs/codex-mcp/src/lib.rs | 2 + codex-rs/codex-mcp/src/mcp/mod.rs | 7 + codex-rs/codex-mcp/src/mcp/mod_tests.rs | 1 + codex-rs/codex-mcp/src/rmcp_client.rs | 48 +++++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 1 + codex-rs/core/src/codex_thread.rs | 44 ++++- codex-rs/core/src/config/mod.rs | 6 + codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/realtime_conversation.rs | 14 +- codex-rs/core/src/session/handlers.rs | 60 ++++++- codex-rs/core/src/session/mcp.rs | 15 +- codex-rs/core/src/session/mcp_runtime.rs | 1 + codex-rs/core/src/session/mod.rs | 30 +++- codex-rs/core/src/session/session.rs | 7 + codex-rs/core/src/session/tests.rs | 119 ++++++++++++++ codex-rs/core/src/session/turn_context.rs | 14 ++ .../core/tests/suite/mcp_turn_metadata.rs | 65 ++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 5 + codex-rs/thread-manager-sample/src/main.rs | 1 + 46 files changed, 779 insertions(+), 115 deletions(-) create mode 100644 codex-rs/codex-mcp/src/client_capabilities.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0f759fd93862..582a8edaf748 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -793,12 +793,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { title: None, version: "1.0.0".to_string(), }, - capabilities: Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + capabilities: Some(InitializeCapabilities::default()), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1674,12 +1669,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize title: None, version: "1.0.0".to_string(), }, - capabilities: Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + capabilities: Some(InitializeCapabilities::default()), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: CodexRuntimeMetadata { @@ -1978,12 +1968,7 @@ async fn compaction_event_ingests_custom_fact() { title: None, version: "1.0.0".to_string(), }, - capabilities: Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + capabilities: Some(InitializeCapabilities::default()), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), runtime: sample_runtime_metadata(), @@ -2108,12 +2093,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { title: None, version: "1.0.0".to_string(), }, - capabilities: Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + capabilities: Some(InitializeCapabilities::default()), }, product_client_id: DEFAULT_ORIGINATOR.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 4eec8a2348d4..5d5ae83f11bb 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 { Some(self.opt_out_notification_methods.clone()) }, mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, + extensions: None, }; InitializeParams { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 5575fe4ad46a..c51414935a19 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -101,6 +101,7 @@ impl RemoteAppServerConnectArgs { Some(self.opt_out_notification_methods.clone()) }, mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, + extensions: None, }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2f39f821b6a8..a186fc3fa5a2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1279,6 +1279,14 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.", + "type": [ + "object", + "null" + ] + }, "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 2ffff2f96f2b..06bf2f52697b 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 @@ -2923,6 +2923,14 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.", + "type": [ + "object", + "null" + ] + }, "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 dc6e2c969317..49845a2be04b 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 @@ -7524,6 +7524,14 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.", + "type": [ + "object", + "null" + ] + }, "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 75f0860ddda5..dea49b20b5e0 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,14 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.", + "type": [ + "object", + "null" + ] + }, "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 dcc4dffb0b51..4e4f8687fc0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -1,6 +1,7 @@ // 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. +import type { JsonValue } from "./serde_json/JsonValue"; /** * Client-declared capabilities negotiated during initialize. @@ -22,4 +23,11 @@ mcpServerOpenaiFormElicitation?: boolean, * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). */ -optOutNotificationMethods?: Array | null, }; +optOutNotificationMethods?: Array | null, +/** + * MCP extension settings declared by the app-server client. + * + * Codex currently consumes `io.modelcontextprotocol/ui` so widget-capable + * hosts can advertise the same UI support to downstream MCP servers. + */ +extensions?: { [key in string]?: JsonValue } | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c4b94d8b3fb3..02df23890252 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2207,6 +2207,12 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), }), }, }; @@ -2228,7 +2234,12 @@ mod tests { "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" - ] + ], + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } } } }), @@ -2255,7 +2266,12 @@ mod tests { "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" - ] + ], + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } } } }))?; @@ -2278,6 +2294,12 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), }), }, } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index dccea51a3601..7c5953120e2c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -57,6 +57,13 @@ pub struct InitializeCapabilities { /// connection (for example `thread/started`). #[ts(optional = nullable)] pub opt_out_notification_methods: Option>, + /// MCP extension settings declared by the app-server client. + /// + /// Codex currently consumes `io.modelcontextprotocol/ui` so widget-capable + /// hosts can advertise the same UI support to downstream MCP servers. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub extensions: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a70945c46856..51342aff8c49 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1671,6 +1671,7 @@ impl CodexClient { .collect(), ), mcp_server_openai_form_elicitation: false, + extensions: None, }), }, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6ae73784ff66..b777e7cf2f5a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -225,6 +225,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) client_version: String, pub(crate) request_attestation: bool, pub(crate) supports_openai_form_elicitation: bool, + pub(crate) supports_mcp_app_ui_webview: bool, } impl Default for ConnectionSessionState { @@ -281,6 +282,12 @@ impl ConnectionSessionState { .get() .is_some_and(|session| session.supports_openai_form_elicitation) } + + pub(crate) fn supports_mcp_app_ui_webview(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.supports_mcp_app_ui_webview) + } pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -897,6 +904,7 @@ impl MessageProcessor { 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 supports_openai_form_elicitation = session.supports_openai_form_elicitation(); + let supports_mcp_app_ui_webview = session.supports_mcp_app_ui_webview(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -913,6 +921,7 @@ impl MessageProcessor { app_server_client_name, client_version, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await; if let Err(error) = result { @@ -935,6 +944,7 @@ impl MessageProcessor { Ok(()) } + #[allow(clippy::too_many_arguments)] async fn handle_initialized_client_request( self: Arc, connection_request_id: ConnectionRequestId, @@ -943,6 +953,7 @@ impl MessageProcessor { app_server_client_name: Option, client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1099,6 +1110,7 @@ impl MessageProcessor { app_server_client_name.clone(), client_version.clone(), supports_openai_form_elicitation, + supports_mcp_app_ui_webview, request_context, ) .await @@ -1117,6 +1129,7 @@ impl MessageProcessor { client_version.clone(), /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await } @@ -1129,6 +1142,7 @@ impl MessageProcessor { client_version.clone(), /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await } @@ -1330,6 +1344,7 @@ impl MessageProcessor { client_version.clone(), /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await } @@ -1346,7 +1361,7 @@ impl MessageProcessor { } ClientRequest::ThreadRealtimeStart { params, .. } => { self.turn_processor - .thread_realtime_start(&request_id, params) + .thread_realtime_start(&request_id, params, supports_mcp_app_ui_webview) .await } ClientRequest::ThreadRealtimeAppendAudio { params, .. } => { @@ -1373,7 +1388,9 @@ impl MessageProcessor { self.turn_processor.thread_realtime_list_voices().await } ClientRequest::ReviewStart { params, .. } => { - self.turn_processor.review_start(&request_id, params).await + self.turn_processor + .review_start(&request_id, params, supports_mcp_app_ui_webview) + .await } ClientRequest::McpServerOauthLogin { params, .. } => { self.mcp_processor.mcp_server_oauth_login(params).await @@ -1393,7 +1410,7 @@ impl MessageProcessor { } ClientRequest::McpServerToolCall { params, .. } => { self.mcp_processor - .mcp_server_tool_call(&request_id, params) + .mcp_server_tool_call(&request_id, params, supports_mcp_app_ui_webview) .await } ClientRequest::WindowsSandboxSetupStart { params, .. } => { 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 cfdad27f50ff..f6fa82668011 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -71,6 +71,8 @@ impl InitializeRequestProcessor { 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 supports_mcp_app_ui_webview = + codex_mcp::supports_mcp_app_ui_webview(capabilities.extensions.as_ref()); let opt_out_notification_methods = capabilities .opt_out_notification_methods .unwrap_or_default(); @@ -98,6 +100,7 @@ impl InitializeRequestProcessor { client_version: version, request_attestation, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index ac2125c2cfcd..133619e9a040 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -67,8 +67,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpServerToolCallParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { - self.call_mcp_server_tool(request_id, params) + self.call_mcp_server_tool(request_id, params, supports_mcp_app_ui_webview) .await .map(|()| None) } @@ -456,16 +457,22 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpServerToolCallParams, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let outgoing = Arc::clone(&self.outgoing); let thread_id = params.thread_id.clone(); let (_, thread) = self.load_thread(&thread_id).await?; + let mcp_runtime = thread + .current_mcp_runtime_with_mcp_app_ui_webview_support(supports_mcp_app_ui_webview) + .await + .map_err(|err| internal_error(format!("failed to resolve MCP runtime: {err}")))?; let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); let request_id = request_id.clone(); tokio::spawn(async move { - let result = thread - .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) + let result = mcp_runtime + .manager() + .call_tool(¶ms.server, ¶ms.tool, params.arguments, meta) .await .map(McpServerToolCallResponse::from) .map_err(|error| internal_error(format!("{error:#}"))); 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 af8525873f52..b3b9ba31a898 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -419,6 +419,7 @@ impl ThreadRequestProcessor { } } + #[allow(clippy::too_many_arguments)] pub(crate) async fn thread_start( &self, request_id: ConnectionRequestId, @@ -426,6 +427,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -434,6 +436,7 @@ impl ThreadRequestProcessor { app_server_client_name, app_server_client_version, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, request_context, ) .await @@ -457,6 +460,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, @@ -464,6 +468,7 @@ impl ThreadRequestProcessor { app_server_client_name, app_server_client_version, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await .map(|()| None) @@ -476,6 +481,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, @@ -483,6 +489,7 @@ impl ThreadRequestProcessor { app_server_client_name, app_server_client_version, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await .map(|()| None) @@ -776,6 +783,7 @@ impl ThreadRequestProcessor { thread: &CodexThread, app_server_client_name: Option, app_server_client_version: Option, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( app_server_client_name.as_deref(), @@ -785,6 +793,7 @@ impl ThreadRequestProcessor { .set_app_server_client_info( app_server_client_name, app_server_client_version, + Some(supports_mcp_app_ui_webview), mcp_elicitations_auto_deny, ) .await @@ -899,6 +908,7 @@ impl ThreadRequestProcessor { .await } + #[allow(clippy::too_many_arguments)] async fn thread_start_inner( &self, request_id: ConnectionRequestId, @@ -906,6 +916,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -982,6 +993,7 @@ impl ThreadRequestProcessor { app_server_client_name, app_server_client_version, supports_openai_form_elicitation, + supports_mcp_app_ui_webview, config, typesafe_overrides, dynamic_tools, @@ -1059,6 +1071,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1145,6 +1158,8 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; + if let Ok(Some(err)) = codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1230,6 +1245,7 @@ impl ThreadRequestProcessor { thread.as_ref(), app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, ) .await?; @@ -2640,6 +2656,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2684,6 +2701,7 @@ impl ThreadRequestProcessor { ¶ms, app_server_client_name.clone(), app_server_client_version.clone(), + supports_mcp_app_ui_webview, ) .await { @@ -2762,7 +2780,7 @@ impl ThreadRequestProcessor { .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self + let mut config = match self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await @@ -2774,6 +2792,7 @@ impl ThreadRequestProcessor { return Ok(()); } }; + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; let response_history = thread_history.clone(); @@ -2798,6 +2817,7 @@ impl ThreadRequestProcessor { codex_thread.as_ref(), app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, ) .await { @@ -2977,6 +2997,7 @@ impl ThreadRequestProcessor { params: &ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_mcp_app_ui_webview: bool, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -3105,6 +3126,7 @@ impl ThreadRequestProcessor { existing_thread.as_ref(), app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, ) .await?; @@ -3405,6 +3427,7 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3498,11 +3521,12 @@ impl ThreadRequestProcessor { ); typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self + let mut config = self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await .map_err(|err| config_load_error(&err))?; + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; let fallback_model_provider = config.model_provider_id.clone(); @@ -3538,6 +3562,7 @@ impl ThreadRequestProcessor { forked_thread.as_ref(), app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, ) .await?; if session_configured.rollout_path.is_some() 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 3ff64cd34e3c..6bc64f01e6f4 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -159,6 +159,7 @@ impl TurnRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { validate_user_input_image_urls(¶ms.input)?; self.turn_start_inner( @@ -167,6 +168,7 @@ impl TurnRequestProcessor { app_server_client_name, app_server_client_version, /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + supports_mcp_app_ui_webview, ) .await .map(|response| Some(response.into())) @@ -216,8 +218,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStartParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_start_inner(request_id, params) + self.thread_realtime_start_inner(request_id, params, supports_mcp_app_ui_webview) .await .map(|response| response.map(Into::into)) } @@ -277,8 +280,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { - self.review_start_inner(request_id, params) + self.review_start_inner(request_id, params, supports_mcp_app_ui_webview) .await .map(|()| None) } @@ -419,6 +423,22 @@ impl TurnRequestProcessor { .await } + async fn submit_core_op_with_mcp_app_ui_webview_support( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + supports_mcp_app_ui_webview: bool, + ) -> CodexResult { + thread + .submit_with_trace_and_mcp_app_ui_webview_support( + op, + self.request_trace_context(request_id).await, + supports_mcp_app_ui_webview, + ) + .await + } + fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { let mut error = invalid_params(format!( "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." @@ -446,6 +466,7 @@ impl TurnRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, supports_openai_form_elicitation: bool, + supports_mcp_app_ui_webview: bool, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -531,6 +552,7 @@ impl TurnRequestProcessor { turn_op, self.request_trace_context(&request_id).await, client_user_message_id, + supports_mcp_app_ui_webview, ) .await .map_err(|err| { @@ -840,6 +862,7 @@ impl TurnRequestProcessor { .set_app_server_client_info( app_server_client_name, app_server_client_version, + None, mcp_elicitations_auto_deny, ) .await @@ -990,6 +1013,7 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStartParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) @@ -997,7 +1021,7 @@ impl TurnRequestProcessor { else { return Ok(None); }; - self.submit_core_op( + self.submit_core_op_with_mcp_app_ui_webview_support( request_id, thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { @@ -1024,6 +1048,7 @@ impl TurnRequestProcessor { version: params.version, voice: params.voice, }), + supports_mcp_app_ui_webview, ) .await .map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?; @@ -1178,12 +1203,14 @@ impl TurnRequestProcessor { review_request: ReviewRequest, display_text: &str, parent_thread_id: String, + supports_mcp_app_ui_webview: bool, ) -> std::result::Result<(), JSONRPCErrorError> { let turn_id = self - .submit_core_op( + .submit_core_op_with_mcp_app_ui_webview_support( request_id, parent_thread.as_ref(), Op::Review { review_request }, + supports_mcp_app_ui_webview, ) .await .map_err(|err| internal_error(format!("failed to start review: {err}")))?; @@ -1200,6 +1227,7 @@ impl TurnRequestProcessor { parent_thread: Arc, review_request: ReviewRequest, display_text: &str, + supports_mcp_app_ui_webview: bool, ) -> std::result::Result<(), JSONRPCErrorError> { parent_thread.ensure_rollout_materialized().await; parent_thread.flush_rollout().await.map_err(|err| { @@ -1217,6 +1245,7 @@ impl TurnRequestProcessor { })?; let mut config = self.config.as_ref().clone(); + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; if let Some(review_model) = &config.review_model { config.model = Some(review_model.clone()); } @@ -1287,10 +1316,11 @@ impl TurnRequestProcessor { } let turn_id = self - .submit_core_op( + .submit_core_op_with_mcp_app_ui_webview_support( request_id, review_thread.as_ref(), Op::Review { review_request }, + supports_mcp_app_ui_webview, ) .await .map_err(|err| { @@ -1309,6 +1339,7 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let ReviewStartParams { thread_id, @@ -1326,6 +1357,7 @@ impl TurnRequestProcessor { review_request, &display_text, thread_id, + supports_mcp_app_ui_webview, ) .await?; } @@ -1336,6 +1368,7 @@ impl TurnRequestProcessor { parent_thread, review_request, &display_text, + supports_mcp_app_ui_webview, ) .await?; } diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index ea567a2d43cb..d5c02abe6af2 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -80,8 +80,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand Some(InitializeCapabilities { experimental_api: true, request_attestation: true, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, + ..Default::default() }), ), ) 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 80fdb0317151..e2cc28a6e6db 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -35,12 +35,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -67,12 +62,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -114,12 +104,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -149,12 +134,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -184,12 +164,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -235,12 +210,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -274,12 +244,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { @@ -312,12 +277,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil let init = mcp .initialize_with_capabilities( default_client_info(), - Some(InitializeCapabilities { - experimental_api: false, - request_attestation: false, - opt_out_notification_methods: None, - mcp_server_openai_form_elicitation: false, - }), + Some(InitializeCapabilities::default()), ) .await?; let JSONRPCMessage::Response(_) = init else { diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 69253e760f2c..878a386de2d7 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -212,9 +212,8 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu }, Some(InitializeCapabilities { experimental_api: true, - request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), - mcp_server_openai_form_elicitation: false, + ..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 364175b7ae09..1d540bd882ed 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -146,9 +146,8 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { }, Some(InitializeCapabilities { experimental_api: true, - request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), - mcp_server_openai_form_elicitation: false, + ..Default::default() }), ), ) diff --git a/codex-rs/codex-mcp/src/client_capabilities.rs b/codex-rs/codex-mcp/src/client_capabilities.rs new file mode 100644 index 000000000000..c89f80ee6904 --- /dev/null +++ b/codex-rs/codex-mcp/src/client_capabilities.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; + +use rmcp::model::ExtensionCapabilities; +use serde_json::Map; +use serde_json::Value; + +const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; +const MCP_APP_UI_WEBVIEW_MIME_TYPE: &str = "text/html;profile=mcp-app"; +const MCP_CLIENT_CAPABILITIES_META_KEY: &str = "io.modelcontextprotocol/clientCapabilities"; + +/// Returns whether the app-server host can render MCP App WebViews. +/// +/// App-server clients may declare unrelated extensions or additional UI MIME +/// types. Codex only forwards this one trusted capability because it is the +/// rendering contract downstream MCP servers need for MCP App widgets. +pub fn supports_mcp_app_ui_webview(extensions: Option<&HashMap>) -> bool { + extensions + .and_then(|extensions| extensions.get(MCP_APP_UI_EXTENSION_ID)) + .and_then(Value::as_object) + .and_then(|settings| settings.get("mimeTypes")) + .and_then(Value::as_array) + .is_some_and(|mime_types| { + mime_types + .iter() + .any(|mime_type| mime_type.as_str() == Some(MCP_APP_UI_WEBVIEW_MIME_TYPE)) + }) +} + +pub(crate) fn mcp_app_ui_extensions() -> ExtensionCapabilities { + let mut settings = Map::new(); + settings.insert( + "mimeTypes".to_string(), + serde_json::json!([MCP_APP_UI_WEBVIEW_MIME_TYPE]), + ); + [(MCP_APP_UI_EXTENSION_ID.to_string(), settings)] + .into_iter() + .collect() +} + +/// Adds trusted host capabilities to a Codex Apps request and removes any +/// caller-supplied copy of the reserved metadata key. +pub(crate) fn with_client_capabilities_meta( + meta: Option, + supports_mcp_app_ui_webview: bool, +) -> Option { + let meta = match meta { + Some(Value::Object(mut object)) => { + object.remove(MCP_CLIENT_CAPABILITIES_META_KEY); + Some(Value::Object(object)) + } + other => other, + }; + if !supports_mcp_app_ui_webview { + return meta; + } + let extensions = mcp_app_ui_extensions(); + let capabilities = serde_json::json!({ "extensions": extensions }); + let mut object = match meta { + Some(Value::Object(object)) => object, + _ => Map::new(), + }; + object.insert(MCP_CLIENT_CAPABILITIES_META_KEY.to_string(), capabilities); + Some(Value::Object(object)) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn recognizes_only_the_webview_mime_type() { + let extensions = HashMap::from([ + ( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!({ + "mimeTypes": [ + MCP_APP_UI_WEBVIEW_MIME_TYPE, + "text/x-dil;profile=mcp-app", + ], + }), + ), + ("example/other".to_string(), json!({"enabled": true})), + ]); + + assert!(supports_mcp_app_ui_webview(Some(&extensions))); + assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!({"mimeTypes": ["text/x-dil;profile=mcp-app"]}), + ),])))); + assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!("invalid") + ),])))); + } + + #[test] + fn trusted_capabilities_replace_spoofed_request_metadata() { + let meta = with_client_capabilities_meta( + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, + "caller": "preserved", + })), + true, + ); + + assert_eq!( + meta, + Some(json!({ + "caller": "preserved", + MCP_CLIENT_CAPABILITIES_META_KEY: { + "extensions": { + MCP_APP_UI_EXTENSION_ID: { + "mimeTypes": [ + MCP_APP_UI_WEBVIEW_MIME_TYPE, + ] + } + } + } + })) + ); + } + + #[test] + fn absent_host_capabilities_remove_spoofed_request_metadata() { + assert_eq!( + with_client_capabilities_meta( + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, + "caller": "preserved", + })), + false, + ), + Some(json!({"caller": "preserved"})) + ); + } + + #[test] + fn invalid_caller_metadata_does_not_suppress_trusted_capabilities() { + assert_eq!( + with_client_capabilities_meta(Some(json!("invalid")), true), + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: { + "extensions": { + MCP_APP_UI_EXTENSION_ID: { + "mimeTypes": [MCP_APP_UI_WEBVIEW_MIME_TYPE], + } + } + } + })) + ); + } +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 2143e3b57765..351e4c4c9469 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::client_capabilities::with_client_capabilities_meta; use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheKey; use crate::codex_apps_cache::CodexAppsToolsFetchSource; @@ -116,6 +117,7 @@ pub struct McpConnectionManager { required_servers: Vec, tool_plugin_provenance: Arc, prefix_mcp_tool_names: bool, + supports_mcp_app_ui_webview: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -138,6 +140,7 @@ impl McpConnectionManager { codex_apps_tools_cache_key: CodexAppsToolsCacheKey, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, + supports_mcp_app_ui_webview: bool, supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, @@ -222,6 +225,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), + server_name == CODEX_APPS_MCP_SERVER_NAME && supports_mcp_app_ui_webview, supports_openai_form_elicitation, ); clients.insert(server_name.clone(), async_managed_client.clone()); @@ -273,6 +277,7 @@ impl McpConnectionManager { required_servers, tool_plugin_provenance, prefix_mcp_tool_names, + supports_mcp_app_ui_webview, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: startup_cancellation_token.clone(), }; @@ -358,6 +363,7 @@ impl McpConnectionManager { required_servers: Vec::new(), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), prefix_mcp_tool_names, + supports_mcp_app_ui_webview: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.clone(), @@ -749,6 +755,11 @@ impl McpConnectionManager { )); } + let meta = if server == CODEX_APPS_MCP_SERVER_NAME { + with_client_capabilities_meta(meta, self.supports_mcp_app_ui_webview) + } else { + meta + }; let result: rmcp::model::CallToolResult = client .client .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 41fc19a594f5..5794a4759f8e 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1558,6 +1558,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { }, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), + /*supports_mcp_app_ui_webview*/ false, /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), /*auth*/ None, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 77128d79701a..807f03ae4afa 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,3 +1,4 @@ +pub use client_capabilities::supports_mcp_app_ui_webview; pub use connection_manager::McpConnectionManager; pub use connection_manager::tool_is_model_visible; pub use elicitation::ElicitationLifecycle; @@ -77,6 +78,7 @@ pub use tools::declared_openai_file_input_param_names; pub(crate) mod auth_elicitation; mod catalog; +pub(crate) mod client_capabilities; pub(crate) mod codex_apps; pub(crate) mod codex_apps_cache; pub(crate) mod connection_manager; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 87ea25dd9f16..a1a7744d147e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -145,6 +145,11 @@ pub struct McpConfig { pub prefix_mcp_tool_names: bool, /// Client-side elicitation capabilities advertised during MCP initialization. pub client_elicitation_capability: ElicitationCapability, + /// Whether the active host can render MCP App WebViews. + /// + /// This is runtime-only and is forwarded only to the host-owned Codex Apps + /// server, never to arbitrary configured MCP servers. + pub supports_mcp_app_ui_webview: bool, /// Resolved MCP registrations keyed by logical server name. pub mcp_server_catalog: ResolvedMcpCatalog, /// Plugin declarations used to attribute connector tools to plugin display names. @@ -335,6 +340,7 @@ pub async fn read_mcp_resource( codex_apps_tools_cache_key(auth), config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + config.supports_mcp_app_ui_webview, /*supports_openai_form_elicitation*/ false, tool_plugin_provenance(config), auth, @@ -413,6 +419,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( codex_apps_tools_cache_key(auth), config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + config.supports_mcp_app_ui_webview, /*supports_openai_form_elicitation*/ false, tool_plugin_provenance, auth, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 7d166cbab80f..962e01851bce 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -33,6 +33,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { apps_enabled: false, prefix_mcp_tool_names: true, client_elicitation_capability: ElicitationCapability::default(), + supports_mcp_app_ui_webview: false, mcp_server_catalog: ResolvedMcpCatalog::default(), connector_snapshot: codex_connectors::ConnectorSnapshot::default(), } diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 37819203f6ac..b1c889b2bca5 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; +#[cfg(test)] use std::collections::BTreeMap; use std::collections::HashMap; use std::env; @@ -275,6 +276,7 @@ struct ManagedClientStartup { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + supports_mcp_app_ui_webview: bool, supports_openai_form_elicitation: bool, cancel_token: CancellationToken, startup_complete: Arc, @@ -293,6 +295,7 @@ impl ManagedClientStartup { runtime_context, runtime_auth_provider, client_elicitation_capability, + supports_mcp_app_ui_webview, supports_openai_form_elicitation, cancel_token, startup_complete, @@ -338,6 +341,7 @@ impl ManagedClientStartup { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_mcp_app_ui_webview, supports_openai_form_elicitation, }, ) @@ -391,6 +395,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + supports_mcp_app_ui_webview: bool, supports_openai_form_elicitation: bool, ) -> Self { let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; @@ -419,6 +424,7 @@ impl AsyncManagedClient { runtime_context, runtime_auth_provider, client_elicitation_capability, + supports_mcp_app_ui_webview, supports_openai_form_elicitation, cancel_token: cancel_token.clone(), startup_complete: Arc::clone(&startup_complete), @@ -799,10 +805,12 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_mcp_app_ui_webview, supports_openai_form_elicitation, } = params; let params = mcp_initialize_request_params( client_elicitation_capability, + supports_mcp_app_ui_webview, supports_openai_form_elicitation, ); @@ -871,16 +879,20 @@ async fn start_server_task( fn mcp_initialize_request_params( client_elicitation_capability: ElicitationCapability, + supports_mcp_app_ui_webview: bool, supports_openai_form_elicitation: bool, ) -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); + let mut extensions = if supports_mcp_app_ui_webview { + crate::client_capabilities::mcp_app_ui_extensions() + } else { + Default::default() + }; if supports_openai_form_elicitation { - capabilities.extensions = Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])); + extensions.insert(OPENAI_FORM_CAPABILITY.to_string(), JsonObject::new()); } + capabilities.extensions = (!extensions.is_empty()).then_some(extensions); InitializeRequestParams::new( capabilities, Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), @@ -913,6 +925,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, + supports_mcp_app_ui_webview: bool, supports_openai_form_elicitation: bool, } @@ -1023,24 +1036,45 @@ mod tests { } #[test] - fn mcp_initialize_advertises_openai_form_only_when_supported() { + fn mcp_initialize_advertises_extensions_only_when_supported() { let unsupported = mcp_initialize_request_params( ElicitationCapability::default(), + /*supports_mcp_app_ui_webview*/ false, /*supports_openai_form_elicitation*/ false, ); assert_eq!(unsupported.capabilities.extensions, None); - let supported = mcp_initialize_request_params( + let form_supported = mcp_initialize_request_params( ElicitationCapability::default(), + /*supports_mcp_app_ui_webview*/ false, /*supports_openai_form_elicitation*/ true, ); assert_eq!( - supported.capabilities.extensions, + form_supported.capabilities.extensions, Some(BTreeMap::from([( OPENAI_FORM_CAPABILITY.to_string(), JsonObject::new(), )])) ); + + let ui_supported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_mcp_app_ui_webview*/ true, + /*supports_openai_form_elicitation*/ false, + ); + assert_eq!( + ui_supported.capabilities.extensions, + Some(crate::client_capabilities::mcp_app_ui_extensions()) + ); + + let both_supported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_mcp_app_ui_webview*/ true, + /*supports_openai_form_elicitation*/ true, + ); + let mut expected = crate::client_capabilities::mcp_app_ui_extensions(); + expected.insert(OPENAI_FORM_CAPABILITY.to_string(), JsonObject::new()); + assert_eq!(both_supported.capabilities.extensions, Some(expected)); } fn tool_with_connector_meta() -> RmcpTool { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4cba4bddc5eb..923ea54b05e1 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -250,6 +250,7 @@ pub(crate) async fn run_codex_thread_one_shot( id: "shutdown".to_string(), op: Op::Shutdown {}, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: None, }) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 5cb2251a4a38..f186455d80fc 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -156,6 +156,7 @@ async fn forward_ops_preserves_submission_trace_context() { id: "sub-1".to_string(), op: Op::Interrupt, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: Some(codex_protocol::protocol::W3cTraceContext { traceparent: Some( "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index c80268e2f9c7..cea203622d9e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -258,11 +258,27 @@ impl CodexThread { self.codex.submit_with_trace(op, trace).await } + pub async fn submit_with_trace_and_mcp_app_ui_webview_support( + &self, + op: Op, + trace: Option, + supports_mcp_app_ui_webview: bool, + ) -> CodexResult { + self.codex + .submit_with_trace_and_mcp_app_ui_webview_support( + op, + trace, + supports_mcp_app_ui_webview, + ) + .await + } + pub async fn submit_user_input_with_client_user_message_id( &self, op: Op, trace: Option, client_user_message_id: Option, + supports_mcp_app_ui_webview: bool, ) -> CodexResult { self.codex .session @@ -271,7 +287,12 @@ impl CodexThread { .ensure_execution_capacity_for_op(self.session_configured.thread_id, &op) .await?; self.codex - .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) + .submit_user_input_with_client_user_message_id( + op, + trace, + client_user_message_id, + supports_mcp_app_ui_webview, + ) .await } @@ -335,12 +356,14 @@ impl CodexThread { &self, app_server_client_name: Option, app_server_client_version: Option, + supports_mcp_app_ui_webview: Option, mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.codex .set_app_server_client_info( app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, mcp_elicitations_auto_deny, ) .await @@ -614,6 +637,25 @@ impl CodexThread { .clone() } + /// Captures the MCP runtime for one app-server request before detached work begins. + pub async fn current_mcp_runtime_with_mcp_app_ui_webview_support( + &self, + supports_mcp_app_ui_webview: bool, + ) -> CodexResult> { + let turn_context = self + .codex + .session + .new_default_turn_with_mcp_app_ui_webview_support(supports_mcp_app_ui_webview) + .await?; + Ok(self + .codex + .session + .capture_step_context(turn_context) + .await + .mcp + .clone()) + } + pub fn multi_agent_version(&self) -> Option { self.codex.session.multi_agent_version() } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8c0a165564db..aed936456821 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -967,6 +967,10 @@ pub struct Config { /// Optional product SKU forwarded to the host-owned apps MCP server. pub apps_mcp_product_sku: Option, + /// Whether the active app-server host can render MCP App WebViews. + /// + /// This is runtime-only state, not a config.toml setting. + pub supports_mcp_app_ui_webview: bool, /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -1584,6 +1588,7 @@ impl Config { McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), apps_mcp_product_sku: self.apps_mcp_product_sku.clone(), + supports_mcp_app_ui_webview: self.supports_mcp_app_ui_webview, codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, auth_keyring_backend_kind: self.auth_keyring_backend_kind(), @@ -3908,6 +3913,7 @@ impl Config { .unwrap_or("https://chatgpt.com/backend-api/".to_string()), respect_system_proxy, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), + supports_mcp_app_ui_webview: false, realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 6e643f34d5ca..54688ae60619 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -266,6 +266,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( codex_apps_tools_cache_key(auth.as_ref()), mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + mcp_config.supports_mcp_app_ui_webview, /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), auth.as_ref(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index f68d32ff4e69..bd13c1e00659 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1435,6 +1435,7 @@ async fn host_owned_codex_apps_manager( codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), + turn_context.config.supports_mcp_app_ui_webview, /*supports_openai_form_elicitation*/ false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 3915c8190dda..13a3068fb892 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -700,6 +700,7 @@ pub(crate) async fn handle_start( sess: &Arc, sub_id: String, params: ConversationStartParams, + supports_mcp_app_ui_webview: Option, ) -> CodexResult<()> { let prepared_start = match prepare_realtime_start(sess, params).await { Ok(prepared_start) => prepared_start, @@ -717,7 +718,9 @@ pub(crate) async fn handle_start( } }; - if let Err(err) = handle_start_inner(sess, &sub_id, prepared_start).await { + if let Err(err) = + handle_start_inner(sess, &sub_id, prepared_start, supports_mcp_app_ui_webview).await + { error!("failed to start realtime conversation: {err}"); let message = err.to_string(); sess.send_event_raw(Event { @@ -982,6 +985,7 @@ async fn handle_start_inner( sess: &Arc, sub_id: &str, prepared_start: PreparedRealtimeConversationStart, + supports_mcp_app_ui_webview: Option, ) -> CodexResult<()> { let PreparedRealtimeConversationStart { api_provider, @@ -1074,7 +1078,9 @@ async fn handle_start_inner( if let Some(text) = maybe_routed_text { debug!(text = %text, "[realtime-text] realtime conversation text output"); let sess_for_routed_text = Arc::clone(&sess_clone); - sess_for_routed_text.route_realtime_text_input(text).await; + sess_for_routed_text + .route_realtime_text_input(text, supports_mcp_app_ui_webview) + .await; } sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( @@ -1085,7 +1091,9 @@ async fn handle_start_inner( .await; } if let Ok(text) = transcript_tail_rx.recv().await { - sess_clone.route_realtime_text_input(text).await; + sess_clone + .route_realtime_text_input(text, supports_mcp_app_ui_webview) + .await; } if fanout_realtime_active.swap(false, Ordering::Relaxed) { match end { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 924750bc34f6..2a115f6f2d95 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -85,8 +85,16 @@ pub async fn user_input_or_turn( sub_id: String, op: Op, client_user_message_id: Option, + supports_mcp_app_ui_webview: Option, ) { - user_input_or_turn_inner(sess, sub_id, op, client_user_message_id).await; + user_input_or_turn_inner( + sess, + sub_id, + op, + client_user_message_id, + supports_mcp_app_ui_webview, + ) + .await; } pub async fn update_thread_settings( @@ -185,6 +193,7 @@ pub(super) async fn user_input_or_turn_inner( sub_id: String, op: Op, client_user_message_id: Option, + supports_mcp_app_ui_webview: Option, ) { let Op::UserInput { items, @@ -203,6 +212,7 @@ pub(super) async fn user_input_or_turn_inner( SessionSettingsUpdate::default() }; updates.final_output_json_schema = Some(final_output_json_schema); + updates.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. @@ -669,8 +679,26 @@ pub async fn review( config: &Arc, sub_id: String, review_request: ReviewRequest, + supports_mcp_app_ui_webview: Option, ) { - let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + let turn_context = match supports_mcp_app_ui_webview { + Some(supports_mcp_app_ui_webview) => { + let Ok(turn_context) = sess + .new_turn_with_sub_id( + sub_id.clone(), + SessionSettingsUpdate { + supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview), + ..Default::default() + }, + ) + .await + else { + return; + }; + turn_context + } + None => sess.new_default_turn_with_sub_id(sub_id.clone()).await, + }; sess.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) @@ -721,8 +749,13 @@ pub(super) async fn submission_loop( false } Op::RealtimeConversationStart(params) => { - if let Err(err) = - handle_realtime_conversation_start(&sess, sub.id.clone(), params).await + if let Err(err) = handle_realtime_conversation_start( + &sess, + sub.id.clone(), + params, + sub.supports_mcp_app_ui_webview, + ) + .await { sess.send_event_raw(Event { id: sub.id.clone(), @@ -756,8 +789,14 @@ pub(super) async fn submission_loop( false } Op::UserInput { .. } => { - user_input_or_turn(&sess, sub.id.clone(), sub.op, sub.client_user_message_id) - .await; + user_input_or_turn( + &sess, + sub.id.clone(), + sub.op, + sub.client_user_message_id, + sub.supports_mcp_app_ui_webview, + ) + .await; false } Op::ThreadSettings { thread_settings } => { @@ -829,7 +868,14 @@ pub(super) async fn submission_loop( } Op::Shutdown => shutdown(&sess, sub.id.clone()).await, Op::Review { review_request } => { - review(&sess, &config, sub.id.clone(), review_request).await; + review( + &sess, + &config, + sub.id.clone(), + review_request, + sub.supports_mcp_app_ui_webview, + ) + .await; false } Op::ApproveGuardianDeniedAction { event } => { diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 3f5e92eca7c5..008e2972c13e 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -116,13 +116,21 @@ impl Session { let available_environment_ids = Self::available_selected_environment_ids(selected_capability_roots); let current = self.services.latest_mcp_runtime(); - if current.available_environment_ids() == available_environment_ids { + let host_capability_matches = current.config().supports_mcp_app_ui_webview + == turn_context.config.supports_mcp_app_ui_webview; + if current.available_environment_ids() == available_environment_ids + && host_capability_matches + { return current; } let _guard = self.services.mcp_projection_lock.lock().await; let current = self.services.latest_mcp_runtime(); - if current.available_environment_ids() == available_environment_ids { + let host_capability_matches = current.config().supports_mcp_app_ui_webview + == turn_context.config.supports_mcp_app_ui_webview; + if current.available_environment_ids() == available_environment_ids + && host_capability_matches + { return current; } let mcp_projection = self @@ -153,6 +161,8 @@ impl Session { .mcp_server_catalog .has_same_servers(&mcp_config.mcp_server_catalog) && current.config().connector_snapshot == mcp_config.connector_snapshot + && current.config().supports_mcp_app_ui_webview + == mcp_config.supports_mcp_app_ui_webview { // Availability is only an input to the MCP projection. When that input changes but // the projected servers and connectors do not, advance the input key without @@ -377,6 +387,7 @@ impl Session { codex_apps_tools_cache_key(auth.as_ref()), mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability.clone(), + mcp_config.supports_mcp_app_ui_webview, self.services .supports_openai_form_elicitation .load(std::sync::atomic::Ordering::Relaxed), diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index 5e7aeffcee3a..efc62c2580b7 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -79,6 +79,7 @@ impl McpRuntimeSnapshot { apps_enabled: config.features.enabled(Feature::Apps), prefix_mcp_tool_names: config.prefix_mcp_tool_names(), client_elicitation_capability: ElicitationCapability::default(), + supports_mcp_app_ui_webview: config.supports_mcp_app_ui_webview, mcp_server_catalog: ResolvedMcpCatalog::default(), connector_snapshot: codex_connectors::ConnectorSnapshot::default(), }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fa240b890ba8..526ad643665f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -752,6 +752,25 @@ impl Codex { id: id.clone(), op, client_user_message_id: None, + supports_mcp_app_ui_webview: None, + trace, + }; + self.submit_with_id(sub).await?; + Ok(id) + } + + pub async fn submit_with_trace_and_mcp_app_ui_webview_support( + &self, + op: Op, + trace: Option, + supports_mcp_app_ui_webview: bool, + ) -> CodexResult { + let id = new_submission_id(); + let sub = Submission { + id: id.clone(), + op, + client_user_message_id: None, + supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview), trace, }; self.submit_with_id(sub).await?; @@ -763,6 +782,7 @@ impl Codex { op: Op, trace: Option, client_user_message_id: Option, + supports_mcp_app_ui_webview: bool, ) -> CodexResult { debug_assert!(matches!(op, Op::UserInput { .. })); let id = new_submission_id(); @@ -770,6 +790,7 @@ impl Codex { id: id.clone(), op, client_user_message_id, + supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview), trace, }; self.submit_with_id(sub).await?; @@ -843,12 +864,14 @@ impl Codex { &self, app_server_client_name: Option, app_server_client_version: Option, + supports_mcp_app_ui_webview: Option, mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.session .update_settings(SessionSettingsUpdate { app_server_client_name, app_server_client_version, + supports_mcp_app_ui_webview, ..Default::default() }) .await?; @@ -1220,7 +1243,11 @@ impl Session { format!("auto-compact-{id}") } - pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { + pub(crate) async fn route_realtime_text_input( + self: &Arc, + text: String, + supports_mcp_app_ui_webview: Option, + ) { handlers::user_input_or_turn_inner( self, Uuid::now_v7().to_string(), @@ -1235,6 +1262,7 @@ impl Session { thread_settings: Default::default(), }, /*client_user_message_id*/ None, + supports_mcp_app_ui_webview, ) .await; } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 08abb4637749..78c5765eb1fd 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -373,6 +373,11 @@ impl SessionConfiguration { if let Some(app_server_client_version) = updates.app_server_client_version.clone() { next_configuration.app_server_client_version = Some(app_server_client_version); } + if let Some(supports_mcp_app_ui_webview) = updates.supports_mcp_app_ui_webview { + let mut config = (*next_configuration.original_config_do_not_use).clone(); + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; + next_configuration.original_config_do_not_use = Arc::new(config); + } Ok(next_configuration) } @@ -431,6 +436,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) supports_mcp_app_ui_webview: Option, } pub(crate) struct AppServerClientMetadata { @@ -1213,6 +1219,7 @@ impl Session { .config .client_elicitation_capability .clone(), + mcp_projection.config.supports_mcp_app_ui_webview, sess.services .supports_openai_form_elicitation .load(std::sync::atomic::Ordering::Relaxed), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5ebce9aa4253..643ac760b4c8 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6479,6 +6479,7 @@ async fn submit_with_id_captures_current_span_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: None, }) .await @@ -6492,6 +6493,40 @@ async fn submit_with_id_captures_current_span_trace_context() { assert_eq!(submitted.trace, Some(expected_trace)); } +#[tokio::test] +async fn user_input_submission_carries_request_scoped_mcp_app_ui_capability() { + let (session, _turn_context) = make_session_and_context().await; + let (tx_sub, rx_sub) = async_channel::bounded(1); + let (_tx_event, rx_event) = async_channel::unbounded(); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Codex { + tx_sub, + rx_event, + agent_status, + session: Arc::new(session), + session_loop_termination: completed_session_loop_termination(), + }; + + codex + .submit_user_input_with_client_user_message_id( + Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }, + /*trace*/ None, + /*client_user_message_id*/ None, + /*supports_mcp_app_ui_webview*/ true, + ) + .await + .expect("submit user input"); + + let submitted = rx_sub.recv().await.expect("submission"); + assert_eq!(submitted.supports_mcp_app_ui_webview, Some(true)); +} + #[tokio::test] async fn new_default_turn_captures_current_span_trace_id() { let (session, _turn_context) = make_session_and_context().await; @@ -6551,6 +6586,7 @@ fn submission_dispatch_span_prefers_submission_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: Some(submission_trace), }) }); @@ -6578,6 +6614,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { }, }), client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: None, }); @@ -6644,6 +6681,7 @@ async fn user_turn_updates_approvals_reviewer() { }, }, /*client_user_message_id*/ None, + /*supports_mcp_app_ui_webview*/ None, ) .await; @@ -6654,6 +6692,27 @@ async fn user_turn_updates_approvals_reviewer() { ); } +#[tokio::test] +async fn routed_realtime_text_uses_the_starting_client_mcp_app_ui_capability() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + session + .update_settings(SessionSettingsUpdate { + supports_mcp_app_ui_webview: Some(true), + ..Default::default() + }) + .await + .expect("simulate a later widget-capable client update"); + + session + .route_realtime_text_input( + "hello from realtime".to_string(), + /*supports_mcp_app_ui_webview*/ Some(false), + ) + .await; + + assert!(!session.get_config().await.supports_mcp_app_ui_webview); +} + #[tokio::test] async fn turn_environments_set_primary_environment() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; @@ -6891,6 +6950,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: Some(submission_trace.clone()), }); let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); @@ -7759,6 +7819,65 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() { ); } +#[tokio::test] +async fn host_mcp_app_ui_capability_change_rebuilds_the_mcp_manager() { + let (session, _) = make_session_and_context().await; + let session = Arc::new(session); + let old_runtime = session.services.latest_mcp_runtime(); + let old_manager = old_runtime.manager_arc(); + assert!(!old_runtime.config().supports_mcp_app_ui_webview); + + session + .update_settings(SessionSettingsUpdate { + supports_mcp_app_ui_webview: Some(true), + ..Default::default() + }) + .await + .expect("update host MCP App UI capability"); + let turn_context = session.new_default_turn().await; + + let new_runtime = session + .mcp_runtime_for_step( + turn_context.as_ref(), + &turn_context.environments, + /*selected_capability_roots*/ &[], + ) + .await; + + assert!(new_runtime.config().supports_mcp_app_ui_webview); + assert!(!Arc::ptr_eq(&old_runtime, &new_runtime)); + assert!(!Arc::ptr_eq(&old_manager, &new_runtime.manager_arc())); +} + +#[tokio::test] +async fn request_scoped_host_mcp_app_ui_capability_survives_later_session_update() { + let (session, _) = make_session_and_context().await; + let session = Arc::new(session); + let widget_turn_context = session + .new_default_turn_with_mcp_app_ui_webview_support(true) + .await + .expect("capture widget client turn context"); + + session + .update_settings(SessionSettingsUpdate { + supports_mcp_app_ui_webview: Some(false), + ..Default::default() + }) + .await + .expect("simulate a later text-only client update"); + + let widget_runtime = session + .mcp_runtime_for_step( + widget_turn_context.as_ref(), + &widget_turn_context.environments, + /*selected_capability_roots*/ &[], + ) + .await; + + assert!(widget_turn_context.config.supports_mcp_app_ui_webview); + assert!(widget_runtime.config().supports_mcp_app_ui_webview); +} + #[tokio::test] async fn plugin_availability_change_reuses_the_mcp_manager() { struct ReadyPluginContributor; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b9bf42073b3c..e325eb245964 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -816,6 +816,20 @@ impl Session { .await } + pub(crate) async fn new_default_turn_with_mcp_app_ui_webview_support( + &self, + supports_mcp_app_ui_webview: bool, + ) -> CodexResult> { + self.new_turn_with_sub_id( + self.next_internal_sub_id(), + SessionSettingsUpdate { + supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview), + ..Default::default() + }, + ) + .await + } + pub(crate) async fn new_startup_prewarm_turn_with_sub_id( &self, sub_id: String, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 0d403180d7c4..ebec8fefa7be 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -124,6 +124,71 @@ async fn submit_user_turn( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_tool_call_forwards_mcp_app_ui_webview_capability() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "calendar-call-with-ui-capability"; + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config.supports_mcp_app_ui_webview = true; + set_calendar_approval_mode(config, AppToolApproval::Auto); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Use [$calendar](app://calendar) to create a calendar event.", + AskForApproval::Never, + /*collaboration_mode*/ None, + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(mock.requests().len(), 2); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, call_id).await; + assert_eq!( + apps_tool_call.pointer( + "/params/_meta/io.modelcontextprotocol~1clientCapabilities/extensions/io.modelcontextprotocol~1ui/mimeTypes" + ), + Some(&json!(["text/html;profile=mcp-app"])) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index cbc113ff79e0..1982a530e6c4 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( thread_settings: Default::default(), }, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: None, }; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index d211ff0a32fc..46634be2cdbc 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -546,6 +546,7 @@ impl MessageProcessor { id: request_id_string, op: codex_protocol::protocol::Op::Interrupt, client_user_message_id: None, + supports_mcp_app_ui_webview: None, trace: None, }) .await diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b1b845fe2823..7c24981ffc14 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -166,6 +166,11 @@ pub struct Submission { pub op: Op, /// Client-provided id for the user message represented by `Op::UserInput`. pub client_user_message_id: Option, + /// Request-scoped host capability for turns submitted by app-server clients. + /// + /// This is carried with the queued operation so a later request on the same + /// thread cannot change which MCP runtime the turn initializes. + pub supports_mcp_app_ui_webview: Option, /// Optional W3C trace carrier propagated across async submission handoffs. pub trace: Option, } diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 4237a33fefe8..d0c5c230025e 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -261,6 +261,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), respect_system_proxy: false, apps_mcp_product_sku: None, + supports_mcp_app_ui_webview: false, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_webrtc_call_base_url: None, From ad1db47cecff3bbdc465a65f0c324703ebd1c03c Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:56:21 -0700 Subject: [PATCH 2/5] Annotate MCP UI capability arguments --- .../app-server/src/request_processors/turn_processor.rs | 2 +- codex-rs/codex-mcp/src/client_capabilities.rs | 9 ++++++--- codex-rs/core/src/session/tests.rs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) 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 6bc64f01e6f4..25e66a7f6823 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -862,7 +862,7 @@ impl TurnRequestProcessor { .set_app_server_client_info( app_server_client_name, app_server_client_version, - None, + /*supports_mcp_app_ui_webview*/ None, mcp_elicitations_auto_deny, ) .await diff --git a/codex-rs/codex-mcp/src/client_capabilities.rs b/codex-rs/codex-mcp/src/client_capabilities.rs index c89f80ee6904..7876b08c102f 100644 --- a/codex-rs/codex-mcp/src/client_capabilities.rs +++ b/codex-rs/codex-mcp/src/client_capabilities.rs @@ -103,7 +103,7 @@ mod tests { MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, "caller": "preserved", })), - true, + /*supports_mcp_app_ui_webview*/ true, ); assert_eq!( @@ -131,7 +131,7 @@ mod tests { MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, "caller": "preserved", })), - false, + /*supports_mcp_app_ui_webview*/ false, ), Some(json!({"caller": "preserved"})) ); @@ -140,7 +140,10 @@ mod tests { #[test] fn invalid_caller_metadata_does_not_suppress_trusted_capabilities() { assert_eq!( - with_client_capabilities_meta(Some(json!("invalid")), true), + with_client_capabilities_meta( + Some(json!("invalid")), + /*supports_mcp_app_ui_webview*/ true, + ), Some(json!({ MCP_CLIENT_CAPABILITIES_META_KEY: { "extensions": { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 643ac760b4c8..b1d152476eae 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7854,7 +7854,7 @@ async fn request_scoped_host_mcp_app_ui_capability_survives_later_session_update let (session, _) = make_session_and_context().await; let session = Arc::new(session); let widget_turn_context = session - .new_default_turn_with_mcp_app_ui_webview_support(true) + .new_default_turn_with_mcp_app_ui_webview_support(/*supports_mcp_app_ui_webview*/ true) .await .expect("capture widget client turn context"); From fed1dd3b8d62f1635eef400aa1cf24534f8d28e0 Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:26:39 -0700 Subject: [PATCH 3/5] Move MCP UI capability tests to sibling module --- codex-rs/codex-mcp/src/client_capabilities.rs | 94 +------------------ .../src/client_capabilities_tests.rs | 92 ++++++++++++++++++ 2 files changed, 94 insertions(+), 92 deletions(-) create mode 100644 codex-rs/codex-mcp/src/client_capabilities_tests.rs diff --git a/codex-rs/codex-mcp/src/client_capabilities.rs b/codex-rs/codex-mcp/src/client_capabilities.rs index 7876b08c102f..df7696039c9b 100644 --- a/codex-rs/codex-mcp/src/client_capabilities.rs +++ b/codex-rs/codex-mcp/src/client_capabilities.rs @@ -64,95 +64,5 @@ pub(crate) fn with_client_capabilities_meta( } #[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - use serde_json::json; - - use super::*; - - #[test] - fn recognizes_only_the_webview_mime_type() { - let extensions = HashMap::from([ - ( - MCP_APP_UI_EXTENSION_ID.to_string(), - json!({ - "mimeTypes": [ - MCP_APP_UI_WEBVIEW_MIME_TYPE, - "text/x-dil;profile=mcp-app", - ], - }), - ), - ("example/other".to_string(), json!({"enabled": true})), - ]); - - assert!(supports_mcp_app_ui_webview(Some(&extensions))); - assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( - MCP_APP_UI_EXTENSION_ID.to_string(), - json!({"mimeTypes": ["text/x-dil;profile=mcp-app"]}), - ),])))); - assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( - MCP_APP_UI_EXTENSION_ID.to_string(), - json!("invalid") - ),])))); - } - - #[test] - fn trusted_capabilities_replace_spoofed_request_metadata() { - let meta = with_client_capabilities_meta( - Some(json!({ - MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, - "caller": "preserved", - })), - /*supports_mcp_app_ui_webview*/ true, - ); - - assert_eq!( - meta, - Some(json!({ - "caller": "preserved", - MCP_CLIENT_CAPABILITIES_META_KEY: { - "extensions": { - MCP_APP_UI_EXTENSION_ID: { - "mimeTypes": [ - MCP_APP_UI_WEBVIEW_MIME_TYPE, - ] - } - } - } - })) - ); - } - - #[test] - fn absent_host_capabilities_remove_spoofed_request_metadata() { - assert_eq!( - with_client_capabilities_meta( - Some(json!({ - MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, - "caller": "preserved", - })), - /*supports_mcp_app_ui_webview*/ false, - ), - Some(json!({"caller": "preserved"})) - ); - } - - #[test] - fn invalid_caller_metadata_does_not_suppress_trusted_capabilities() { - assert_eq!( - with_client_capabilities_meta( - Some(json!("invalid")), - /*supports_mcp_app_ui_webview*/ true, - ), - Some(json!({ - MCP_CLIENT_CAPABILITIES_META_KEY: { - "extensions": { - MCP_APP_UI_EXTENSION_ID: { - "mimeTypes": [MCP_APP_UI_WEBVIEW_MIME_TYPE], - } - } - } - })) - ); - } -} +#[path = "client_capabilities_tests.rs"] +mod tests; diff --git a/codex-rs/codex-mcp/src/client_capabilities_tests.rs b/codex-rs/codex-mcp/src/client_capabilities_tests.rs new file mode 100644 index 000000000000..b6e9836e6920 --- /dev/null +++ b/codex-rs/codex-mcp/src/client_capabilities_tests.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn recognizes_only_the_webview_mime_type() { + let extensions = HashMap::from([ + ( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!({ + "mimeTypes": [ + MCP_APP_UI_WEBVIEW_MIME_TYPE, + "text/x-dil;profile=mcp-app", + ], + }), + ), + ("example/other".to_string(), json!({"enabled": true})), + ]); + + assert!(supports_mcp_app_ui_webview(Some(&extensions))); + assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!({"mimeTypes": ["text/x-dil;profile=mcp-app"]}), + ),])))); + assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([( + MCP_APP_UI_EXTENSION_ID.to_string(), + json!("invalid") + ),])))); +} + +#[test] +fn trusted_capabilities_replace_spoofed_request_metadata() { + let meta = with_client_capabilities_meta( + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, + "caller": "preserved", + })), + /*supports_mcp_app_ui_webview*/ true, + ); + + assert_eq!( + meta, + Some(json!({ + "caller": "preserved", + MCP_CLIENT_CAPABILITIES_META_KEY: { + "extensions": { + MCP_APP_UI_EXTENSION_ID: { + "mimeTypes": [ + MCP_APP_UI_WEBVIEW_MIME_TYPE, + ] + } + } + } + })) + ); +} + +#[test] +fn absent_host_capabilities_remove_spoofed_request_metadata() { + assert_eq!( + with_client_capabilities_meta( + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true}, + "caller": "preserved", + })), + /*supports_mcp_app_ui_webview*/ false, + ), + Some(json!({"caller": "preserved"})) + ); +} + +#[test] +fn invalid_caller_metadata_does_not_suppress_trusted_capabilities() { + assert_eq!( + with_client_capabilities_meta( + Some(json!("invalid")), + /*supports_mcp_app_ui_webview*/ true, + ), + Some(json!({ + MCP_CLIENT_CAPABILITIES_META_KEY: { + "extensions": { + MCP_APP_UI_EXTENSION_ID: { + "mimeTypes": [MCP_APP_UI_WEBVIEW_MIME_TYPE], + } + } + } + })) + ); +} From 1ce184efd43cb75426d824fc8609341e25646d74 Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:43:04 -0700 Subject: [PATCH 4/5] Cover direct MCP UI capability forwarding --- codex-rs/app-server/README.md | 18 +++ .../app-server/tests/suite/v2/mcp_tool.rs | 146 +++++++++++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8300c70d94ca..89227b3bf92b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -93,6 +93,24 @@ 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`. +Clients that can render MCP App WebViews advertise the MCP UI extension: + +```json +{ + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } + } +} +``` + +App-server forwards this capability to the host-owned `codex_apps` MCP +server during initialization and on each `tools/call`. Clients without an +MCP App WebView omit the extension. + 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 diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 1f3289e43932..0ff82570542c 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -1,17 +1,24 @@ use std::borrow::Cow; use std::collections::BTreeMap; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use axum::Router; use codex_app_server_protocol::CapabilityRootLocation; +use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::EnvironmentAddResponse; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -33,6 +40,7 @@ use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; use codex_features::Feature; use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -68,6 +76,10 @@ use tokio::task::JoinHandle; use tokio::time::timeout; use tokio_tungstenite::tungstenite::Message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; use super::exec_server_test_support::accept_exec_server_environment; use super::exec_server_test_support::read_exec_server_json; @@ -80,8 +92,128 @@ const ELICITATION_MESSAGE: &str = "Allow this request?"; const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; const URL_ELICITATION_URL: &str = "https://github.example/login/device"; +const MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE: &str = "mcp-app-ui-capability"; const LATE_ENVIRONMENT_ID: &str = "late-environment"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (apps_server_url, apps_server_handle) = + start_mcp_server_at_path("/api/codex/ps/mcp").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &responses_server.uri(), + &apps_server_url, + )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write(config_path, format!("{config}\n[features]\napps = true\n"))?; + 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 text_only_client = connect_websocket(bind_addr).await?; + send_request( + &mut text_only_client, + "initialize", + 1, + Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: "text-only-client".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + })?), + ) + .await?; + let _ = read_response_for_id(&mut text_only_client, 1).await?; + + send_request( + &mut text_only_client, + "thread/start", + 2, + Some(serde_json::to_value(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let thread_start_response = read_response_for_id(&mut text_only_client, 2).await?; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + let mut widget_capable_client = connect_websocket(bind_addr).await?; + send_request( + &mut widget_capable_client, + "initialize", + 3, + Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: "widget-capable-client".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + extensions: Some(HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), + ..Default::default() + }), + })?), + ) + .await?; + let _ = read_response_for_id(&mut widget_capable_client, 3).await?; + + send_request( + &mut widget_capable_client, + "mcpServer/tool/call", + 4, + Some(serde_json::to_value(McpServerToolCallParams { + thread_id: thread.id, + server: "codex_apps".to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE, + })), + meta: None, + })?), + ) + .await?; + let tool_call_response = read_response_for_id(&mut widget_capable_client, 4).await?; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + + assert_eq!( + response + .structured_content + .as_ref() + .and_then(|content| content.pointer( + "/requestMeta/io.modelcontextprotocol~1clientCapabilities/extensions/io.modelcontextprotocol~1ui/mimeTypes" + )), + Some(&json!(["text/html;profile=mcp-app"])) + ); + + process.kill().await?; + apps_server_handle.abort(); + let _ = apps_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { let responses_server = responses::start_mock_server().await; @@ -743,6 +875,14 @@ impl ServerHandler for ToolAppsMcpServer { .and_then(|value| value.as_str()) .unwrap_or_default(); + if message == MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE { + let mut result = CallToolResult::structured(json!({ + "requestMeta": context.meta.0, + })); + result.content = vec![Content::text("captured request metadata")]; + return Ok(result); + } + let mut meta = Meta::new(); meta.0.insert("calledBy".to_string(), json!("mcp-app")); @@ -819,6 +959,10 @@ impl ServerHandler for ToolAppsMcpServer { } async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { + start_mcp_server_at_path("/mcp").await +} + +async fn start_mcp_server_at_path(path: &str) -> Result<(String, JoinHandle<()>)> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; let mcp_service = StreamableHttpService::new( @@ -826,7 +970,7 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), ); - let router = Router::new().nest_service("/mcp", mcp_service); + let router = Router::new().nest_service(path, mcp_service); let handle = tokio::spawn(async move { let _ = axum::serve(listener, router).await; From cc23f67e367ba6ada1c81bebde65382c6a656195 Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:59:32 -0700 Subject: [PATCH 5/5] Annotate MCP tool test request ids --- codex-rs/app-server/tests/suite/v2/mcp_tool.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 0ff82570542c..90456c889990 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -124,7 +124,7 @@ async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() - send_request( &mut text_only_client, "initialize", - 1, + /*id*/ 1, Some(serde_json::to_value(InitializeParams { client_info: ClientInfo { name: "text-only-client".to_string(), @@ -138,26 +138,26 @@ async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() - })?), ) .await?; - let _ = read_response_for_id(&mut text_only_client, 1).await?; + let _ = read_response_for_id(&mut text_only_client, /*id*/ 1).await?; send_request( &mut text_only_client, "thread/start", - 2, + /*id*/ 2, Some(serde_json::to_value(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() })?), ) .await?; - let thread_start_response = read_response_for_id(&mut text_only_client, 2).await?; + let thread_start_response = read_response_for_id(&mut text_only_client, /*id*/ 2).await?; let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; let mut widget_capable_client = connect_websocket(bind_addr).await?; send_request( &mut widget_capable_client, "initialize", - 3, + /*id*/ 3, Some(serde_json::to_value(InitializeParams { client_info: ClientInfo { name: "widget-capable-client".to_string(), @@ -177,12 +177,12 @@ async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() - })?), ) .await?; - let _ = read_response_for_id(&mut widget_capable_client, 3).await?; + let _ = read_response_for_id(&mut widget_capable_client, /*id*/ 3).await?; send_request( &mut widget_capable_client, "mcpServer/tool/call", - 4, + /*id*/ 4, Some(serde_json::to_value(McpServerToolCallParams { thread_id: thread.id, server: "codex_apps".to_string(), @@ -194,7 +194,7 @@ async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() - })?), ) .await?; - let tool_call_response = read_response_for_id(&mut widget_capable_client, 4).await?; + let tool_call_response = read_response_for_id(&mut widget_capable_client, /*id*/ 4).await?; let response: McpServerToolCallResponse = to_response(tool_call_response)?; assert_eq!(