diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caea115825..45d83c362c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -822,6 +822,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1883,6 +1884,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -2188,6 +2190,7 @@ async fn compaction_event_ingests_custom_fact() { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -2319,6 +2322,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: 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 df8a8ab1ba..509bb72d57 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -349,6 +349,7 @@ impl InProcessClientStartArgs { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, + extensions: None, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 28ad04ea89..0e375eef6f 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -94,6 +94,7 @@ impl RemoteAppServerConnectArgs { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, + extensions: None, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 343803b332..a4d3194e6b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1536,8 +1536,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.", + "type": [ + "object", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { - "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "description": "Legacy opt-in for the `openai/form` MCP extension.\n\nNew clients should declare `openai/form` in [`Self::extensions`].", "type": "boolean" }, "optOutNotificationMethods": { 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 cc7e3781dc..49e1ed3829 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 @@ -3115,8 +3115,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.", + "type": [ + "object", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { - "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "description": "Legacy opt-in for the `openai/form` MCP extension.\n\nNew clients should declare `openai/form` in [`Self::extensions`].", "type": "boolean" }, "optOutNotificationMethods": { 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 ec948b4080..650866067a 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 @@ -8326,8 +8326,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.", + "type": [ + "object", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { - "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "description": "Legacy opt-in for the `openai/form` MCP extension.\n\nNew clients should declare `openai/form` in [`Self::extensions`].", "type": "boolean" }, "optOutNotificationMethods": { 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 75f0860ddd..7acc76386c 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,8 +30,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "extensions": { + "additionalProperties": true, + "description": "MCP extension settings declared by the app-server client.", + "type": [ + "object", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { - "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "description": "Legacy opt-in for the `openai/form` MCP extension.\n\nNew clients should declare `openai/form` in [`Self::extensions`].", "type": "boolean" }, "optOutNotificationMethods": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 45fd35e344..6c0f1b4cd9 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index a0a0fccc51..2bc2a19ea2 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index dcc4dffb0b..6b4afd166b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; /** * Client-declared capabilities negotiated during initialize. @@ -15,11 +16,17 @@ experimentalApi: boolean, */ requestAttestation: boolean, /** - * Allow downstream MCP servers to request OpenAI extended form elicitations. + * Legacy opt-in for the `openai/form` MCP extension. + * + * New clients should declare `openai/form` in [`Self::extensions`]. */ mcpServerOpenaiFormElicitation?: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). */ -optOutNotificationMethods?: Array | null, }; +optOutNotificationMethods?: Array | null, +/** + * MCP extension settings declared by the app-server client. + */ +extensions?: { [key in string]?: JsonValue } | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c1ab9a6bac..c36df0a8f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2472,6 +2472,12 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), }), }, }; @@ -2493,7 +2499,12 @@ mod tests { "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" - ] + ], + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } } } }), @@ -2520,7 +2531,12 @@ mod tests { "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" - ] + ], + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } } } }))?; @@ -2543,6 +2559,12 @@ mod tests { "thread/started".to_string(), "item/agentMessage/delta".to_string(), ]), + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), }), }, } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index bdd820e25e..17013b528f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -50,13 +50,19 @@ pub struct InitializeCapabilities { /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. #[serde(default)] pub request_attestation: bool, - /// Allow downstream MCP servers to request OpenAI extended form elicitations. + /// Legacy opt-in for the `openai/form` MCP extension. + /// + /// New clients should declare `openai/form` in [`Self::extensions`]. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub mcp_server_openai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] pub opt_out_notification_methods: Option>, + /// MCP extension settings declared by the app-server client. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub extensions: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 0f04339226..5f35575f33 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1759,6 +1759,7 @@ impl CodexClient { .collect(), ), mcp_server_openai_form_elicitation: false, + extensions: None, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6d547f9723..ddb47c5182 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,12 +88,33 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. -Clients that handle OpenAI extended MCP forms, including a fallback for -unsupported field types, set -`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`. -App-server then advertises the downstream `openai/form` MCP extension for -threads started, resumed, or forked by that connection. Clients that cannot -handle the request envelope omit the field or set it to `false`. +Clients declare supported MCP extensions during initialization. For OpenAI +extended forms, clients must handle the request envelope, including a fallback +for unsupported field types. `mcpServerOpenaiFormElicitation: true` remains a +legacy alias for declaring the `openai/form` extension. + +```json +{ + "capabilities": { + "extensions": { + "openai/form": {}, + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } + } +} +``` + +App-server keeps the complete value under `io.modelcontextprotocol/ui`, rather +than deriving a WebView boolean, so clients can advertise additional supported +MIME types and future extension settings. The MCP extension profile is fixed +when a Codex session is created by `thread/start`, `thread/resume`, or +`thread/fork`. Codex advertises that profile in the downstream MCP +`initialize` request; it is not repeated in individual tool-call metadata. +Every turn and direct MCP tool call in that loaded session therefore uses the +same initialized profile. A different app-server connection cannot change it +by starting a later turn. Subagent sessions inherit the same extension profile. Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 94fba3b9a1..c11671bb5d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -72,6 +72,7 @@ use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_protocol::ThreadId; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use codex_rollout::StateDbHandle; @@ -135,7 +136,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, - pub(crate) supports_openai_form_elicitation: bool, + pub(crate) client_mcp_extensions: ClientMcpExtensions, } impl Default for ConnectionSessionState { @@ -187,10 +188,11 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } - pub(crate) fn supports_openai_form_elicitation(&self) -> bool { + pub(crate) fn client_mcp_extensions(&self) -> ClientMcpExtensions { self.initialized .get() - .is_some_and(|session| session.supports_openai_form_elicitation) + .map(|session| session.client_mcp_extensions.clone()) + .unwrap_or_default() } pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) @@ -829,7 +831,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 supports_openai_form_elicitation = session.supports_openai_form_elicitation(); + let client_mcp_extensions = session.client_mcp_extensions(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -845,7 +847,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await; if let Err(error) = result { @@ -875,7 +877,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1039,7 +1041,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - supports_openai_form_elicitation, + client_mcp_extensions.clone(), request_context, ) .await @@ -1056,8 +1058,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - /*supports_openai_form_elicitation*/ - supports_openai_form_elicitation, + client_mcp_extensions.clone(), ) .await } @@ -1068,8 +1069,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - /*supports_openai_form_elicitation*/ - supports_openai_form_elicitation, + client_mcp_extensions.clone(), ) .await } @@ -1298,8 +1298,6 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), - /*supports_openai_form_elicitation*/ - supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/app-server/src/request_processors/apps_processor/installed.rs b/codex-rs/app-server/src/request_processors/apps_processor/installed.rs index e2360f4744..b5ca558b2b 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor/installed.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor/installed.rs @@ -12,6 +12,7 @@ use codex_mcp::ToolInfo; use codex_mcp::effective_mcp_servers; use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::tool_is_model_visible; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::PermissionProfile; #[cfg(test)] @@ -101,7 +102,7 @@ impl AppsRequestProcessor { codex_apps_tools_cache: mcp_manager.codex_apps_tools_cache(), tool_catalog_cache: mcp_manager.tool_catalog_cache(), codex_apps_tools_cache_key: cache_key.clone(), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.clone(), codex_apps_auth_manager, elicitation_reviewer: None, 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 cfdad27f50..442b71f335 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -70,7 +70,11 @@ impl InitializeRequestProcessor { let capabilities = params.capabilities.unwrap_or_default(); let experimental_api_enabled = capabilities.experimental_api; let request_attestation = capabilities.request_attestation; - let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation; + let extensions = capabilities.extensions.as_ref(); + let client_mcp_extensions = codex_mcp::client_mcp_extensions( + extensions, + capabilities.mcp_server_openai_form_elicitation, + ); let opt_out_notification_methods = capabilities .opt_out_notification_methods .unwrap_or_default(); @@ -97,7 +101,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, - supports_openai_form_elicitation, + client_mcp_extensions, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 2b3b5fa21b..ed72571f40 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -10,6 +10,7 @@ use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_extension_api::ExtensionDataInit; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::protocol::ThreadHistoryMode; @@ -453,7 +454,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -461,7 +462,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, - supports_openai_form_elicitation, + client_mcp_extensions, request_context, ) .await @@ -484,14 +485,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await .map(|()| None) @@ -503,14 +504,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await .map(|()| None) @@ -984,7 +985,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -1069,7 +1070,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, - supports_openai_form_elicitation, + client_mcp_extensions, config, typesafe_overrides, dynamic_tools, @@ -1146,7 +1147,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1291,7 +1292,7 @@ impl ThreadRequestProcessor { parent_trace: request_trace, environments: Some(environments), thread_extension_init, - supports_openai_form_elicitation, + client_mcp_extensions, ..StartThreadOptions::new(config) }) .instrument(tracing::info_span!( @@ -3042,7 +3043,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -3214,7 +3215,7 @@ impl ThreadRequestProcessor { thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await { @@ -3995,7 +3996,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -4237,7 +4238,7 @@ impl ThreadRequestProcessor { prepared_fork, thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions.clone(), ) .await } else { @@ -4252,7 +4253,7 @@ impl ThreadRequestProcessor { }), thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await }; 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 09c54f9035..198f0e333d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -178,7 +178,6 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { validate_user_input_image_urls(¶ms.input)?; self.turn_start_inner( @@ -186,7 +185,6 @@ impl TurnRequestProcessor { params, app_server_client_name, app_server_client_version, - /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await .map(|response| Some(response.into())) @@ -479,7 +477,6 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, - supports_openai_form_elicitation: bool, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -506,15 +503,6 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - thread - .set_openai_form_elicitation_support(supports_openai_form_elicitation) - .await - .map_err(|err| { - internal_error(format!( - "failed to update OpenAI form elicitation support: {err}" - )) - })?; - let runtime_workspace_roots = params .runtime_workspace_roots .map(resolve_runtime_workspace_roots); diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 9df1571627..5ffdb90800 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -85,6 +85,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand request_attestation: true, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs index 8f69d20078..7465f7f339 100644 --- a/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs +++ b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs @@ -227,6 +227,9 @@ async fn existing_thread_loads_api_curated_mcp_after_auth_switch_sync() -> Resul Some(json!({ "echoed": "available after sync", "threadId": thread_id, + "clientCapabilities": { + "extensions": {}, + }, })) ); 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 07efb08a20..2e8af60f05 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -44,6 +44,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -80,6 +81,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -136,6 +138,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -175,6 +178,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -214,6 +218,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -273,6 +278,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -315,6 +321,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: None, }), ) .await?; @@ -356,6 +363,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + extensions: 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 047cd7ada2..923bb41535 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -244,6 +244,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), mcp_server_openai_form_elicitation: false, + extensions: None, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 3073076483..e14fd225c2 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -464,7 +464,8 @@ async fn initialize_websocket_client( }, capabilities: Some(InitializeCapabilities { experimental_api: true, - mcp_server_openai_form_elicitation: supports_openai_form_elicitation, + extensions: supports_openai_form_elicitation + .then(|| HashMap::from([("openai/form".to_string(), serde_json::json!({}))])), ..Default::default() }), })?), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index f110c54d11..6ac1272697 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -9,8 +9,10 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use axum::Router; use codex_app_server_protocol::CapabilityRootLocation; +use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::EnvironmentAddResponse; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::McpElicitationSchema; @@ -49,6 +51,8 @@ use rmcp::model::ContentBlock; use rmcp::model::ElicitRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::MetaObject; @@ -133,6 +137,9 @@ async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { Some(json!({ "echoed": "hello from app", "threadId": thread_id, + "clientCapabilities": { + "extensions": {}, + }, })) ); assert_eq!(response.is_error, Some(false)); @@ -149,6 +156,187 @@ async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_uses_session_client_extensions() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + mcp_tool_config(&responses_server.uri(), &mcp_server_url, AUTO_COMPACT_LIMIT) + .write(codex_home.path())?; + + let app_ui = json!({ + "mimeTypes": [ + "text/html;profile=mcp-app", + "text/x-dil;profile=mcp-app", + ], + "futureField": {"preserved": true}, + }); + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_test".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + request_attestation: false, + mcp_server_openai_form_elicitation: true, + opt_out_notification_methods: None, + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + app_ui.clone(), + )])), + }), + ) + .await?; + let ThreadStartResponse { thread, .. } = mcp + .start_thread(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_id = thread.id; + + let response: McpServerToolCallResponse = mcp + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: McpServerToolCallParams { + thread_id: thread_id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({"message": "capabilities"})), + meta: None, + }, + }) + .await?; + + assert_eq!( + response.structured_content, + Some(json!({ + "echoed": "capabilities", + "threadId": thread_id, + "clientCapabilities": { + "extensions": { + "openai/form": {}, + "io.modelcontextprotocol/ui": app_ui, + } + }, + })) + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_mcp_tool_call_uses_session_client_extensions() -> Result<()> { + let call_id = "call-session-capabilities"; + let namespace = format!("mcp__{TEST_SERVER_NAME}"); + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-capabilities"), + responses::ev_function_call_with_namespace( + call_id, + &namespace, + TEST_TOOL_NAME, + &serde_json::to_string(&json!({"message": "capabilities"}))?, + ), + responses::ev_completed("resp-capabilities"), + ]), + create_final_assistant_message_sse_response("done")?, + ]; + let responses_server = create_mock_responses_server_sequence(responses).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + mcp_tool_config(&responses_server.uri(), &mcp_server_url, AUTO_COMPACT_LIMIT) + .write(codex_home.path())?; + + let app_ui = json!({ + "mimeTypes": [ + "text/html;profile=mcp-app", + "text/x-dil;profile=mcp-app", + ], + "futureField": {"preserved": true}, + }); + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_test".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + request_attestation: false, + mcp_server_openai_form_elicitation: true, + opt_out_notification_methods: None, + extensions: Some(std::collections::HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + app_ui.clone(), + )])), + }), + ) + .await?; + let ThreadStartResponse { thread, .. } = mcp + .start_thread(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + mcp.request::(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Call the MCP tool".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + + let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?; + let ThreadItem::McpToolCall { + result: Some(result), + .. + } = completed.item + else { + panic!("expected completed MCP tool call item"); + }; + assert_eq!( + result.structured_content, + Some(json!({ + "echoed": "capabilities", + "threadId": thread.id, + "clientCapabilities": { + "extensions": { + "openai/form": {}, + "io.modelcontextprotocol/ui": app_ui, + } + }, + })) + ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + Ok(()) +} + #[tokio::test] async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { let codex_home = TempDir::new()?; @@ -746,6 +934,15 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result struct ToolAppsMcpServer; impl ServerHandler for ToolAppsMcpServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } @@ -795,6 +992,11 @@ impl ServerHandler for ToolAppsMcpServer { .get("threadId") .and_then(|value| value.as_str()) .unwrap_or_default(); + let client_capabilities = context.peer.peer_info().map(|request| { + json!({ + "extensions": request.capabilities.extensions.clone().unwrap_or_default(), + }) + }); let mut meta = MetaObject::new(); meta.0.insert("calledBy".to_string(), json!("mcp-app")); @@ -876,10 +1078,14 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into()); } - let mut result = CallToolResult::structured(json!({ + let mut structured_content = json!({ "echoed": message, "threadId": thread_id, - })); + }); + if let Some(client_capabilities) = client_capabilities { + structured_content["clientCapabilities"] = client_capabilities; + } + let mut result = CallToolResult::structured(structured_content); result.content = vec![ContentBlock::text(format!("echo: {message}"))]; result.meta = Some(meta); Ok(result.into()) 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 999b285f97..bf2db5c0f7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -152,6 +152,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), mcp_server_openai_form_elicitation: false, + extensions: None, }), ), ) diff --git a/codex-rs/codex-mcp/src/binding.rs b/codex-rs/codex-mcp/src/binding.rs index 6cec4cb393..f66e08858b 100644 --- a/codex-rs/codex-mcp/src/binding.rs +++ b/codex-rs/codex-mcp/src/binding.rs @@ -258,7 +258,7 @@ impl PreparedMcpCall { } /// Runs irreversible call preparation and execution under the authority of - /// this call's exact catalog revision. + /// this call's exact catalog revision and the extensions owned by the Codex session. #[expect( clippy::await_holding_invalid_type, reason = "catalog replacement must remain serialized with call preparation and execution" @@ -288,7 +288,7 @@ impl PreparedMcpCall { } } -fn call_tool_result_from_rmcp(result: rmcp::model::CallToolResult) -> CallToolResult { +pub(crate) fn call_tool_result_from_rmcp(result: rmcp::model::CallToolResult) -> CallToolResult { let content = result .content .into_iter() 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..0be38c3799 --- /dev/null +++ b/codex-rs/codex-mcp/src/client_capabilities.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID; +use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; +use serde_json::Map; +use serde_json::Value; + +/// Selects the MCP extensions Codex supports from those declared by the app-server host. +/// +/// App-server clients may declare unrelated extensions. Codex retains only the +/// trusted extension namespaces it knows how to project downstream. The +/// legacy form capability is normalized into the same extension map. +pub fn client_mcp_extensions( + extensions: Option<&HashMap>, + legacy_openai_form_elicitation: bool, +) -> ClientMcpExtensions { + let mut selected = extensions + .into_iter() + .flat_map(HashMap::iter) + .filter(|(id, _)| { + matches!( + id.as_str(), + OPENAI_FORM_EXTENSION_ID | MCP_APP_UI_EXTENSION_ID + ) + }) + .map(|(id, value)| (id.clone(), value.clone())) + .collect::>(); + if legacy_openai_form_elicitation { + selected + .entry(OPENAI_FORM_EXTENSION_ID.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + ClientMcpExtensions::new(selected) +} + +#[cfg(test)] +#[path = "client_capabilities_tests.rs"] +mod tests; diff --git a/codex-rs/codex-mcp/src/client_capabilities_tests.rs b/codex-rs/codex-mcp/src/client_capabilities_tests.rs new file mode 100644 index 0000000000..88e76cc522 --- /dev/null +++ b/codex-rs/codex-mcp/src/client_capabilities_tests.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; + +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn selects_only_supported_mcp_extensions() { + let app_ui = json!({ + "mimeTypes": [ + "text/html;profile=mcp-app", + "text/x-dil;profile=mcp-app", + ], + "futureField": {"preserved": true}, + }); + let extensions = HashMap::from([ + (MCP_APP_UI_EXTENSION_ID.to_string(), app_ui.clone()), + (OPENAI_FORM_EXTENSION_ID.to_string(), json!({})), + ("example/other".to_string(), json!({"enabled": true})), + ]); + + assert_eq!( + client_mcp_extensions( + Some(&extensions), + /*legacy_openai_form_elicitation*/ false, + ), + ClientMcpExtensions::new(HashMap::from([ + (MCP_APP_UI_EXTENSION_ID.to_string(), app_ui), + (OPENAI_FORM_EXTENSION_ID.to_string(), json!({})), + ])) + ); +} + +#[test] +fn normalizes_legacy_form_capability_into_extensions() { + assert_eq!( + client_mcp_extensions( + /*extensions*/ None, /*legacy_openai_form_elicitation*/ true, + ), + ClientMcpExtensions::new(HashMap::from([( + OPENAI_FORM_EXTENSION_ID.to_string(), + json!({}), + )])) + ); +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 810ab7181f..173e94053a 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -28,6 +28,7 @@ use std::sync::atomic::Ordering; use std::time::Duration; use crate::McpServerSource; +use crate::binding::call_tool_result_from_rmcp; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -180,7 +181,7 @@ impl McpConnectionSet { codex_apps_tools_cache, tool_catalog_cache, codex_apps_tools_cache_key, - supports_openai_form_elicitation, + client_mcp_extensions, auth, codex_apps_auth_manager, elicitation_reviewer, @@ -312,7 +313,7 @@ impl McpConnectionSet { shares_codex_apps_tools_cache .then(|| (codex_home.clone(), codex_apps_tools_cache_key.clone())), client_elicitation_capability.clone(), - supports_openai_form_elicitation, + client_mcp_extensions.clone(), ); let expected_protocol_mode = match &configured_config.transport { McpServerTransportConfig::StreamableHttp { .. } => Some(protocol_mode), @@ -371,7 +372,7 @@ impl McpConnectionSet { &runtime_context, environment.as_ref(), &client_elicitation_capability, - supports_openai_form_elicitation, + &client_mcp_extensions, ) } else { None @@ -392,7 +393,7 @@ impl McpConnectionSet { resolved_environment, runtime_auth_provider, client_elicitation_capability.clone(), - supports_openai_form_elicitation, + client_mcp_extensions.clone(), protocol_mode, catalog_item_limit, ); @@ -667,28 +668,13 @@ impl McpConnectionSet { .client() .await .context("failed to get client")?; - let result: rmcp::model::CallToolResult = client .client .call_tool(tool.to_string(), arguments, meta, view.tool_timeout) .await .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; - let content = result - .content - .into_iter() - .map(|content| { - serde_json::to_value(content) - .unwrap_or_else(|_| serde_json::Value::String("".to_string())) - }) - .collect(); - - Ok(CallToolResult { - content, - structured_content: result.structured_content, - is_error: result.is_error, - meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()), - }) + Ok(call_tool_result_from_rmcp(result)) } /// Returns presentation metadata from the current connection. diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 4510983bfd..b31dd8db4b 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -36,6 +36,7 @@ use codex_exec_server_test_support::environment_manager_without_environments; use codex_login::AuthHeaders; use codex_login::CodexAuth; use codex_protocol::ToolName; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; @@ -1341,7 +1342,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow: codex_apps_tools_cache, tool_catalog_cache: McpToolCatalogCache::default(), codex_apps_tools_cache_key: cache_key, - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: None, codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -1627,7 +1628,7 @@ async fn tool_catalog_cache_sanitizes_tools_and_tracks_environment_generation() &runtime_context, Some(environment), &ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + &ClientMcpExtensions::default(), ) .expect("cache context") }; @@ -1687,7 +1688,7 @@ fn tool_catalog_cache_bypasses_remote_sourced_environment_variables() { &runtime_context, /*resolved_environment*/ None, &ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + &ClientMcpExtensions::default(), ) .is_none() ); @@ -1971,7 +1972,7 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets() &runtime_context, /*resolved_environment*/ None, &ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + &ClientMcpExtensions::default(), ) .expect("shared pending MCP catalog"); @@ -2995,7 +2996,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - Some(&hosted_auth), /*codex_apps_cache_identity*/ None, ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) }; let direct_keyring_identity = connection_identity(AuthKeyringBackendKind::Direct); @@ -3046,7 +3047,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: Some(hosted_auth.clone()), codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -3163,7 +3164,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: None, codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -3422,7 +3423,7 @@ fn reusable_server_identity( /*auth*/ None, /*codex_apps_cache_identity*/ None, ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) } @@ -3484,7 +3485,7 @@ async fn reconcile_reusable_server( codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: None, codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -3705,7 +3706,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() { codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: None, codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -3762,7 +3763,7 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: None, codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -3948,7 +3949,7 @@ async fn connection_identity_distinguishes_accounts_with_the_same_token() -> any Some(auth), /*codex_apps_cache_identity*/ None, ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) }; @@ -3999,7 +4000,7 @@ async fn connection_identity_distinguishes_agent_account_runtime_and_task() -> a Some(auth), /*codex_apps_cache_identity*/ None, ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) }; let previous_identity = connection_identity(&previous_auth); diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index ecc9cf7d73..43fb01387e 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,5 +1,6 @@ pub use binding::McpBinding; pub use binding::PreparedMcpCall; +pub use client_capabilities::client_mcp_extensions; pub use codex_rmcp_client::McpProtocolMode; pub use connection_manager::tool_is_model_visible; pub use elicitation::ElicitationLifecycle; @@ -86,6 +87,7 @@ pub(crate) mod auth_elicitation; mod binding; pub(crate) mod binding_clients; mod catalog; +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 51fc4f3ce7..a8575d8c74 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -32,6 +32,7 @@ use codex_connectors::ConnectorSnapshot; use codex_connectors::connector_runtime_context_key; use codex_login::CodexAuth; use codex_model_provider::CHATGPT_CODEX_BASE_URL; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; @@ -342,7 +343,7 @@ pub async fn read_mcp_resource( codex_apps_tools_cache, tool_catalog_cache, codex_apps_tools_cache_key: connector_runtime_context_key(auth), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.cloned(), codex_apps_auth_manager: None, elicitation_reviewer: None, @@ -419,7 +420,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( codex_apps_tools_cache, tool_catalog_cache, codex_apps_tools_cache_key: connector_runtime_context_key(auth), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.cloned(), codex_apps_auth_manager: None, elicitation_reviewer: None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3a0e81984e..ae831c1026 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -48,6 +48,7 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_connectors::ConnectorRuntimeContext; use codex_connectors::ConnectorRuntimeFetchSource; use codex_exec_server::Environment; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -67,7 +68,6 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; -use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio::time::Instant as TokioInstant; @@ -83,8 +83,6 @@ pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; /// not use it. Its `cacheable: false` property disables sharing tool definitions across connections. const MCP_TOOL_CATALOG_CACHE_CAPABILITY: &str = "codex/tool-catalog-cache"; const MCP_TOOL_CATALOG_CACHEABLE_PROPERTY: &str = "cacheable"; -pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; - pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; @@ -283,7 +281,7 @@ struct ManagedClientStartup { resolved_environment: std::result::Result>, String>, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, protocol_mode: McpProtocolMode, catalog_item_limit: usize, cancel_token: CancellationToken, @@ -305,7 +303,7 @@ impl ManagedClientStartup { resolved_environment, runtime_auth_provider, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, protocol_mode, catalog_item_limit, cancel_token, @@ -361,7 +359,7 @@ impl ManagedClientStartup { tool_catalog_cache_context, tool_catalog_fetch_ticket, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, catalog_item_limit, }, ) @@ -424,7 +422,7 @@ impl AsyncManagedClient { resolved_environment: std::result::Result>, String>, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, protocol_mode: McpProtocolMode, catalog_item_limit: usize, ) -> Self { @@ -452,7 +450,7 @@ impl AsyncManagedClient { resolved_environment, runtime_auth_provider, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, protocol_mode, catalog_item_limit, cancel_token: cancel_token.clone(), @@ -855,13 +853,11 @@ async fn start_server_task( tool_catalog_cache_context, tool_catalog_fetch_ticket, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, catalog_item_limit, } = params; - let params = mcp_initialize_request_params( - client_elicitation_capability, - supports_openai_form_elicitation, - ); + let params = + mcp_initialize_request_params(client_elicitation_capability, client_mcp_extensions); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); let initialize_result = client @@ -943,15 +939,21 @@ async fn start_server_task( fn mcp_initialize_request_params( client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); capabilities.elicitation = Some(client_elicitation_capability); - if supports_openai_form_elicitation { - capabilities.extensions = Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])); + let extensions = client_mcp_extensions + .iter() + .filter_map(|(id, settings)| { + settings + .as_object() + .cloned() + .map(|settings| (id.to_string(), settings)) + }) + .collect::>(); + if !extensions.is_empty() { + capabilities.extensions = Some(extensions); } InitializeRequestParams::new( capabilities, @@ -989,7 +991,7 @@ struct StartServerTaskParams { tool_catalog_cache_context: Option, tool_catalog_fetch_ticket: Option, client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, catalog_item_limit: usize, } @@ -1103,6 +1105,8 @@ async fn make_rmcp_client( #[cfg(test)] mod tests { use super::*; + use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID; + use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::MetaObject; @@ -1157,23 +1161,33 @@ mod tests { } #[test] - fn mcp_initialize_advertises_openai_form_only_when_supported() { + fn mcp_initialize_advertises_client_extensions() { let unsupported = mcp_initialize_request_params( ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ); assert_eq!(unsupported.capabilities.extensions, None); + let app_ui = serde_json::json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + "futureField": {"preserved": true}, + }); let supported = mcp_initialize_request_params( ElicitationCapability::default(), - /*supports_openai_form_elicitation*/ true, + ClientMcpExtensions::new([ + (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({})), + (MCP_APP_UI_EXTENSION_ID.to_string(), app_ui.clone()), + ]), ); assert_eq!( supported.capabilities.extensions, - Some(BTreeMap::from([( - OPENAI_FORM_CAPABILITY.to_string(), - JsonObject::new(), - )])) + Some(BTreeMap::from([ + (OPENAI_FORM_EXTENSION_ID.to_string(), JsonObject::new()), + ( + MCP_APP_UI_EXTENSION_ID.to_string(), + app_ui.as_object().cloned().expect("app UI settings"), + ), + ])) ); } diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 4d1746cf26..33debec79d 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -24,6 +24,7 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Event; use codex_rmcp_client::ElicitationResponse; @@ -59,7 +60,7 @@ pub struct McpRuntimeInput { pub codex_apps_tools_cache: ConnectorRuntimeManager, pub tool_catalog_cache: McpToolCatalogCache, pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey, - pub supports_openai_form_elicitation: bool, + pub client_mcp_extensions: ClientMcpExtensions, pub auth: Option, pub codex_apps_auth_manager: Option>, pub elicitation_reviewer: Option, diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index c7abf92e20..c00e786c06 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -14,6 +14,7 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_connectors::ConnectorRuntimeContextKey; use codex_exec_server::Environment; use codex_login::CodexAuth; +use codex_protocol::mcp::ClientMcpExtensions; use codex_rmcp_client::StoredOAuthTokens; use codex_rmcp_client::stored_oauth_credentials; use rmcp::model::ElicitationCapability; @@ -90,7 +91,7 @@ pub(crate) struct McpServerConnectionIdentity { runtime_auth_token: Option, codex_apps_cache_identity: Option<(PathBuf, ConnectorRuntimeContextKey)>, client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, } impl McpServerConnectionIdentity { @@ -106,7 +107,7 @@ impl McpServerConnectionIdentity { auth: Option<&CodexAuth>, codex_apps_cache_identity: Option<(PathBuf, ConnectorRuntimeContextKey)>, client_elicitation_capability: ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> Self { let config = server.config(); let stored_oauth_url = if runtime_auth_provider.is_none() @@ -163,7 +164,7 @@ impl McpServerConnectionIdentity { runtime_auth_token, codex_apps_cache_identity, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, } } @@ -191,7 +192,7 @@ impl McpServerConnectionIdentity { && self.runtime_auth_token == other.runtime_auth_token && self.codex_apps_cache_identity == other.codex_apps_cache_identity && self.client_elicitation_capability == other.client_elicitation_capability - && self.supports_openai_form_elicitation == other.supports_openai_form_elicitation + && self.client_mcp_extensions == other.client_mcp_extensions } pub(crate) fn oauth_credentials(&self) -> Result<&Option, &String> { diff --git a/codex-rs/codex-mcp/src/tool_catalog_cache.rs b/codex-rs/codex-mcp/src/tool_catalog_cache.rs index 76062e388d..3cd4f6004c 100644 --- a/codex-rs/codex-mcp/src/tool_catalog_cache.rs +++ b/codex-rs/codex-mcp/src/tool_catalog_cache.rs @@ -15,6 +15,7 @@ use std::time::Duration; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_exec_server::Environment; +use codex_protocol::mcp::ClientMcpExtensions; use lru::LruCache; use rmcp::model::ElicitationCapability; use sha1::Digest; @@ -78,7 +79,7 @@ impl McpToolCatalogCache { runtime_context: &McpRuntimeContext, resolved_environment: Option<&Arc>, client_elicitation_capability: &ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: &ClientMcpExtensions, ) -> Option { let identity = ToolCatalogIdentity::new( server_name, @@ -86,7 +87,7 @@ impl McpToolCatalogCache { runtime_context, resolved_environment, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, )?; let entry = lock_unpoisoned(&self.entries) .get_or_insert(identity, || Arc::new(ToolCatalogCacheEntry::default())) @@ -218,12 +219,12 @@ impl ToolCatalogIdentity { runtime_context: &McpRuntimeContext, environment: Option<&Arc>, client_elicitation_capability: &ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: &ClientMcpExtensions, ) -> Option { let transport = ToolCatalogTransportIdentity::new( config, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions, )?; Some(Self { server_name: server_name.to_string(), @@ -247,7 +248,7 @@ impl ToolCatalogTransportIdentity { fn new( config: &McpServerConfig, client_elicitation_capability: &ElicitationCapability, - supports_openai_form_elicitation: bool, + client_mcp_extensions: &ClientMcpExtensions, ) -> Option { let McpServerTransportConfig::Stdio { command, @@ -282,7 +283,7 @@ impl ToolCatalogTransportIdentity { cwd, &config.environment_id, client_elicitation_capability, - supports_openai_form_elicitation, + client_mcp_extensions.iter().collect::>(), )) .ok()?, ); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ce6c698579..35f289b46b 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -132,10 +132,7 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, environment_selections: parent_ctx.environments.to_selections(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), - supports_openai_form_elicitation: parent_session - .services - .supports_openai_form_elicitation - .load(std::sync::atomic::Ordering::Relaxed), + client_mcp_extensions: parent_session.services.client_mcp_extensions.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 5bb0228bb4..0fb65079a6 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -406,12 +406,6 @@ impl CodexThread { .await } - pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> { - self.session - .set_openai_form_elicitation_support(supported) - .await - } - /// Preview persistent thread settings overrides without committing them. pub async fn preview_thread_settings_overrides( &self, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 8b1a01d61b..e543f2ae38 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -39,6 +39,7 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::effective_mcp_servers; use codex_mcp::tool_plugin_provenance; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::PermissionProfile; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -259,7 +260,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( codex_apps_tools_cache: mcp_manager.codex_apps_tools_cache(), tool_catalog_cache: mcp_manager.tool_catalog_cache(), codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.clone(), codex_apps_auth_manager, elicitation_reviewer: None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 042a7014e5..ca9ac79923 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -558,26 +558,6 @@ impl Session { .await } - pub(crate) async fn set_openai_form_elicitation_support( - &self, - supported: bool, - ) -> anyhow::Result<()> { - if self - .services - .supports_openai_form_elicitation - .load(std::sync::atomic::Ordering::Relaxed) - == supported - { - return Ok(()); - } - - self.services - .supports_openai_form_elicitation - .store(supported, std::sync::atomic::Ordering::Relaxed); - self.request_mcp_runtime_refresh(); - Ok(()) - } - pub(crate) async fn refresh_mcp_servers_now( &self, turn_context: &TurnContext, diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index f51bdbbde3..b2dd2dc61b 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -138,10 +138,6 @@ impl Session { elicitation_reviewer: Option, ) -> McpRuntimeInput { let auth = desired.auth.clone(); - let supports_openai_form_elicitation = self - .services - .supports_openai_form_elicitation - .load(std::sync::atomic::Ordering::Acquire); let McpRuntimeProjection { mut config, plugins_available, @@ -186,7 +182,7 @@ impl Session { codex_apps_tools_cache: self.services.mcp_manager.codex_apps_tools_cache(), tool_catalog_cache: self.services.mcp_manager.tool_catalog_cache(), codex_apps_tools_cache_key: connector_runtime_context_key(auth.as_ref()), - supports_openai_form_elicitation, + client_mcp_extensions: self.services.client_mcp_extensions.clone(), auth, codex_apps_auth_manager, elicitation_reviewer, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f515fa2787..9db57e9da7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -340,6 +340,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::LocalImagePreparation; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -448,7 +449,7 @@ pub(crate) struct SessionSpawnArgs { pub(crate) parent_trace: Option, pub(crate) environment_selections: Vec, pub(crate) thread_extension_init: ExtensionDataInit, - pub(crate) supports_openai_form_elicitation: bool, + pub(crate) client_mcp_extensions: ClientMcpExtensions, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, @@ -540,7 +541,7 @@ impl Session { parent_trace: _, environment_selections, thread_extension_init, - supports_openai_form_elicitation, + client_mcp_extensions, analytics_events_client, thread_store, attestation_provider, @@ -731,7 +732,7 @@ impl Session { code_mode_session_provider, extensions, thread_extension_init, - supports_openai_form_elicitation, + client_mcp_extensions, agent_control, environment_manager, inherited_environments, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e6f5627920..bb1154713b 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -17,6 +17,7 @@ use codex_protocol::SessionId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::MultiAgentVersion; @@ -515,7 +516,7 @@ impl Session { code_mode_session_provider: Arc, extensions: Arc>, mut thread_extension_init: ExtensionDataInit, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, agent_control: AgentControl, environment_manager: Arc, inherited_environments: Option, @@ -1126,9 +1127,7 @@ impl Session { thread_extension_data, selected_capability_roots, mcp_thread_init, - supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new( - supports_openai_form_elicitation, - ), + client_mcp_extensions, agent_control, network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), network_proxy_audit_metadata, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9a21b6a1a2..4bf68896ee 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5546,7 +5546,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5742,7 +5742,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), selected_capability_roots: Vec::new(), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), - supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), + client_mcp_extensions: ClientMcpExtensions::default(), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), @@ -5956,7 +5956,7 @@ async fn make_session_with_config_and_rx( Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -6066,7 +6066,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), agent_control, environment_manager, /*inherited_environments*/ None, @@ -7936,7 +7936,7 @@ where thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), selected_capability_roots: Vec::new(), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), - supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), + client_mcp_extensions: ClientMcpExtensions::default(), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 1bbd11a808..e0c34e1b16 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -765,7 +765,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { parent_trace: None, environment_selections: Vec::new(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), analytics_events_client: None, thread_store, attestation_provider: None, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 6169fe074c..f62b77b32d 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use crate::SkillsService; use crate::agent::AgentControl; @@ -35,6 +34,7 @@ use codex_mcp::McpRuntime; use codex_models_manager::manager::SharedModelsManager; use codex_otel::SessionTelemetry; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::mcp::ClientMcpExtensions; use codex_rollout::state_db::StateDbHandle; use codex_rollout_trace::ThreadTraceContext; use codex_thread_store::LiveThread; @@ -72,7 +72,8 @@ pub(crate) struct SessionServices { pub(crate) extensions: Arc>, pub(crate) session_extension_data: ExtensionData, pub(crate) thread_extension_data: ExtensionData, - pub(crate) supports_openai_form_elicitation: AtomicBool, + /// MCP extensions fixed when this session is created. + pub(crate) client_mcp_extensions: ClientMcpExtensions, /// Raw capability selections for this thread. Each model step resolves them against its /// current executor environments before using them. pub(crate) selected_capability_roots: Vec, diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 273311e9d6..47427773eb 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -24,6 +24,8 @@ use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::SessionSource; @@ -112,7 +114,10 @@ pub async fn start_thread_with_user_shell_override( .start_thread_with_user_shell_override_for_tests( config, user_shell_override, - supports_openai_form_elicitation, + ClientMcpExtensions::new( + supports_openai_form_elicitation + .then(|| (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))), + ), ) .await } @@ -131,7 +136,10 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path, auth_manager, user_shell_override, - supports_openai_form_elicitation, + ClientMcpExtensions::new( + supports_openai_form_elicitation + .then(|| (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))), + ), ) .await } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index c636ace7cd..dffdb605bd 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -48,6 +48,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -210,7 +211,7 @@ pub struct StartThreadOptions { pub parent_trace: Option, pub environments: Option>, pub thread_extension_init: ExtensionDataInit, - pub supports_openai_form_elicitation: bool, + pub client_mcp_extensions: ClientMcpExtensions, } impl StartThreadOptions { @@ -227,7 +228,7 @@ impl StartThreadOptions { parent_trace: None, environments: None, thread_extension_init: ExtensionDataInit::default(), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), } } } @@ -873,7 +874,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, parent_trace: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; Box::pin(self.resume_thread_with_history( @@ -881,7 +882,7 @@ impl ThreadManager { initial_history, auth_manager, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, )) .await } @@ -893,7 +894,7 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let (session_source, thread_source) = initial_history @@ -912,7 +913,7 @@ impl ThreadManager { session_source: Some(session_source), thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, ..StartThreadOptions::new(config) }; Box::pin(self.state.spawn_thread(ThreadSpawnRequest::new( @@ -927,11 +928,11 @@ impl ThreadManager { &self, config: Config, user_shell_override: crate::shell::Shell, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let options = StartThreadOptions { - supports_openai_form_elicitation, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = @@ -946,7 +947,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; @@ -957,7 +958,7 @@ impl ThreadManager { initial_history, session_source: Some(session_source), thread_source, - supports_openai_form_elicitation, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = ThreadSpawnRequest::new(options, auth_manager, agent_control); @@ -1046,7 +1047,7 @@ impl ThreadManager { history, thread_source, parent_trace, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await } @@ -1077,7 +1078,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult where S: Into, @@ -1091,7 +1092,7 @@ impl ThreadManager { }, thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await } @@ -1103,7 +1104,7 @@ impl ThreadManager { prepared: PreparedFork, thread_source: Option, parent_trace: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let history = InitialHistory::Resumed(ResumedHistory { conversation_id: prepared.source_thread_id, @@ -1124,7 +1125,7 @@ impl ThreadManager { }, thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, ) .await; drop(prepared); @@ -1137,7 +1138,7 @@ impl ThreadManager { fork_history: ForkHistory, thread_source: Option, parent_trace: Option, - supports_openai_form_elicitation: bool, + client_mcp_extensions: ClientMcpExtensions, ) -> CodexResult { let ForkHistory { snapshot, @@ -1169,7 +1170,7 @@ impl ThreadManager { initial_history: history, thread_source, parent_trace, - supports_openai_form_elicitation, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = @@ -1504,12 +1505,14 @@ impl ThreadManagerState { inherited_exec_policy: Option>, environments: Option>, ) -> CodexResult { + let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await; let options = StartThreadOptions { history_mode, session_source: Some(session_source), thread_source, metrics_service_name, environments, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = @@ -1534,11 +1537,13 @@ impl ThreadManagerState { inherited_environments, inherited_exec_policy, } = options; + let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await; let thread_source = initial_history.get_resumed_thread_source(); let options = StartThreadOptions { initial_history, session_source: Some(session_source), thread_source, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = @@ -1565,6 +1570,7 @@ impl ThreadManagerState { environments: Option>, thread_extension_init: ExtensionDataInit, ) -> CodexResult { + let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await; let options = StartThreadOptions { initial_history, history_mode, @@ -1572,6 +1578,7 @@ impl ThreadManagerState { thread_source, environments, thread_extension_init, + client_mcp_extensions, ..StartThreadOptions::new(config) }; let mut request = @@ -1583,6 +1590,19 @@ impl ThreadManagerState { Box::pin(self.spawn_thread(request)).await } + async fn client_mcp_extensions_for_child( + &self, + parent_thread_id: Option, + ) -> ClientMcpExtensions { + let Some(parent_thread_id) = parent_thread_id else { + return ClientMcpExtensions::default(); + }; + self.get_thread(parent_thread_id) + .await + .map(|parent| parent.session.services.client_mcp_extensions.clone()) + .unwrap_or_default() + } + /// Spawn a new thread with optional history and register it with the manager. async fn spawn_thread(&self, request: ThreadSpawnRequest) -> CodexResult { let ThreadSpawnRequest { @@ -1608,7 +1628,7 @@ impl ThreadManagerState { parent_trace, environments, thread_extension_init, - supports_openai_form_elicitation, + client_mcp_extensions, } = options; let session_source = session_source.unwrap_or_else(|| self.session_source.clone()); let environments = environments.unwrap_or_else(|| { @@ -1704,7 +1724,7 @@ impl ThreadManagerState { parent_trace, environment_selections: environments, thread_extension_init, - supports_openai_form_elicitation, + client_mcp_extensions, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d467a12d7f..c1e4b49dec 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -13,6 +13,9 @@ use codex_models_manager::manager::RefreshStrategy; use codex_protocol::ResponseItemId; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID; +use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; @@ -39,6 +42,52 @@ use wiremock::MockServer; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +#[tokio::test] +async fn child_session_inherits_client_mcp_extensions() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let parent = manager + .start_thread(StartThreadOptions { + client_mcp_extensions: ClientMcpExtensions::new(HashMap::from([ + (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({})), + ( + MCP_APP_UI_EXTENSION_ID.to_string(), + serde_json::json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + ), + ])), + ..StartThreadOptions::new(config) + }) + .await + .expect("start parent thread"); + + assert_eq!( + manager + .state + .client_mcp_extensions_for_child(Some(parent.thread_id)) + .await, + ClientMcpExtensions::new(HashMap::from([ + (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({})), + ( + MCP_APP_UI_EXTENSION_ID.to_string(), + serde_json::json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + ), + ])) + ); +} + struct FakeAgentGraphStore { root_thread_id: ThreadId, descendant_thread_ids: Vec, @@ -960,7 +1009,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume source thread"); @@ -1117,7 +1166,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { rollout_path, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume active source thread"); @@ -1184,7 +1233,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { rollout_path, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume stopped source thread"); @@ -1258,7 +1307,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { rollout_path, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume source thread"); @@ -1384,7 +1433,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { }), auth_manager.clone(), /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("seed rollout path in store"); @@ -1401,7 +1450,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume from rollout path"); @@ -1772,7 +1821,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ]), auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("create source thread from completed history"); @@ -1892,7 +1941,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ]), auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("create source thread from explicit partial history"); @@ -1988,7 +2037,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ]), auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("create source thread from partial history"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d322e02330..bbb26bde6c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -30,6 +30,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -2730,7 +2731,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("start thread"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1118591ac1..762c84832e 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -36,6 +36,8 @@ use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; +use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -674,6 +676,12 @@ impl TestCodexBuilder { }; let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); + let client_mcp_extensions = || { + ClientMcpExtensions::new( + self.supports_openai_form_elicitation + .then(|| (OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))), + ) + }; let new_conversation = match (resume_from, user_shell_override) { (Some(path), Some(user_shell_override)) => { @@ -697,7 +705,7 @@ impl TestCodexBuilder { path, auth_manager, /*parent_trace*/ None, - self.supports_openai_form_elicitation, + client_mcp_extensions(), )) .await? } @@ -715,7 +723,7 @@ impl TestCodexBuilder { (None, None) => { Box::pin(thread_manager.start_thread(StartThreadOptions { history_mode: self.history_mode, - supports_openai_form_elicitation: self.supports_openai_form_elicitation, + client_mcp_extensions: client_mcp_extensions(), ..StartThreadOptions::new(config.clone()) })) .await? diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 3270a922b0..6de4596f09 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -17,6 +17,7 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::WarningEvent; @@ -830,7 +831,7 @@ async fn resume_conversation( path, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), )) .await .expect("resume conversation") diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 56374d0de6..140af23c3f 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -5,6 +5,7 @@ use codex_core::NewThread; use codex_core::ThreadConfigSnapshot; use codex_core::parse_turn_item; use codex_protocol::items::TurnItem; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; @@ -238,7 +239,7 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto }), /*thread_source*/ None, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("fork from stored history"); @@ -274,7 +275,7 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto codex_login::CodexAuth::from_api_key("dummy"), ), /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume copied paginated fork") diff --git a/codex-rs/core/tests/suite/mcp_auth_refresh.rs b/codex-rs/core/tests/suite/mcp_auth_refresh.rs index 931407f9aa..83386949f3 100644 --- a/codex-rs/core/tests/suite/mcp_auth_refresh.rs +++ b/codex-rs/core/tests/suite/mcp_auth_refresh.rs @@ -17,6 +17,7 @@ use codex_mcp::McpRuntime; use codex_mcp::McpRuntimeContext; use codex_mcp::McpRuntimeInput; use codex_mcp::McpToolCatalogCache; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::AskForApproval; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::start_mock_server; @@ -102,7 +103,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token( codex_apps_tools_cache: CodexAppsToolsCache::default(), tool_catalog_cache: McpToolCatalogCache::default(), codex_apps_tools_cache_key: codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)), - supports_openai_form_elicitation: false, + client_mcp_extensions: ClientMcpExtensions::default(), auth: Some(expected_auth.clone()), codex_apps_auth_manager: Some(Arc::clone(&auth_manager)), elicitation_reviewer: None, diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index 3cbb6e9ca7..6e95b930f2 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -6,6 +6,7 @@ use std::time::Duration; use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_protocol::protocol::Op; use core_test_support::process::process_is_alive; use core_test_support::process::wait_for_pid_file; use core_test_support::process::wait_for_process_exit; @@ -112,10 +113,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh ]), ) .await; - fixture - .codex - .set_openai_form_elicitation_support(/*supported*/ true) - .await?; + fixture.codex.submit(Op::RefreshMcpServers).await?; fixture.submit_turn("refresh MCP servers").await?; let replacement_pid = wait_for_pid_file(&pid_file).await?; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 20f1868f87..19041c5acb 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -6,6 +6,7 @@ use codex_core::test_support::auth_manager_from_auth; use codex_login::CodexAuth; use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_protocol::ThreadId; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; @@ -2726,7 +2727,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge InitialHistory::Forked(history), auth_manager_from_auth(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await?; let codex = resumed_thread.thread; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 6cd3e8f452..876b65c437 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -7,6 +7,7 @@ use codex_login::CodexAuth; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::ResumedHistory; @@ -116,7 +117,7 @@ async fn emits_warning_when_resumed_model_differs() { initial_history, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("resume conversation"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index d965a60987..9de72267fb 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -426,58 +426,6 @@ async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result assert_openai_form_capability_advertisement(/*expected*/ false).await } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> { - skip_if_wine_exec!( - Ok(()), - "requires a Windows test_stdio_server in the Wine-exec environment" - ); - - let server = start_mock_server().await; - let server_name = "capabilities"; - let command = stdio_server_bin()?; - let fixture = test_codex() - .with_config(move |config| { - insert_mcp_server( - config, - server_name, - stdio_transport(command, /*env*/ None, Vec::new()), - TestMcpServerOptions::default(), - ); - }) - .build(&server) - .await?; - wait_for_mcp_server(&fixture.codex, server_name).await?; - - let unsupported = call_structured_tool( - &server, - &fixture, - server_name, - "client_capabilities", - "call-client-capabilities-unsupported", - ) - .await?; - assert_eq!( - unsupported, - json!({ "supportsOpenaiFormElicitation": false }) - ); - - fixture - .codex - .set_openai_form_elicitation_support(/*supported*/ true) - .await?; - let supported = call_structured_tool( - &server, - &fixture, - server_name, - "client_capabilities", - "call-client-capabilities-supported", - ) - .await?; - assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true })); - Ok(()) -} - async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> { skip_if_wine_exec!( Ok(()), diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index 812424a80c..c57b503281 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -4,6 +4,7 @@ use codex_config::CONFIG_TOML_FILE; use codex_core::NewThread; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::WarningEvent; @@ -50,7 +51,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { InitialHistory::New, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("spawn conversation"); @@ -100,7 +101,7 @@ async fn suppresses_warning_when_configured() { InitialHistory::New, auth_manager, /*parent_trace*/ None, - /*supports_openai_form_elicitation*/ false, + ClientMcpExtensions::default(), ) .await .expect("spawn conversation"); diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index a1916d424c..f6aea6fea8 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -6,8 +6,46 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; use ts_rs::TS; +/// Extension ID for OpenAI form elicitation. +pub const OPENAI_FORM_EXTENSION_ID: &str = "openai/form"; +/// Extension ID for MCP App UI rendering. +pub const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; + +/// MCP extensions supplied by the client that created a Codex session. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ClientMcpExtensions { + extensions: HashMap, +} + +impl ClientMcpExtensions { + /// Creates a session extension set from trusted client declarations. + pub fn new(extensions: impl IntoIterator) -> Self { + Self { + extensions: extensions.into_iter().collect(), + } + } + + /// Returns whether the client declared the given extension. + pub fn contains(&self, extension_id: &str) -> bool { + self.extensions.contains_key(extension_id) + } + + /// Returns the client's settings for the given extension. + pub fn get(&self, extension_id: &str) -> Option<&serde_json::Value> { + self.extensions.get(extension_id) + } + + /// Iterates over the extensions and their settings. + pub fn iter(&self) -> impl Iterator { + self.extensions + .iter() + .map(|(id, settings)| (id.as_str(), settings)) + } +} + /// 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)]