diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a3b6dfa949..2e8070a5ec 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1627,6 +1627,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1776,6 +1777,7 @@ async fn compaction_event_ingests_custom_fact() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c75915629c..ce1c238103 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -38,6 +38,7 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; @@ -363,6 +364,8 @@ pub struct InProcessClientStartArgs { pub experimental_api: bool, /// Notification methods this client opts out of receiving. pub opt_out_notification_methods: Vec, + /// Passive MCP host capabilities advertised by this client. + pub mcp_client_capabilities: Option, /// Queue capacity for command/event channels (clamped to at least 1). pub channel_capacity: usize, } @@ -385,6 +388,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_client_capabilities: self.mcp_client_capabilities.clone(), }; InitializeParams { @@ -950,6 +954,8 @@ mod tests { use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; + use codex_app_server_protocol::McpAppUiCapability; + use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as ApiSessionSource; use codex_app_server_protocol::ThreadStartParams; @@ -1047,6 +1053,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity, }) .await @@ -1239,10 +1246,32 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: 8, } } + #[test] + fn remote_initialize_params_forward_mcp_client_capabilities() { + let expected = McpClientCapabilities { + app_ui: [ + McpAppUiCapability::WebView, + McpAppUiCapability::DeclarativeUi, + ] + .into_iter() + .collect(), + }; + let mut args = test_remote_connect_args("ws://127.0.0.1:4500".to_string()); + args.mcp_client_capabilities = Some(expected.clone()); + + assert_eq!( + args.initialize_params() + .capabilities + .and_then(|capabilities| capabilities.mcp_client_capabilities), + Some(expected) + ); + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -1513,6 +1542,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: 8, }) .await @@ -1601,6 +1631,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: 8, }) .await @@ -1620,6 +1651,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: 8, }) .await; @@ -2179,6 +2211,9 @@ mod tests { #[tokio::test] async fn runtime_start_args_forward_environment_manager() { let config = Arc::new(build_test_config().await); + let expected_capabilities = McpClientCapabilities { + app_ui: [McpAppUiCapability::WebView].into_iter().collect(), + }; let environment_manager = Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), @@ -2211,11 +2246,19 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: Some(expected_capabilities.clone()), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); assert_eq!(runtime_args.config, config); + assert_eq!( + runtime_args + .initialize + .capabilities + .and_then(|capabilities| capabilities.mcp_client_capabilities), + Some(expected_capabilities) + ); assert!(Arc::ptr_eq( &runtime_args.environment_manager, &environment_manager @@ -2252,6 +2295,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 98020c041c..04a4639c3e 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -33,6 +33,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; @@ -87,10 +88,11 @@ pub struct RemoteAppServerConnectArgs { pub client_version: String, pub experimental_api: bool, pub opt_out_notification_methods: Vec, + pub mcp_client_capabilities: Option, pub channel_capacity: usize, } impl RemoteAppServerConnectArgs { - fn initialize_params(&self) -> InitializeParams { + pub(crate) fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, @@ -99,6 +101,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_client_capabilities: self.mcp_client_capabilities.clone(), }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6d9be4ec02..93060586d5 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1124,6 +1124,17 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "Passive MCP capabilities supported by this app-server client." + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -1406,6 +1417,30 @@ }, "type": "object" }, + "McpAppUiCapability": { + "description": "Passive MCP App presentation features supported by the active host.", + "enum": [ + "webView", + "declarativeUi" + ], + "type": "string" + }, + "McpClientCapabilities": { + "description": "Passive MCP capabilities supplied by the active Codex host.", + "properties": { + "appUi": { + "items": { + "$ref": "#/definitions/McpAppUiCapability" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "appUi" + ], + "type": "object" + }, "McpResourceReadParams": { "properties": { "server": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1873ed317e..ad0aa68bf0 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 @@ -2821,6 +2821,17 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "Passive MCP capabilities supported by this app-server client." + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -3011,6 +3022,30 @@ "title": "JSONRPCResponse", "type": "object" }, + "McpAppUiCapability": { + "description": "Passive MCP App presentation features supported by the active host.", + "enum": [ + "webView", + "declarativeUi" + ], + "type": "string" + }, + "McpClientCapabilities": { + "description": "Passive MCP capabilities supplied by the active Codex host.", + "properties": { + "appUi": { + "items": { + "$ref": "#/definitions/McpAppUiCapability" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "appUi" + ], + "type": "object" + }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 38c74b1859..f97d9f6f5e 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 @@ -6928,6 +6928,17 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "Passive MCP capabilities supported by this app-server client." + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -7718,6 +7729,14 @@ "title": "MarketplaceUpgradeResponse", "type": "object" }, + "McpAppUiCapability": { + "description": "Passive MCP App presentation features supported by the active host.", + "enum": [ + "webView", + "declarativeUi" + ], + "type": "string" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -7727,6 +7746,22 @@ ], "type": "string" }, + "McpClientCapabilities": { + "description": "Passive MCP capabilities supplied by the active Codex host.", + "properties": { + "appUi": { + "items": { + "$ref": "#/definitions/McpAppUiCapability" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "appUi" + ], + "type": "object" + }, "McpResourceReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index af5c509249..e420aee085 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,17 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpClientCapabilities": { + "anyOf": [ + { + "$ref": "#/definitions/McpClientCapabilities" + }, + { + "type": "null" + } + ], + "description": "Passive MCP capabilities supported by this app-server client." + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -47,6 +58,30 @@ } }, "type": "object" + }, + "McpAppUiCapability": { + "description": "Passive MCP App presentation features supported by the active host.", + "enum": [ + "webView", + "declarativeUi" + ], + "type": "string" + }, + "McpClientCapabilities": { + "description": "Passive MCP capabilities supplied by the active Codex host.", + "properties": { + "appUi": { + "items": { + "$ref": "#/definitions/McpAppUiCapability" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "appUi" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index c5043e3b64..49fe6fdbda 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 { McpClientCapabilities } from "./McpClientCapabilities"; /** * Client-declared capabilities negotiated during initialize. @@ -18,4 +19,8 @@ requestAttestation: boolean, * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). */ -optOutNotificationMethods?: Array | null, }; +optOutNotificationMethods?: Array | null, +/** + * Passive MCP capabilities supported by this app-server client. + */ +mcpClientCapabilities?: McpClientCapabilities | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpAppUiCapability.ts b/codex-rs/app-server-protocol/schema/typescript/McpAppUiCapability.ts new file mode 100644 index 0000000000..e9761316a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpAppUiCapability.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Passive MCP App presentation features supported by the active host. + */ +export type McpAppUiCapability = "webView" | "declarativeUi"; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts new file mode 100644 index 0000000000..0a0270768e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpClientCapabilities.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpAppUiCapability } from "./McpAppUiCapability"; + +/** + * Passive MCP capabilities supplied by the active Codex host. + */ +export type McpClientCapabilities = { appUi: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 149b3aec0d..e1cc1020ce 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -43,6 +43,8 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; +export type { McpAppUiCapability } from "./McpAppUiCapability"; +export type { McpClientCapabilities } from "./McpClientCapabilities"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index f6d7670e10..8a1859e177 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -4,6 +4,8 @@ mod jsonrpc_lite; mod protocol; mod schema_fixtures; +pub use codex_protocol::mcp::McpAppUiCapability; +pub use codex_protocol::mcp::McpClientCapabilities; pub use experimental_api::*; pub use export::GenerateTsOptions; pub use export::generate_internal_json_schema; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d3c8eae22b..9be9fceaea 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1622,6 +1622,8 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; + use codex_protocol::mcp::McpAppUiCapability; + use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; @@ -2111,6 +2113,7 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + mcp_client_capabilities: None, }), }, }; @@ -2140,6 +2143,58 @@ mod tests { Ok(()) } + #[test] + fn serialize_initialize_with_mcp_client_capabilities() -> Result<()> { + let capabilities = McpClientCapabilities { + app_ui: [ + McpAppUiCapability::WebView, + McpAppUiCapability::DeclarativeUi, + ] + .into_iter() + .collect(), + }; + let request = ClientRequest::Initialize { + request_id: RequestId::Integer(42), + params: v1::InitializeParams { + client_info: v1::ClientInfo { + name: "codex_app".to_string(), + title: Some("Codex App".to_string()), + version: "0.1.0".to_string(), + }, + capabilities: Some(v1::InitializeCapabilities { + experimental_api: false, + request_attestation: false, + opt_out_notification_methods: None, + mcp_client_capabilities: Some(capabilities), + }), + }, + }; + + assert_eq!( + json!({ + "method": "initialize", + "id": 42, + "params": { + "clientInfo": { + "name": "codex_app", + "title": "Codex App", + "version": "0.1.0" + }, + "capabilities": { + "experimentalApi": false, + "requestAttestation": false, + "optOutNotificationMethods": null, + "mcpClientCapabilities": { + "appUi": ["webView", "declarativeUi"] + } + } + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> { let request: ClientRequest = serde_json::from_value(json!({ @@ -2179,6 +2234,7 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + mcp_client_capabilities: None, }), }, } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c3..b5b761186d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; @@ -54,6 +55,10 @@ pub struct InitializeCapabilities { /// connection (for example `thread/started`). #[ts(optional = nullable)] pub opt_out_notification_methods: Option>, + /// Passive MCP capabilities supported by this app-server client. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub mcp_client_capabilities: 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 e89a8dbe99..31417470f9 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1564,6 +1564,7 @@ impl CodexClient { .map(|method| (*method).to_string()) .collect(), ), + mcp_client_capabilities: None, }), }, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 84d3d88dda..0697fb6a4e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -62,6 +62,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; @@ -201,6 +202,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, + pub(crate) mcp_client_capabilities: Option, } impl Default for ConnectionSessionState { @@ -252,6 +254,12 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } + pub(crate) fn mcp_client_capabilities(&self) -> Option<&McpClientCapabilities> { + self.initialized + .get() + .and_then(|session| session.mcp_client_capabilities.as_ref()) + } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -838,6 +846,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); + let mcp_client_capabilities = session.mcp_client_capabilities().cloned(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -853,6 +862,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, + mcp_client_capabilities, ) .await; if let Err(error) = result { @@ -882,6 +892,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1021,6 +1032,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), request_context, ) .await @@ -1037,6 +1049,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), ) .await } @@ -1047,6 +1060,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), ) .await } @@ -1171,7 +1185,9 @@ impl MessageProcessor { self.plugin_processor.plugin_installed(params).await } ClientRequest::PluginRead { params, .. } => { - self.plugin_processor.plugin_read(params).await + self.plugin_processor + .plugin_read(params, mcp_client_capabilities.clone()) + .await } ClientRequest::PluginSkillRead { params, .. } => { self.plugin_processor.plugin_skill_read(params).await @@ -1194,7 +1210,9 @@ impl MessageProcessor { self.plugin_processor.plugin_share_delete(params).await } ClientRequest::AppsList { params, .. } => { - self.apps_processor.apps_list(&request_id, params).await + self.apps_processor + .apps_list(&request_id, params, mcp_client_capabilities.clone()) + .await } ClientRequest::SkillsConfigWrite { params, .. } => { self.catalog_processor.skills_config_write(params).await @@ -1231,6 +1249,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + mcp_client_capabilities.clone(), ) .await } @@ -1269,7 +1288,15 @@ 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, + app_server_client_name.clone(), + client_version.clone(), + mcp_client_capabilities.clone(), + ) + .await } ClientRequest::McpServerOauthLogin { params, .. } => { self.mcp_processor.mcp_server_oauth_login(params).await @@ -1279,12 +1306,12 @@ impl MessageProcessor { } ClientRequest::McpServerStatusList { params, .. } => { self.mcp_processor - .mcp_server_status_list(&request_id, params) + .mcp_server_status_list(&request_id, params, mcp_client_capabilities.clone()) .await } ClientRequest::McpResourceRead { params, .. } => { self.mcp_processor - .mcp_resource_read(&request_id, params) + .mcp_resource_read(&request_id, params, mcp_client_capabilities.clone()) .await } ClientRequest::McpServerToolCall { params, .. } => { diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 4a665d8ef3..939198c8f6 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::McpClientCapabilities; pub(crate) struct AppsRequestProcessor { auth_manager: Arc, @@ -35,8 +36,9 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { - self.apps_list_inner(request_id, params) + self.apps_list_inner(request_id, params, mcp_client_capabilities) .await .map(|response| response.map(Into::into)) } @@ -45,6 +47,7 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { let thread = if let Some(thread_id) = params.thread_id.as_deref() { let (_, loaded_thread) = self.load_thread(thread_id).await?; @@ -92,7 +95,14 @@ impl AppsRequestProcessor { tokio::spawn(async move { tokio::select! { _ = shutdown_token.cancelled() => {} - _ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {} + _ = Self::apps_list_task( + outgoing, + request, + params, + config, + environment_manager, + mcp_client_capabilities, + ) => {} } }); Ok(None) @@ -108,11 +118,19 @@ impl AppsRequestProcessor { params: AppsListParams, config: Config, environment_manager: Arc, + mcp_client_capabilities: Option, ) { let retry_params = params.clone(); let retry_config = config.clone(); let retry_environment_manager = Arc::clone(&environment_manager); - let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; + let result = Self::apps_list_response( + &outgoing, + params, + config, + environment_manager, + mcp_client_capabilities.clone(), + ) + .await; let should_retry = result .as_ref() .is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready); @@ -128,6 +146,7 @@ impl AppsRequestProcessor { retry_params, retry_config, retry_environment_manager, + mcp_client_capabilities, ) .await { @@ -141,6 +160,7 @@ impl AppsRequestProcessor { params: AppsListParams, config: Config, environment_manager: Arc, + mcp_client_capabilities: Option, ) -> Result<(AppsListResponse, bool), JSONRPCErrorError> { let AppsListParams { cursor, @@ -157,7 +177,10 @@ impl AppsRequestProcessor { }; let (mut accessible_connectors, mut all_connectors) = tokio::join!( - connectors::list_cached_accessible_connectors_from_mcp_tools(&config), + connectors::list_cached_accessible_connectors_from_mcp_tools( + &config, + mcp_client_capabilities.as_ref(), + ), connectors::list_cached_all_connectors(&config) ); let cached_all_connectors = all_connectors.clone(); @@ -172,6 +195,7 @@ impl AppsRequestProcessor { &accessible_config, force_refetch, Arc::clone(&environment_manager), + mcp_client_capabilities, ) .await .map_err(|err| format!("failed to load accessible apps: {err}")); 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 a40007db11..0b8157e202 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,17 +67,22 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = - match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities.request_attestation, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, false, Vec::new()), - }; + let ( + experimental_api_enabled, + request_attestation, + opt_out_notification_methods, + mcp_client_capabilities, + ) = match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities.request_attestation, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + capabilities.mcp_client_capabilities, + ), + None => (false, false, Vec::new(), None), + }; let ClientInfo { name, title: _title, @@ -101,6 +106,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, + mcp_client_capabilities, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index ae62e2e785..1d7b9c12b4 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::McpClientCapabilities; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; @@ -47,8 +48,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { - self.list_mcp_server_status(request_id, params) + self.list_mcp_server_status(request_id, params, mcp_client_capabilities) .await .map(|()| None) } @@ -57,8 +59,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpResourceReadParams, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { - self.read_mcp_resource(request_id, params) + self.read_mcp_resource(request_id, params, mcp_client_capabilities) .await .map(|()| None) } @@ -195,6 +198,7 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let request = request_id.clone(); @@ -229,6 +233,7 @@ impl McpRequestProcessor { mcp_config, auth, runtime_context, + mcp_client_capabilities, ) .await; }); @@ -242,6 +247,7 @@ impl McpRequestProcessor { mcp_config: codex_mcp::McpConfig, auth: Option, runtime_context: McpRuntimeContext, + mcp_client_capabilities: Option, ) { let result = Self::list_mcp_server_status_response( request_id.request_id.to_string(), @@ -249,6 +255,7 @@ impl McpRequestProcessor { mcp_config, auth, runtime_context, + mcp_client_capabilities, ) .await; outgoing.send_result(request_id, result).await; @@ -260,6 +267,7 @@ impl McpRequestProcessor { mcp_config: codex_mcp::McpConfig, auth: Option, runtime_context: McpRuntimeContext, + mcp_client_capabilities: Option, ) -> Result { let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { McpServerStatusDetail::Full => McpSnapshotDetail::Full, @@ -272,6 +280,7 @@ impl McpRequestProcessor { request_id, runtime_context, detail, + mcp_client_capabilities, ) .await; @@ -341,6 +350,7 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpResourceReadParams, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let outgoing = Arc::clone(&self.outgoing); let McpResourceReadParams { @@ -380,6 +390,7 @@ impl McpRequestProcessor { runtime_context, &server, &uri, + mcp_client_capabilities, ) .await .and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from)); diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 79791c142a..360b41d047 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1,6 +1,7 @@ use super::*; use crate::error_code::internal_error; use crate::error_code::invalid_request; +use codex_app_server_protocol::McpClientCapabilities; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginSharePrincipalRole; @@ -363,8 +364,9 @@ impl PluginRequestProcessor { pub(crate) async fn plugin_read( &self, params: PluginReadParams, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { - self.plugin_read_response(params) + self.plugin_read_response(params, mcp_client_capabilities) .await .map(|response| Some(response.into())) } @@ -925,6 +927,7 @@ impl PluginRequestProcessor { async fn plugin_read_response( &self, params: PluginReadParams, + mcp_client_capabilities: Option, ) -> Result { let plugins_manager = self.thread_manager.plugins_manager(); let PluginReadParams { @@ -1017,6 +1020,7 @@ impl PluginRequestProcessor { &config, &outcome.plugin.apps, Arc::clone(&environment_manager), + mcp_client_capabilities.clone(), ) .await; let visible_skills = outcome @@ -1099,6 +1103,7 @@ impl PluginRequestProcessor { &config, &plugin_apps, Arc::clone(&environment_manager), + mcp_client_capabilities, ) .await; remote_plugin_detail_to_info(remote_detail, app_summaries) @@ -1568,7 +1573,8 @@ impl PluginRequestProcessor { connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( config, /*force_refetch*/ true, - Arc::clone(&environment_manager) + Arc::clone(&environment_manager), + None, ), ); @@ -1593,7 +1599,7 @@ impl PluginRequestProcessor { "failed to load accessible apps after plugin install: {err:#}" ); ( - connectors::list_cached_accessible_connectors_from_mcp_tools(config) + connectors::list_cached_accessible_connectors_from_mcp_tools(config, None) .await .unwrap_or_default(), false, @@ -1835,6 +1841,7 @@ async fn load_plugin_app_summaries( config: &Config, plugin_apps: &[codex_plugin::AppConnectorId], environment_manager: Arc, + mcp_client_capabilities: Option, ) -> Vec { if plugin_apps.is_empty() { return Vec::new(); @@ -1858,6 +1865,7 @@ async fn load_plugin_app_summaries( config, /*force_refetch*/ false, environment_manager, + mcp_client_capabilities, ) .await { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 4cc169f80f..e0f4e532c2 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,5 +1,6 @@ use super::*; use crate::error_code::method_not_found; +use codex_app_server_protocol::McpClientCapabilities; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -370,6 +371,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -377,6 +379,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, request_context, ) .await @@ -399,12 +402,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .map(|()| None) @@ -416,12 +421,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .map(|()| None) @@ -680,6 +687,7 @@ impl ThreadRequestProcessor { thread: &CodexThread, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( app_server_client_name.as_deref(), @@ -690,6 +698,7 @@ impl ThreadRequestProcessor { app_server_client_name, app_server_client_version, mcp_elicitations_auto_deny, + mcp_client_capabilities, ) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) @@ -805,6 +814,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -874,6 +884,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, + mcp_client_capabilities, config, typesafe_overrides, dynamic_tools, @@ -946,6 +957,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1094,6 +1106,7 @@ impl ThreadRequestProcessor { thread.as_ref(), app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await?; @@ -2381,6 +2394,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2425,6 +2439,7 @@ impl ThreadRequestProcessor { ¶ms, app_server_client_name.clone(), app_server_client_version.clone(), + mcp_client_capabilities.clone(), ) .await { @@ -2533,6 +2548,7 @@ impl ThreadRequestProcessor { codex_thread.as_ref(), app_server_client_name, app_server_client_version, + mcp_client_capabilities.clone(), ) .await { @@ -2707,6 +2723,7 @@ impl ThreadRequestProcessor { params: &ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -2829,6 +2846,7 @@ impl ThreadRequestProcessor { existing_thread.as_ref(), app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await?; @@ -3127,6 +3145,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3250,6 +3269,7 @@ impl ThreadRequestProcessor { forked_thread.as_ref(), app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .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 7bdd9df0d8..3ca36aecc4 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::McpClientCapabilities; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; @@ -95,12 +96,14 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { self.turn_start_inner( request_id, params, app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .map(|response| Some(response.into())) @@ -200,10 +203,19 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result, JSONRPCErrorError> { - self.review_start_inner(request_id, params) - .await - .map(|()| None) + self.review_start_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + mcp_client_capabilities, + ) + .await + .map(|()| None) } fn track_error_response( @@ -369,6 +381,7 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { self.track_error_response( @@ -388,6 +401,7 @@ impl TurnRequestProcessor { thread.as_ref(), app_server_client_name, app_server_client_version, + mcp_client_capabilities, ) .await .inspect_err(|error| { @@ -736,6 +750,7 @@ impl TurnRequestProcessor { thread: &CodexThread, app_server_client_name: Option, app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( app_server_client_name.as_deref(), @@ -746,6 +761,7 @@ impl TurnRequestProcessor { app_server_client_name, app_server_client_version, mcp_elicitations_auto_deny, + mcp_client_capabilities, ) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) @@ -1065,6 +1081,9 @@ impl TurnRequestProcessor { parent_thread: Arc, review_request: ReviewRequest, display_text: &str, + app_server_client_name: Option, + app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> std::result::Result<(), JSONRPCErrorError> { parent_thread.ensure_rollout_materialized().await; parent_thread.flush_rollout().await.map_err(|err| { @@ -1108,6 +1127,14 @@ impl TurnRequestProcessor { internal_error(format!("error creating detached review thread: {err}")) })?; + Self::set_app_server_client_info( + review_thread.as_ref(), + app_server_client_name, + app_server_client_version, + mcp_client_capabilities, + ) + .await?; + log_listener_attach_result( self.ensure_conversation_listener( thread_id, @@ -1173,6 +1200,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + mcp_client_capabilities: Option, ) -> Result<(), JSONRPCErrorError> { let ReviewStartParams { thread_id, @@ -1181,6 +1211,13 @@ impl TurnRequestProcessor { } = params; let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; + Self::set_app_server_client_info( + parent_thread.as_ref(), + app_server_client_name.clone(), + app_server_client_version.clone(), + mcp_client_capabilities.clone(), + ) + .await?; let (review_request, display_text) = Self::review_request_from_target(target)?; match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { CoreReviewDelivery::Inline => { @@ -1200,6 +1237,9 @@ impl TurnRequestProcessor { parent_thread, review_request, &display_text, + app_server_client_name, + app_server_client_version, + mcp_client_capabilities, ) .await?; } diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 567f37397a..73e8851109 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -90,6 +90,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand experimental_api: true, request_attestation: true, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ), ) 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 fc1100d17b..b52cef1e46 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -108,6 +110,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -142,6 +145,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -176,6 +180,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -218,6 +223,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -256,6 +262,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; @@ -293,6 +300,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_client_capabilities: None, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 0cf8504d3b..62892eb826 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), + mcp_client_capabilities: None, }), ), ) 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 b8349a77f1..c59cad439d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), + mcp_client_capabilities: None, }), ), ) diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 63b34e7355..6c33ec9661 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -13,6 +13,7 @@ use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; use codex_core::config::Config; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_client_capabilities; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; 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 0000000000..5a37c3d7e4 --- /dev/null +++ b/codex-rs/codex-mcp/src/client_capabilities.rs @@ -0,0 +1,199 @@ +use codex_protocol::mcp::McpAppUiCapability; +use codex_protocol::mcp::McpClientCapabilities; +use rmcp::model::ExtensionCapabilities; +use rmcp::model::Meta; +use rmcp::model::PaginatedRequestParams; +use serde_json::Map; +use serde_json::Value as JsonValue; +use sha1::Digest; +use sha1::Sha1; + +const MCP_APPS_UI_EXTENSION: &str = "io.modelcontextprotocol/ui"; +const MCP_CLIENT_CAPABILITIES_META: &str = "openai/clientCapabilities"; +const MCP_FUTURE_CLIENT_CAPABILITIES_META: &str = "io.modelcontextprotocol/clientCapabilities"; +const MCP_APP_WEB_VIEW_MIME_TYPE: &str = "text/html;profile=mcp-app"; +const MCP_APP_DECLARATIVE_UI_MIME_TYPE: &str = "text/x-dil;profile=mcp-app"; + +pub(crate) fn fingerprint(capabilities: &McpClientCapabilities) -> String { + let canonical = serde_json::to_string(capabilities).unwrap_or_default(); + let digest = Sha1::digest(format!("v1:{canonical}").as_bytes()); + format!("{digest:x}") +} + +fn app_ui_mime_types(capabilities: &McpClientCapabilities) -> Vec<&'static str> { + capabilities + .app_ui + .iter() + .map(|capability| match capability { + McpAppUiCapability::WebView => MCP_APP_WEB_VIEW_MIME_TYPE, + McpAppUiCapability::DeclarativeUi => MCP_APP_DECLARATIVE_UI_MIME_TYPE, + }) + .collect() +} + +fn as_json(capabilities: &McpClientCapabilities) -> JsonValue { + serde_json::json!({ + "extensions": { + MCP_APPS_UI_EXTENSION: { + "mimeTypes": app_ui_mime_types(capabilities), + } + } + }) +} + +pub(crate) fn as_extensions(capabilities: &McpClientCapabilities) -> ExtensionCapabilities { + let settings = [( + "mimeTypes".to_string(), + JsonValue::Array( + app_ui_mime_types(capabilities) + .into_iter() + .map(|mime_type| JsonValue::String(mime_type.to_string())) + .collect(), + ), + )] + .into_iter() + .collect(); + [(MCP_APPS_UI_EXTENSION.to_string(), settings)] + .into_iter() + .collect() +} + +pub(crate) fn add_meta(meta: &mut Option, capabilities: Option<&McpClientCapabilities>) { + if let Some(meta) = meta.as_mut() { + meta.remove(MCP_CLIENT_CAPABILITIES_META); + meta.remove(MCP_FUTURE_CLIENT_CAPABILITIES_META); + } + let Some(capabilities) = capabilities else { + return; + }; + meta.get_or_insert_with(Meta::new).insert( + MCP_CLIENT_CAPABILITIES_META.to_string(), + as_json(capabilities), + ); +} + +pub(crate) fn add_json_meta( + meta: &mut Option, + capabilities: Option<&McpClientCapabilities>, +) { + if let Some(object) = meta.as_mut().and_then(JsonValue::as_object_mut) { + object.remove(MCP_CLIENT_CAPABILITIES_META); + object.remove(MCP_FUTURE_CLIENT_CAPABILITIES_META); + } + let Some(capabilities) = capabilities else { + return; + }; + if !meta.as_ref().is_some_and(JsonValue::is_object) { + *meta = Some(JsonValue::Object(Map::new())); + } + meta.as_mut() + .and_then(JsonValue::as_object_mut) + .expect("metadata was normalized to an object") + .insert( + MCP_CLIENT_CAPABILITIES_META.to_string(), + as_json(capabilities), + ); +} + +pub(crate) fn paginated_params( + cursor: Option, + capabilities: Option<&McpClientCapabilities>, +) -> Option { + if cursor.is_none() && capabilities.is_none() { + return None; + } + let mut params = PaginatedRequestParams::default().with_cursor(cursor); + add_meta(&mut params.meta, capabilities); + Some(params) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn combined_capabilities() -> McpClientCapabilities { + McpClientCapabilities { + app_ui: [ + McpAppUiCapability::DeclarativeUi, + McpAppUiCapability::WebView, + ] + .into_iter() + .collect(), + } + } + + #[test] + fn semantic_capabilities_map_to_mcp_mime_types() { + assert_eq!( + as_json(&combined_capabilities()), + serde_json::json!({ + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": [ + "text/html;profile=mcp-app", + "text/x-dil;profile=mcp-app", + ], + }, + }, + }) + ); + } + + #[test] + fn fingerprint_is_stable_for_sorted_enum_set() { + let first = combined_capabilities(); + let second = McpClientCapabilities { + app_ui: [ + McpAppUiCapability::WebView, + McpAppUiCapability::DeclarativeUi, + ] + .into_iter() + .collect(), + }; + assert_eq!(fingerprint(&first), fingerprint(&second)); + assert_ne!( + fingerprint(&first), + fingerprint(&McpClientCapabilities::default()) + ); + } + + #[test] + fn request_metadata_removes_both_reserved_forms() { + let mut meta = Some(serde_json::json!({ + MCP_CLIENT_CAPABILITIES_META: {"spoofed": true}, + MCP_FUTURE_CLIENT_CAPABILITIES_META: {"spoofed": true}, + "caller": "preserved", + })); + add_json_meta(&mut meta, Some(&McpClientCapabilities::default())); + + assert_eq!( + meta, + Some(serde_json::json!({ + "caller": "preserved", + MCP_CLIENT_CAPABILITIES_META: { + "extensions": { + MCP_APPS_UI_EXTENSION: {"mimeTypes": []}, + }, + }, + })) + ); + } + + #[test] + fn request_metadata_replaces_non_object_values() { + let mut meta = Some(serde_json::json!("invalid")); + + add_json_meta(&mut meta, Some(&McpClientCapabilities::default())); + + assert_eq!( + meta, + Some(serde_json::json!({ + MCP_CLIENT_CAPABILITIES_META: { + "extensions": { + MCP_APPS_UI_EXTENSION: {"mimeTypes": []}, + }, + }, + })) + ); + } +} diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 3196a143e8..c27e4646de 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -41,6 +41,7 @@ pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCac pub(crate) struct CodexAppsToolsCacheContext { pub(crate) codex_home: PathBuf, pub(crate) user_key: CodexAppsToolsCacheKey, + pub(crate) client_capabilities_fingerprint: Option, } impl CodexAppsToolsCacheContext { @@ -55,9 +56,11 @@ impl CodexAppsToolsCacheContext { fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); - self.codex_home - .join(cache_dir) - .join(format!("{user_key_hash}.json")) + let filename = self.client_capabilities_fingerprint.as_ref().map_or_else( + || format!("{user_key_hash}.json"), + |fingerprint| format!("{user_key_hash}-{fingerprint}.json"), + ); + self.codex_home.join(cache_dir).join(filename) } } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index d15a2e1500..59178286b6 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; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; @@ -40,11 +41,13 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; +use codex_api::SharedAuthProvider; use codex_config::Constrained; use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -110,6 +113,7 @@ pub struct McpConnectionManager { prefix_mcp_tool_names: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, + codex_apps_client_capabilities: Option, } impl McpConnectionManager { @@ -142,6 +146,7 @@ impl McpConnectionManager { /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), + codex_apps_client_capabilities: None, } } @@ -189,6 +194,10 @@ impl McpConnectionManager { self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME } + pub fn codex_apps_client_capabilities(&self) -> Option<&McpClientCapabilities> { + self.codex_apps_client_capabilities.as_ref() + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -227,6 +236,7 @@ impl McpConnectionManager { tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, + codex_apps_client_capabilities: Option, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); @@ -262,6 +272,9 @@ impl McpConnectionManager { Some(CodexAppsToolsCacheContext { codex_home: codex_home.clone(), user_key: codex_apps_tools_cache_key.clone(), + client_capabilities_fingerprint: codex_apps_client_capabilities + .as_ref() + .map(client_capabilities::fingerprint), }) } else { None @@ -290,6 +303,9 @@ impl McpConnectionManager { tx_event.clone(), elicitation_requests.clone(), codex_apps_tools_cache_context, + (host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME) + .then(|| codex_apps_client_capabilities.clone()) + .flatten(), Arc::clone(&tool_plugin_provenance), runtime_context.clone(), runtime_auth_provider, @@ -338,6 +354,7 @@ impl McpConnectionManager { prefix_mcp_tool_names, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), + codex_apps_client_capabilities, }; tokio::spawn(async move { let outcomes = join_set.join_all().await; @@ -364,6 +381,115 @@ impl McpConnectionManager { (manager, cancel_token) } + #[allow(clippy::too_many_arguments)] + pub async fn refresh_codex_apps_client( + &mut self, + server: Option, + store_mode: OAuthCredentialsStoreMode, + auth_entry: Option, + submit_id: String, + tx_event: Sender, + codex_home: PathBuf, + codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + client_capabilities: Option, + runtime_context: McpRuntimeContext, + runtime_auth_provider: Option, + client_elicitation_capability: ElicitationCapability, + ) { + if let Some(client) = self.clients.remove(CODEX_APPS_MCP_SERVER_NAME) { + client.shutdown().await; + } + self.server_metadata.remove(CODEX_APPS_MCP_SERVER_NAME); + self.codex_apps_client_capabilities = client_capabilities.clone(); + + let Some(server) = server.filter(EffectiveMcpServer::enabled) else { + return; + }; + self.server_metadata.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + McpServerMetadata::from(&server), + ); + let _ = emit_update( + submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + status: McpStartupStatus::Starting, + }, + ) + .await; + + let cancel_token = self.startup_cancellation_token.child_token(); + let client = AsyncManagedClient::new( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + server, + store_mode, + cancel_token.clone(), + tx_event.clone(), + self.elicitation_requests.clone(), + Some(CodexAppsToolsCacheContext { + codex_home, + user_key: codex_apps_tools_cache_key, + client_capabilities_fingerprint: client_capabilities + .as_ref() + .map(client_capabilities::fingerprint), + }), + client_capabilities, + Arc::clone(&self.tool_plugin_provenance), + runtime_context, + runtime_auth_provider, + client_elicitation_capability, + ); + self.clients + .insert(CODEX_APPS_MCP_SERVER_NAME.to_string(), client.clone()); + + tokio::spawn(async move { + let mut outcome = client.client().await; + if cancel_token.is_cancelled() { + outcome = Err(StartupOutcomeError::Cancelled); + } + let status = match &outcome { + Ok(_) => McpStartupStatus::Ready, + Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, + Err(error) => McpStartupStatus::Failed { + error: mcp_init_error_display( + CODEX_APPS_MCP_SERVER_NAME, + auth_entry.as_ref(), + error, + ), + }, + }; + let _ = emit_update( + submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + status, + }, + ) + .await; + let mut summary = McpStartupCompleteEvent::default(); + match outcome { + Ok(_) => summary.ready.push(CODEX_APPS_MCP_SERVER_NAME.to_string()), + Err(StartupOutcomeError::Cancelled) => summary + .cancelled + .push(CODEX_APPS_MCP_SERVER_NAME.to_string()), + Err(StartupOutcomeError::Failed { error }) => { + summary.failed.push(McpStartupFailure { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + error, + }); + } + } + let _ = tx_event + .send(Event { + id: submit_id, + msg: EventMsg::McpStartupComplete(summary), + }) + .await; + }); + } + pub async fn resolve_elicitation( &self, server_name: String, @@ -498,6 +624,7 @@ impl McpConnectionManager { &managed_client.client, managed_client.tool_timeout, managed_client.server_instructions.as_deref(), + managed_client.client_capabilities.as_ref(), ) .await .with_context(|| { @@ -561,15 +688,17 @@ impl McpConnectionManager { }; let timeout = managed_client.tool_timeout; let client = managed_client.client.clone(); + let client_capabilities = managed_client.client_capabilities.clone(); join_set.spawn(async move { let mut collected: Vec = Vec::new(); let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| { - PaginatedRequestParams::default().with_cursor(Some(next.clone())) - }); + let params = client_capabilities::paginated_params( + cursor.clone(), + client_capabilities.as_ref(), + ); let response = match client.list_resources(params, timeout).await { Ok(result) => result, Err(err) => return (server_name, Err(err)), @@ -626,15 +755,17 @@ impl McpConnectionManager { }; let client = managed_client.client.clone(); let timeout = managed_client.tool_timeout; + let client_capabilities = managed_client.client_capabilities.clone(); join_set.spawn(async move { let mut collected: Vec = Vec::new(); let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| { - PaginatedRequestParams::default().with_cursor(Some(next.clone())) - }); + let params = client_capabilities::paginated_params( + cursor.clone(), + client_capabilities.as_ref(), + ); let response = match client.list_resource_templates(params, timeout).await { Ok(result) => result, Err(err) => return (server_name_cloned, Err(err)), @@ -687,7 +818,7 @@ impl McpConnectionManager { server: &str, tool: &str, arguments: Option, - meta: Option, + mut meta: Option, ) -> Result { let client = self.client_by_name(server).await?; if !client.tool_filter.allows(tool) { @@ -696,6 +827,7 @@ impl McpConnectionManager { )); } + client_capabilities::add_json_meta(&mut meta, client.client_capabilities.as_ref()); let result: rmcp::model::CallToolResult = client .client .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) @@ -733,11 +865,15 @@ impl McpConnectionManager { pub async fn list_resources( &self, server: &str, - params: Option, + mut params: Option, ) -> Result { let managed = self.client_by_name(server).await?; let timeout = managed.tool_timeout; + if managed.client_capabilities.is_some() { + let params = params.get_or_insert_with(PaginatedRequestParams::default); + client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref()); + } managed .client .list_resources(params, timeout) @@ -749,12 +885,16 @@ impl McpConnectionManager { pub async fn list_resource_templates( &self, server: &str, - params: Option, + mut params: Option, ) -> Result { let managed = self.client_by_name(server).await?; let client = managed.client.clone(); let timeout = managed.tool_timeout; + if managed.client_capabilities.is_some() { + let params = params.get_or_insert_with(PaginatedRequestParams::default); + client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref()); + } client .list_resource_templates(params, timeout) .await @@ -765,12 +905,13 @@ impl McpConnectionManager { pub async fn read_resource( &self, server: &str, - params: ReadResourceRequestParams, + mut params: ReadResourceRequestParams, ) -> Result { let managed = self.client_by_name(server).await?; let client = managed.client.clone(); let timeout = managed.tool_timeout; let uri = params.uri.clone(); + client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref()); client .read_resource(params, timeout) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 60a0026eeb..6b9e59c0e5 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -82,9 +82,110 @@ fn create_codex_apps_tools_cache_context( chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), is_workspace_account: false, }, + client_capabilities_fingerprint: None, } } +#[test] +fn capability_profiles_partition_codex_apps_cache_paths() { + let legacy = create_codex_apps_tools_cache_context(PathBuf::from("/tmp/codex"), None, None); + let mut explicit_empty = legacy.clone(); + explicit_empty.client_capabilities_fingerprint = Some(crate::client_capabilities::fingerprint( + &codex_protocol::mcp::McpClientCapabilities::default(), + )); + + assert_ne!(legacy.tools_cache_path(), explicit_empty.tools_cache_path()); + assert_ne!( + legacy.server_info_cache_path(), + explicit_empty.server_info_cache_path() + ); + assert!( + legacy + .tools_cache_path() + .to_string_lossy() + .ends_with(".json") + ); +} + +#[tokio::test] +async fn refreshing_codex_apps_capabilities_preserves_unrelated_clients() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let unrelated_cancel_token = CancellationToken::new(); + let old_codex_apps_cancel_token = CancellationToken::new(); + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let cancelled_client = futures::future::ready(Err(StartupOutcomeError::Cancelled)) + .boxed() + .shared(); + manager.clients.insert( + "local".to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: None, + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: unrelated_cancel_token.clone(), + }, + ); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: cancelled_client, + cached_tool_info_snapshot: None, + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: old_codex_apps_cancel_token.clone(), + }, + ); + let capabilities = codex_protocol::mcp::McpClientCapabilities { + app_ui: [codex_protocol::mcp::McpAppUiCapability::WebView] + .into_iter() + .collect(), + }; + let (tx_event, _rx_event) = async_channel::unbounded(); + + manager + .refresh_codex_apps_client( + None, + OAuthCredentialsStoreMode::default(), + None, + "test".to_string(), + tx_event, + tempdir().expect("tempdir").path().to_path_buf(), + CodexAppsToolsCacheKey { + account_id: None, + chatgpt_user_id: None, + is_workspace_account: false, + }, + Some(capabilities.clone()), + McpRuntimeContext::new( + Arc::new(EnvironmentManager::without_environments()), + PathBuf::from("/tmp"), + ), + None, + ElicitationCapability::default(), + ) + .await; + + assert!(old_codex_apps_cancel_token.is_cancelled()); + assert!(!unrelated_cancel_token.is_cancelled()); + assert!(manager.clients.contains_key("local")); + assert!(!manager.clients.contains_key(CODEX_APPS_MCP_SERVER_NAME)); + assert_eq!( + manager.codex_apps_client_capabilities(), + Some(&capabilities) + ); +} + fn create_test_server_info(title: &str) -> McpServerInfo { McpServerInfo { name: "codex-apps".to_string(), @@ -1191,6 +1292,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, + /*codex_apps_client_capabilities*/ None, ) .await; diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 83419175d4..37783af0d9 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -53,6 +53,7 @@ pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; pub(crate) mod auth_elicitation; +pub(crate) mod client_capabilities; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 9633655846..541cd5e42c 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -24,6 +24,7 @@ use codex_config::types::AppToolApproval; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; @@ -266,6 +267,7 @@ pub async fn read_mcp_resource( runtime_context: McpRuntimeContext, server: &str, uri: &str, + mcp_client_capabilities: Option, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); @@ -295,6 +297,7 @@ pub async fn read_mcp_resource( tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, + mcp_client_capabilities, ) .await; @@ -321,6 +324,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( submit_id: String, runtime_context: McpRuntimeContext, detail: McpSnapshotDetail, + mcp_client_capabilities: Option, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); @@ -365,6 +369,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, + mcp_client_capabilities, ) .await; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 78078720e7..3aeec5e28c 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -16,6 +16,7 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use crate::client_capabilities; use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; @@ -48,6 +49,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; @@ -94,6 +96,7 @@ pub(crate) struct ManagedClient { pub(crate) server_instructions: Option, pub(crate) server_supports_sandbox_state_meta_capability: bool, pub(crate) codex_apps_tools_cache_context: Option, + pub(crate) client_capabilities: Option, } impl ManagedClient { @@ -145,6 +148,7 @@ impl AsyncManagedClient { tx_event: Sender, elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, + client_capabilities: Option, tool_plugin_provenance: Arc, runtime_context: McpRuntimeContext, runtime_auth_provider: Option, @@ -200,6 +204,7 @@ impl AsyncManagedClient { tx_event, elicitation_requests, codex_apps_tools_cache_context, + client_capabilities, client_elicitation_capability, }, ) @@ -344,9 +349,11 @@ pub(crate) async fn list_tools_for_client_uncached( client: &Arc, timeout: Option, server_instructions: Option<&str>, + client_capabilities: Option<&McpClientCapabilities>, ) -> Result> { + let params = client_capabilities::paginated_params(None, client_capabilities); let resp = client - .list_tools_with_connector_ids(/*params*/ None, timeout) + .list_tools_with_connector_ids(params, timeout) .await?; let tools = resp .tools @@ -479,10 +486,14 @@ async fn start_server_task( tx_event, elicitation_requests, codex_apps_tools_cache_context, + client_capabilities, client_elicitation_capability, } = params; let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); + capabilities.extensions = client_capabilities + .as_ref() + .map(client_capabilities::as_extensions); let params = InitializeRequestParams::new( capabilities, Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), @@ -509,6 +520,7 @@ async fn start_server_task( &client, startup_timeout, initialize_result.instructions.as_deref(), + client_capabilities.as_ref(), ) .await .map_err(StartupOutcomeError::from)?; @@ -542,6 +554,7 @@ async fn start_server_task( server_instructions: initialize_result.instructions, server_supports_sandbox_state_meta_capability, codex_apps_tools_cache_context, + client_capabilities, }; Ok(managed) @@ -570,6 +583,7 @@ struct StartServerTaskParams { tx_event: Sender, elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, + client_capabilities: Option, client_elicitation_capability: ElicitationCapability, } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ab1f6df03e..4159669227 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; @@ -315,12 +316,14 @@ impl CodexThread { app_server_client_name: Option, app_server_client_version: Option, mcp_elicitations_auto_deny: bool, + mcp_client_capabilities: Option, ) -> ConstraintResult<()> { self.codex .set_app_server_client_info( app_server_client_name, app_server_client_version, mcp_elicitations_auto_deny, + mcp_client_capabilities, ) .await } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 7a8344b3d7..e063d97758 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -14,6 +14,7 @@ use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::models::PermissionProfile; use codex_tools::DiscoverableTool; use rmcp::model::ToolAnnotations; @@ -67,6 +68,7 @@ struct AccessibleConnectorsCacheKey { account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, + mcp_client_capabilities: Option, } #[derive(Clone)] @@ -90,7 +92,7 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) -> anyhow::Result> { Ok( list_accessible_connectors_from_mcp_tools_with_options_and_status( - config, /*force_refetch*/ false, + config, /*force_refetch*/ false, None, ) .await? .connectors, @@ -147,6 +149,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, + mcp_client_capabilities: Option<&McpClientCapabilities>, ) -> Option> { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; @@ -157,7 +160,8 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( { return Some(Vec::new()); } - let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); + let cache_key = + accessible_connectors_cache_key(config, auth.as_ref(), mcp_client_capabilities.cloned()); read_cached_accessible_connectors(&cache_key).map(|connectors| { codex_connectors::filter::filter_disallowed_connectors( connectors, @@ -170,12 +174,13 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( config: &Config, auth: Option<&CodexAuth>, mcp_tools: &[ToolInfo], + mcp_client_capabilities: Option<&McpClientCapabilities>, ) { if !config.features.enabled(Feature::Apps) { return; } - let cache_key = accessible_connectors_cache_key(config, auth); + let cache_key = accessible_connectors_cache_key(config, auth, mcp_client_capabilities.cloned()); let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( accessible_connectors_from_mcp_tools(mcp_tools), originator().value.as_str(), @@ -188,15 +193,36 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( force_refetch: bool, ) -> anyhow::Result> { Ok( - list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch) - .await? - .connectors, + list_accessible_connectors_from_mcp_tools_with_options_and_status( + config, + force_refetch, + None, + ) + .await? + .connectors, + ) +} + +pub async fn list_accessible_connectors_from_mcp_tools_with_client_capabilities( + config: &Config, + force_refetch: bool, + mcp_client_capabilities: Option, +) -> anyhow::Result> { + Ok( + list_accessible_connectors_from_mcp_tools_with_options_and_status( + config, + force_refetch, + mcp_client_capabilities, + ) + .await? + .connectors, ) } pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config: &Config, force_refetch: bool, + mcp_client_capabilities: Option, ) -> anyhow::Result { // TODO: Wire callers that already own an EnvironmentManager into // list_accessible_connectors_from_mcp_tools_with_environment_manager instead @@ -212,6 +238,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config, force_refetch, Arc::new(environment_manager), + mcp_client_capabilities, ) .await } @@ -220,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( config: &Config, force_refetch: bool, environment_manager: Arc, + mcp_client_capabilities: Option, ) -> anyhow::Result { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; @@ -233,7 +261,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( codex_apps_ready: true, }); } - let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); + let cache_key = + accessible_connectors_cache_key(config, auth.as_ref(), mcp_client_capabilities.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = McpManager::new(Arc::clone(&plugins_manager)); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await; @@ -289,6 +318,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + mcp_client_capabilities, ) .await; @@ -366,6 +396,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( fn accessible_connectors_cache_key( config: &Config, auth: Option<&CodexAuth>, + mcp_client_capabilities: Option, ) -> AccessibleConnectorsCacheKey { let account_id = auth.and_then(CodexAuth::get_account_id); let chatgpt_user_id = auth.and_then(CodexAuth::get_chatgpt_user_id); @@ -375,6 +406,7 @@ fn accessible_connectors_cache_key( account_id, chatgpt_user_id, is_workspace_account, + mcp_client_capabilities, } } diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 0427ce5a4a..d3a930477c 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -21,6 +21,8 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; +use codex_protocol::mcp::McpAppUiCapability; +use codex_protocol::mcp::McpClientCapabilities; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; @@ -167,7 +169,43 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal .await .expect("config should load"); let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true); - let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None); + let explicit_empty = McpClientCapabilities::default(); + let web_view = McpClientCapabilities { + app_ui: [McpAppUiCapability::WebView].into_iter().collect(), + }; + let declarative_ui = McpClientCapabilities { + app_ui: [McpAppUiCapability::DeclarativeUi].into_iter().collect(), + }; + let combined = McpClientCapabilities { + app_ui: [ + McpAppUiCapability::WebView, + McpAppUiCapability::DeclarativeUi, + ] + .into_iter() + .collect(), + }; + let legacy_cache_key = accessible_connectors_cache_key(&config, /*auth*/ None, None); + let explicit_empty_cache_key = + accessible_connectors_cache_key(&config, /*auth*/ None, Some(explicit_empty.clone())); + let web_view_cache_key = + accessible_connectors_cache_key(&config, /*auth*/ None, Some(web_view)); + let declarative_ui_cache_key = + accessible_connectors_cache_key(&config, /*auth*/ None, Some(declarative_ui)); + let combined_cache_key = + accessible_connectors_cache_key(&config, /*auth*/ None, Some(combined)); + + let cache_keys = [ + &legacy_cache_key, + &explicit_empty_cache_key, + &web_view_cache_key, + &declarative_ui_cache_key, + &combined_cache_key, + ]; + for (index, cache_key) in cache_keys.iter().enumerate() { + for other_cache_key in &cache_keys[index + 1..] { + assert!(cache_key != other_cache_key); + } + } let tools = vec![ codex_app_tool( "calendar_list_events", @@ -184,8 +222,18 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal ]; let cached = with_accessible_connectors_cache_cleared(|| { - refresh_accessible_connectors_cache_from_mcp_tools(&config, /*auth*/ None, &tools); - read_cached_accessible_connectors(&cache_key).expect("cache should be populated") + refresh_accessible_connectors_cache_from_mcp_tools( + &config, + /*auth*/ None, + &tools, + Some(&explicit_empty), + ); + assert!(read_cached_accessible_connectors(&legacy_cache_key).is_none()); + assert!(read_cached_accessible_connectors(&web_view_cache_key).is_none()); + assert!(read_cached_accessible_connectors(&declarative_ui_cache_key).is_none()); + assert!(read_cached_accessible_connectors(&combined_cache_key).is_none()); + read_cached_accessible_connectors(&explicit_empty_cache_key) + .expect("explicit-empty capability cache should be populated") }); assert_eq!( diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index b9a8d4ff08..dab05490c4 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -686,6 +686,7 @@ async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: & &turn_context.config, auth.as_ref(), &mcp_tools, + turn_context.mcp_client_capabilities.as_ref(), ); } Err(err) => { @@ -1435,15 +1436,18 @@ pub(crate) async fn lookup_mcp_tool_metadata( let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME { let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools( turn_context.config.as_ref(), + turn_context.mcp_client_capabilities.as_ref(), ) .await { Some(connectors) => Some(connectors), - None => { - connectors::list_accessible_connectors_from_mcp_tools(turn_context.config.as_ref()) - .await - .ok() - } + None => connectors::list_accessible_connectors_from_mcp_tools_with_client_capabilities( + turn_context.config.as_ref(), + false, + turn_context.mcp_client_capabilities.clone(), + ) + .await + .ok(), }; connectors.and_then(|connectors| { let connector_id = tool_info.connector_id.as_deref()?; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index bd71c3a054..b199f73c1e 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1276,6 +1276,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + turn_context.mcp_client_capabilities.clone(), ) .await; *session.services.mcp_connection_manager.write().await = manager; diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cc018343eb..d7c04ca53f 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -358,6 +358,7 @@ impl Session { tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, + turn_context.mcp_client_capabilities.clone(), ) .await; { @@ -379,6 +380,54 @@ impl Session { old_manager.shutdown().await; } + pub(crate) async fn refresh_codex_apps_client( + &self, + client_capabilities: Option, + submit_id: String, + ) { + let auth = self.services.auth_manager.auth().await; + let config = self.get_config().await; + let mcp_config = config + .to_mcp_config(self.services.plugins_manager.as_ref()) + .await; + let mut effective = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let auth_statuses = compute_auth_statuses( + effective.iter(), + config.mcp_oauth_credentials_store_mode, + auth.as_ref(), + ) + .await; + let server = effective.remove(codex_mcp::CODEX_APPS_MCP_SERVER_NAME); + let auth_entry = auth_statuses + .get(codex_mcp::CODEX_APPS_MCP_SERVER_NAME) + .cloned(); + let runtime_auth_provider = auth + .as_ref() + .filter(|auth| auth.uses_codex_backend()) + .map(codex_model_provider::auth_provider_from_auth); + self.services + .mcp_connection_manager + .write() + .await + .refresh_codex_apps_client( + server, + config.mcp_oauth_credentials_store_mode, + auth_entry, + submit_id, + self.get_tx_event(), + config.codex_home.to_path_buf(), + codex_apps_tools_cache_key(auth.as_ref()), + client_capabilities, + McpRuntimeContext::new( + Arc::clone(&self.services.environment_manager), + config.cwd.to_path_buf(), + ), + runtime_auth_provider, + mcp_config.client_elicitation_capability, + ) + .await; + } + pub(crate) async fn refresh_mcp_servers_if_requested( &self, turn_context: &TurnContext, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f3e506d40f..0c2f2761ab 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -89,6 +89,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpClientCapabilities; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::BaseInstructions; @@ -319,6 +320,7 @@ use codex_git_utils::get_git_repo_root; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers_from_configured; use codex_mcp::host_owned_codex_apps_enabled; +use codex_mcp::with_codex_apps_mcp; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; @@ -614,6 +616,8 @@ impl Codex { metrics_service_name, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: matches!(&session_source, SessionSource::Cli) + .then(McpClientCapabilities::default), session_source, forked_from_thread_id, parent_thread_id, @@ -782,16 +786,39 @@ impl Codex { app_server_client_name: Option, app_server_client_version: Option, mcp_elicitations_auto_deny: bool, + mcp_client_capabilities: Option, ) -> ConstraintResult<()> { + let _profile_update_guard = self + .session + .app_server_client_profile_update_lock + .lock() + .await; + let capabilities_changed = { + let manager = self.session.services.mcp_connection_manager.read().await; + manager.codex_apps_client_capabilities() != mcp_client_capabilities.as_ref() + }; self.session .update_settings(SessionSettingsUpdate { app_server_client_name, app_server_client_version, + mcp_client_capabilities: Some(mcp_client_capabilities.clone()), ..Default::default() }) .await?; - let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await; - mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny); + self.session + .services + .mcp_connection_manager + .read() + .await + .set_elicitations_auto_deny(mcp_elicitations_auto_deny); + if capabilities_changed { + self.session + .refresh_codex_apps_client( + mcp_client_capabilities, + "app-server-client-profile".to_string(), + ) + .await; + } Ok(()) } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index a3b23c6a78..a6966982b0 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -130,6 +130,7 @@ pub(super) async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + mcp_client_capabilities: parent_turn_context.mcp_client_capabilities.clone(), developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 37a501fb38..a1fda4d2c8 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -29,6 +29,9 @@ pub(crate) struct Session { /// Serializes rebuild/apply cycles for the running proxy; each cycle /// rebuilds from the current SessionState while holding this lock. pub(super) managed_network_proxy_refresh_lock: Semaphore, + /// Keeps the active app-server client profile and the host-owned Apps MCP + /// client in sync when different clients resume the same thread. + pub(super) app_server_client_profile_update_lock: Mutex<()>, /// The set of enabled features should be invariant for the lifetime of the /// session. pub(super) features: ManagedFeatures, @@ -93,6 +96,7 @@ pub(crate) struct SessionConfiguration { pub(super) metrics_service_name: Option, pub(super) app_server_client_name: Option, pub(super) app_server_client_version: Option, + pub(super) mcp_client_capabilities: Option, /// Source of the session (cli, vscode, exec, mcp, ...) pub(super) session_source: SessionSource, /// Immediate history source copied into this thread, when this thread was forked. @@ -367,6 +371,9 @@ 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(mcp_client_capabilities) = updates.mcp_client_capabilities.clone() { + next_configuration.mcp_client_capabilities = mcp_client_capabilities; + } Ok(next_configuration) } @@ -425,6 +432,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) mcp_client_capabilities: Option>, } pub(crate) struct AppServerClientMetadata { @@ -1060,6 +1068,7 @@ impl Session { out_of_band_elicitation_paused, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), + app_server_client_profile_update_lock: Mutex::new(()), features: config.features.clone(), multi_agent_version, pending_mcp_server_refresh_config: Mutex::new(None), @@ -1176,6 +1185,7 @@ impl Session { tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), + session_configuration.mcp_client_capabilities.clone(), ) .instrument(info_span!( "session_init.mcp_manager_init", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d44c7fbdcc..328f249223 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3167,6 +3167,7 @@ async fn set_rate_limits_retains_previous_credits() { metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -3274,6 +3275,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -3805,6 +3807,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -4657,6 +4660,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -4764,6 +4768,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -4915,6 +4920,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), + app_server_client_profile_update_lock: Mutex::new(()), features: config.features.clone(), multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), @@ -4996,6 +5002,7 @@ async fn make_session_with_config_and_rx( metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -5097,6 +5104,8 @@ async fn make_session_with_history_source_and_agent_control_and_rx( metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: matches!(&session_source, SessionSource::Cli) + .then(McpClientCapabilities::default), session_source: session_source.clone(), forked_from_thread_id: None, parent_thread_id: None, @@ -6827,6 +6836,7 @@ where metrics_service_name: None, app_server_client_name: None, app_server_client_version: None, + mcp_client_capabilities: None, session_source: SessionSource::Exec, forked_from_thread_id: None, parent_thread_id: None, @@ -6978,6 +6988,7 @@ where out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), + app_server_client_profile_update_lock: Mutex::new(()), features: config.features.clone(), multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index be6e316a00..ca810c3ff7 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -78,6 +78,7 @@ pub struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) mcp_client_capabilities: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -246,6 +247,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + mcp_client_capabilities: self.mcp_client_capabilities.clone(), developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -545,6 +547,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + mcp_client_capabilities: session_configuration.mcp_client_capabilities.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 3f7bd92d25..ec0c2fff8b 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -339,6 +339,7 @@ async fn refresh_missing_requested_connectors( &turn.config, auth, &mcp_tools, + turn.mcp_client_capabilities.as_ref(), ); Some(accessible_connectors) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b8b87a85ec..ba5c592355 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -550,6 +550,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: None, channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; run_exec_session(ExecRunArgs { diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index a1916d424c..fa84e33217 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -6,8 +6,27 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeSet; use ts_rs::TS; +/// Passive MCP App presentation features supported by the active host. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub enum McpAppUiCapability { + WebView, + DeclarativeUi, +} + +/// Passive MCP capabilities supplied by the active Codex host. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpClientCapabilities { + pub app_ui: BTreeSet, +} + /// ID of a request, which can be either a string or an integer. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(untagged)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5e6b6cf81c..98ddd3d6de 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -464,6 +464,7 @@ async fn connect_remote_app_server( client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: Some(Default::default()), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await @@ -627,6 +628,7 @@ where client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: Some(Default::default()), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index e10700ed82..2f129003ec 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1054,6 +1054,7 @@ mod tests { client_version: "test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), + mcp_client_capabilities: Some(Default::default()), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await