diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e88e670154..a8558fc127 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -22,6 +22,18 @@ late proofs after the approval is canceled or resolved. Only one native worker runs per app-server; if an OS call remains active after cancellation or timeout, subsequent local operations return `failed/providerError` until that worker exits. +# Hosted Codex Apps MCP protocol + +The host-owned HTTP `codex_apps` server uses Legacy by default in app-server and +standalone Codex. To discover the 2026-07-28 protocol, set +`codex_apps_mcp_2026_07_28 = true` under `[features]`, or send a true runtime +override via `experimentalFeature/enablement/set`. Discovery falls back to Legacy +when the server does not support it. Explicit config takes precedence. +The dedicated setting does not apply to third-party HTTP or local `codex_app` +stdio servers. The existing `mcp_2026_07_28` flag still governs eligible other +servers, regardless of whether their names or URLs resemble hosted Apps. +App-server does not persist this selection. + # Thread removal `thread/archive` and `thread/delete` reject attempts to remove a live internal diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index df9c503101..bff9b0db39 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -65,6 +65,7 @@ const BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE: &str = const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "auth_elicitation", BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE, + "codex_apps_mcp_2026_07_28", "mcp_2026_07_28", "memories", "mentions_v2", diff --git a/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs b/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs new file mode 100644 index 0000000000..094ed3a9fa --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs @@ -0,0 +1,128 @@ +//! Verifies that a hosted Apps protocol opt-in refreshes an existing app-server thread. + +use std::collections::BTreeMap; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_config::types::AuthCredentialsStoreMode; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn enabling_hosted_apps_protocol_refreshes_an_existing_thread() -> Result<()> { + let server = responses::start_mock_server().await; + let apps = AppsTestServer::mount(&server).await?; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_root_config(&format!("chatgpt_base_url = \"{}\"", apps.chatgpt_base_url)) + .enable_feature(Feature::Apps) + .write(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let thread_id = app_server + .start_thread(ThreadStartParams::default()) + .await? + .thread + .id; + + let call_tool = |thread_id: String| McpServerToolCallParams { + thread_id, + server: "codex_apps".to_string(), + tool: "calendar_list_events".to_string(), + arguments: Some(json!({ "query": "hello" })), + meta: None, + }; + let _: McpServerToolCallResponse = app_server + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: call_tool(thread_id.clone()), + }) + .await?; + let methods = server + .received_requests() + .await + .expect("mock server should capture Apps MCP startup requests") + .into_iter() + .filter(|request| request.url.path() == "/api/codex/ps/mcp") + .filter_map(|request| { + let body: Value = serde_json::from_slice(&request.body).ok()?; + body.get("method")?.as_str().map(str::to_string) + }) + .collect::>(); + assert_eq!( + methods, + [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call" + ] + ); + + let enablement = BTreeMap::from([("codex_apps_mcp_2026_07_28".to_string(), true)]); + let request_id = app_server + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: enablement.clone(), + }) + .await?; + let response: ExperimentalFeatureEnablementSetResponse = + app_server.read_response(request_id).await?; + assert_eq!(response.enablement, enablement); + + let _: McpServerToolCallResponse = app_server + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: call_tool(thread_id), + }) + .await?; + let methods = server + .received_requests() + .await + .expect("mock server should capture Apps MCP startup requests") + .into_iter() + .filter(|request| request.url.path() == "/api/codex/ps/mcp") + .filter_map(|request| { + let body: Value = serde_json::from_slice(&request.body).ok()?; + body.get("method")?.as_str().map(str::to_string) + }) + .collect::>(); + assert_eq!( + methods, + [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + "server/discover", + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + ] + ); + Ok(()) +} 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 f2fd4e7c5e..8c884a61dc 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -1341,9 +1341,9 @@ impl ServerHandler for ToolAppsMcpServer { .get("threadId") .and_then(|value| value.as_str()) .unwrap_or_default(); - let client_capabilities = context.peer.peer_info().map(|request| { + let client_capabilities = context.client_capabilities().map(|capabilities| { json!({ - "extensions": request.capabilities.extensions.clone().unwrap_or_default(), + "extensions": capabilities.extensions.unwrap_or_default(), }) }); diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 654f9e78df..740f82d0f5 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -53,6 +53,7 @@ mod marketplace_add; mod marketplace_remove; mod marketplace_upgrade; mod mcp_event_stream; +mod mcp_protocol_default; mod mcp_resource; mod mcp_resource_origin; mod mcp_server_elicitation; diff --git a/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs b/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs index ba5af17f29..3d79a64710 100644 --- a/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs @@ -83,9 +83,8 @@ impl ServerHandler for VerificationServer { ) -> Result { assert_eq!( context - .peer - .peer_info() - .and_then(|info| info.capabilities.extensions.clone()) + .client_capabilities() + .and_then(|capabilities| capabilities.extensions) .and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID).cloned()) .map(Value::Object), Some(json!({"userVerification": {}})), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index b44e549507..b95a678134 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -160,6 +160,7 @@ impl Drop for McpServerConnection { #[derive(Clone)] struct McpServerView { connection: Arc, + protocol_mode: crate::McpProtocolMode, metadata: McpServerMetadata, tool_filter: ToolFilter, tool_timeout: Option, @@ -186,7 +187,6 @@ pub(crate) struct McpConnectionSet { servers: HashMap, pub(crate) event_stream_connection: Option>, disabled_servers: Vec, - protocol_mode: crate::McpProtocolMode, required_servers: Vec, optional_startup_deadline: OnceLock, tool_plugin_provenance: Arc, @@ -231,7 +231,8 @@ impl McpConnectionSet { let codex_home = config.codex_home.clone(); let prefix_mcp_tool_names = config.prefix_mcp_tool_names; let non_prefixed_mcp_tool_servers = config.non_prefixed_mcp_tool_servers.clone(); - let protocol_mode = config.protocol_mode; + let default_protocol_mode = config.protocol_mode; + let host_owned_apps_protocol_mode = config.host_owned_apps_protocol_mode; let client_elicitation_capability = config.client_elicitation_capability.clone(); let tool_plugin_provenance = crate::mcp::tool_plugin_provenance(&config); let auth = auth.as_ref(); @@ -313,6 +314,15 @@ impl McpConnectionSet { }; let metadata = McpServerMetadata::from(&server); let configured_config = server.config().clone(); + let protocol_mode = if is_host_owned_codex_apps + && matches!( + &configured_config.transport, + McpServerTransportConfig::StreamableHttp { .. } + ) { + host_owned_apps_protocol_mode + } else { + default_protocol_mode + }; let configured_tool_filter = ToolFilter::from_config(&configured_config); let startup_timeout = configured_config .startup_timeout_sec @@ -445,13 +455,11 @@ impl McpConnectionSet { && !connection.client.cancel_token.is_cancelled() && previous_view.catalog_item_limit == catalog_item_limit && expected_protocol_mode.is_some() - && reusable_previous - .is_some_and(|previous| previous.protocol_mode == protocol_mode); + && previous_view.protocol_mode == protocol_mode; let unchanged_auth_failure = if connection.identity.as_ref() == Some(&connection_identity) && connection_identity.oauth_store_was_contended - && reusable_previous - .is_some_and(|previous| previous.protocol_mode == protocol_mode) + && previous_view.protocol_mode == protocol_mode && connection.client.startup_complete.load(Ordering::Acquire) { connection @@ -480,6 +488,7 @@ impl McpConnectionSet { server_name.clone(), McpServerView { connection, + protocol_mode, metadata, tool_filter: configured_tool_filter, tool_timeout: configured_tool_timeout, @@ -614,6 +623,7 @@ impl McpConnectionSet { startup_trigger, _diagnostics_guard: LIVE_CONNECTIONS.track(), }), + protocol_mode, metadata, tool_filter: configured_tool_filter, tool_timeout: configured_tool_timeout, @@ -738,7 +748,6 @@ impl McpConnectionSet { servers, event_stream_connection, disabled_servers, - protocol_mode, required_servers, optional_startup_deadline: OnceLock::new(), tool_plugin_provenance, @@ -797,7 +806,6 @@ impl McpConnectionSet { servers: HashMap::new(), event_stream_connection: None, disabled_servers: Vec::new(), - protocol_mode: crate::McpProtocolMode::Legacy, required_servers: Vec::new(), optional_startup_deadline: OnceLock::new(), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index f6bc828115..62bfbeeb74 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -106,7 +106,6 @@ impl McpConnectionSet { servers: HashMap::new(), event_stream_connection: None, disabled_servers: Vec::new(), - protocol_mode: crate::McpProtocolMode::Legacy, required_servers: Vec::new(), optional_startup_deadline: OnceLock::new(), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), @@ -132,6 +131,7 @@ impl McpConnectionSet { name, McpServerView { tool_filter: ToolFilter::default(), + protocol_mode: crate::McpProtocolMode::Legacy, connection: Arc::new(McpServerConnection { identity: None, client, @@ -1985,6 +1985,97 @@ fn codex_apps_env_bearer_token_bypasses_shared_tools_cache() { )); } +#[tokio::test] +async fn hosted_apps_protocol_mode_is_independent_of_generic_mode() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let server_config: McpServerConfig = + serde_json::from_value(serde_json::json!({ "url": "http://127.0.0.1:1/ps/mcp" }))?; + + for (generic_mode, hosted_mode) in [ + ( + crate::McpProtocolMode::Legacy, + crate::McpProtocolMode::V20260728, + ), + ( + crate::McpProtocolMode::V20260728, + crate::McpProtocolMode::Legacy, + ), + ] { + let mut config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf()); + config.protocol_mode = generic_mode; + config.host_owned_apps_protocol_mode = hosted_mode; + let mut catalog = crate::ResolvedMcpCatalog::builder(); + catalog.register(crate::McpServerRegistration::from_hosted_apps( + "test-host", + /*contribution_order*/ 0, + server_config.clone(), + )); + catalog.register(crate::McpServerRegistration::from_config( + "third_party".to_string(), + server_config.clone(), + )); + config.mcp_server_catalog = catalog.build(); + + let startup_cancellation_token = CancellationToken::new(); + startup_cancellation_token.cancel(); + let manager = McpConnectionSet::new( + /*previous*/ None, + McpPublicationGate::already_published(), + McpRuntimeInput { + startup_policy: McpStartupPolicy::Eager, + config: Arc::new(config), + plugins_available: false, + ready_selected_capability_roots: Vec::new(), + mcp_servers: HashMap::from([ + ( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + EffectiveMcpServer::configured(server_config.clone()), + ), + ( + "third_party".to_string(), + EffectiveMcpServer::configured(server_config.clone()), + ), + ]), + submit_id: "protocol-mode-scope".to_string(), + tx_event: None, + startup_cancellation_token, + runtime_context: McpRuntimeContext::new( + Arc::new(environment_manager_without_environments()), + codex_home.path().to_path_buf(), + ), + codex_apps_tools_cache: ConnectorRuntimeManager::default(), + tool_catalog_cache: McpToolCatalogCache::default(), + codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( + /*account_id*/ None, /*chatgpt_user_id*/ None, + ), + client_mcp_extensions: ClientMcpExtensions::default(), + auth: None, + auth_manager: None, + elicitation_reviewer: None, + elicitation_lifecycle: None, + }, + ElicitationRequestRouter::default(), + ) + .await; + + assert_eq!( + manager.servers[CODEX_APPS_MCP_SERVER_NAME].protocol_mode, + hosted_mode + ); + assert_eq!(manager.servers["third_party"].protocol_mode, generic_mode); + assert_eq!( + manager + .event_stream_connection + .as_ref() + .expect("hosted Apps event stream") + .protocol_mode, + hosted_mode + ); + } + + Ok(()) +} + #[tokio::test] async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow::Result<()> { let codex_home = tempdir()?; @@ -2005,6 +2096,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow: let server_config: McpServerConfig = serde_json::from_value(serde_json::json!({ "url": "http://127.0.0.1:1" }))?; let mut config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf()); + config.host_owned_apps_protocol_mode = crate::McpProtocolMode::V20260728; let mut catalog = crate::ResolvedMcpCatalog::builder(); catalog.register(crate::McpServerRegistration::from_extension( CODEX_APPS_MCP_SERVER_NAME.to_string(), @@ -2049,6 +2141,11 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow: .await; let client = manager.test_client(CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + manager.servers[CODEX_APPS_MCP_SERVER_NAME].protocol_mode, + crate::McpProtocolMode::Legacy, + "an ordinary extension named codex_apps must not inherit the hosted protocol default" + ); assert!( client.codex_apps_tools_cache_context.is_none(), "an extension must not receive the host-owned Apps cache" @@ -4907,6 +5004,7 @@ async fn manager_with_reusable_ready_server( manager.servers.insert( "docs".to_string(), McpServerView { + protocol_mode: crate::McpProtocolMode::Legacy, connection: Arc::new(McpServerConnection { identity: Some(reusable_server_identity("docs", config, runtime_context)), client: create_ready_async_managed_client(tools).await, @@ -5226,6 +5324,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a previous.servers.insert( "docs".to_string(), McpServerView { + protocol_mode: crate::McpProtocolMode::Legacy, connection: Arc::new(McpServerConnection { identity: Some(reusable_server_identity("docs", &config, &runtime_context)), client: AsyncManagedClient { diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index b6d6ff9bef..a0bec56444 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -172,8 +172,10 @@ pub struct McpConfig { pub prefix_mcp_tool_names: bool, /// MCP servers whose model-visible tool namespaces omit the `mcp__` prefix. pub non_prefixed_mcp_tool_servers: Vec, - /// Protocol compatibility policy captured when this MCP configuration is created. + /// Protocol mode for servers other than the host-owned Codex Apps registration. pub protocol_mode: McpProtocolMode, + /// Independent protocol mode for the trusted, HTTP Codex Apps registration. + pub host_owned_apps_protocol_mode: McpProtocolMode, /// Client-side elicitation capabilities advertised during MCP initialization. pub client_elicitation_capability: ElicitationCapability, /// Resolved MCP registrations keyed by logical server name. diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index fb1ee3126e..8c267d4db7 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -42,6 +42,7 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig { prefix_mcp_tool_names: true, non_prefixed_mcp_tool_servers: Vec::new(), protocol_mode: McpProtocolMode::Legacy, + host_owned_apps_protocol_mode: McpProtocolMode::Legacy, client_elicitation_capability: ElicitationCapability::default(), mcp_server_catalog: ResolvedMcpCatalog::default(), connector_snapshot: codex_connectors::ConnectorSnapshot::default(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b0c2f9c9c2..f84fac728a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -701,6 +701,9 @@ "code_mode_prewarm": { "type": "boolean" }, + "codex_apps_mcp_2026_07_28": { + "type": "boolean" + }, "codex_git_commit": { "type": "boolean" }, @@ -6190,6 +6193,9 @@ "code_mode_prewarm": { "type": "boolean" }, + "codex_apps_mcp_2026_07_28": { + "type": "boolean" + }, "codex_git_commit": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8ca391de86..a2be15e694 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7018,7 +7018,7 @@ async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<( } #[tokio::test] -async fn to_mcp_config_flows_mcp_2026_feature_from_config() -> std::io::Result<()> { +async fn to_mcp_config_flows_independent_mcp_2026_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -7030,13 +7030,42 @@ async fn to_mcp_config_flows_mcp_2026_feature_from_config() -> std::io::Result<( plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; - assert_eq!(mcp_config.protocol_mode, codex_mcp::McpProtocolMode::Legacy); + assert_eq!( + ( + mcp_config.protocol_mode, + mcp_config.host_owned_apps_protocol_mode, + ), + ( + codex_mcp::McpProtocolMode::Legacy, + codex_mcp::McpProtocolMode::Legacy, + ) + ); let _ = config.features.enable(Feature::Mcp20260728); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( - mcp_config.protocol_mode, - codex_mcp::McpProtocolMode::V20260728 + ( + mcp_config.protocol_mode, + mcp_config.host_owned_apps_protocol_mode, + ), + ( + codex_mcp::McpProtocolMode::V20260728, + codex_mcp::McpProtocolMode::Legacy, + ) + ); + + let _ = config.features.disable(Feature::Mcp20260728); + let _ = config.features.enable(Feature::CodexAppsMcp20260728); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert_eq!( + ( + mcp_config.protocol_mode, + mcp_config.host_owned_apps_protocol_mode, + ), + ( + codex_mcp::McpProtocolMode::Legacy, + codex_mcp::McpProtocolMode::V20260728, + ) ); Ok(()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1d23a3dc1d..ffbda1e8e4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1792,6 +1792,11 @@ impl Config { Vec::new() }, protocol_mode: self.mcp_protocol_mode(), + host_owned_apps_protocol_mode: if self.features.enabled(Feature::CodexAppsMcp20260728) { + McpProtocolMode::V20260728 + } else { + McpProtocolMode::Legacy + }, client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) { ElicitationCapability::new() .with_form(FormElicitationCapability::new()) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d813225a80..219b8f584e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1931,6 +1931,18 @@ impl Session { config.mcp_servers = next_config.mcp_servers.clone(); config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace; config.mcp_oauth_credentials_store_mode = next_config.mcp_oauth_credentials_store_mode; + if let Err(err) = config.features.set_enabled( + Feature::Mcp20260728, + next_config.features.enabled(Feature::Mcp20260728), + ) { + warn!("failed to refresh MCP protocol config: {err}"); + } + if let Err(err) = config.features.set_enabled( + Feature::CodexAppsMcp20260728, + next_config.features.enabled(Feature::CodexAppsMcp20260728), + ) { + warn!("failed to refresh Codex Apps MCP protocol config: {err}"); + } if let Err(err) = config.features.set_enabled( Feature::SecretAuthStorage, next_config.features.enabled(Feature::SecretAuthStorage), @@ -1987,6 +1999,18 @@ impl Session { config.mcp_servers = next_config.mcp_servers; config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace; config.mcp_oauth_credentials_store_mode = next_config.mcp_oauth_credentials_store_mode; + if let Err(err) = config.features.set_enabled( + Feature::Mcp20260728, + next_config.features.enabled(Feature::Mcp20260728), + ) { + warn!("failed to refresh MCP protocol config: {err}"); + } + if let Err(err) = config.features.set_enabled( + Feature::CodexAppsMcp20260728, + next_config.features.enabled(Feature::CodexAppsMcp20260728), + ) { + warn!("failed to refresh Codex Apps MCP protocol config: {err}"); + } if let Err(err) = config.features.set_enabled( Feature::SecretAuthStorage, next_config.features.enabled(Feature::SecretAuthStorage), diff --git a/codex-rs/core/tests/suite/codex_apps_protocol.rs b/codex-rs/core/tests/suite/codex_apps_protocol.rs new file mode 100644 index 0000000000..fadc529bae --- /dev/null +++ b/codex-rs/core/tests/suite/codex_apps_protocol.rs @@ -0,0 +1,64 @@ +//! Verifies the hosted Apps protocol choice in standalone Codex sessions. + +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_mcp_server; +use pretty_assertions::assert_eq; +use serde_json::Value; +use test_case::test_case; + +#[derive(Clone, Copy)] +enum HostedProtocolSetting { + Default, + Enabled, +} + +#[test_case(HostedProtocolSetting::Default, &["initialize", "notifications/initialized", "tools/list"]; "defaults_to_legacy")] +#[test_case(HostedProtocolSetting::Enabled, &["server/discover", "initialize", "notifications/initialized", "tools/list"]; "opt_in_discovers_and_falls_back_to_legacy")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn standalone_codex_apps_respects_protocol_setting( + setting: HostedProtocolSetting, + expected_methods: &[&str], +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_base_url = AppsTestServer::mount(&server).await?.chatgpt_base_url; + let fixture = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config + .features + .enable(Feature::Apps) + .expect("test config should allow Apps override"); + if matches!(setting, HostedProtocolSetting::Enabled) { + config + .features + .enable(Feature::CodexAppsMcp20260728) + .expect("test config should allow Apps protocol override"); + } + config.chatgpt_base_url = apps_base_url; + }) + .build_with_auto_env(&server) + .await?; + + wait_for_mcp_server(&fixture.codex, CODEX_APPS_MCP_SERVER_NAME).await?; + let methods = server + .received_requests() + .await + .expect("mock server should capture Apps MCP startup requests") + .into_iter() + .filter(|request| request.url.path() == "/api/codex/ps/mcp") + .filter_map(|request| { + let body: Value = serde_json::from_slice(&request.body).ok()?; + body.get("method")?.as_str().map(str::to_string) + }) + .collect::>(); + assert_eq!(methods, expected_methods); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ddaabb2d8e..4f23375825 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -52,6 +52,7 @@ mod client_websockets; mod cloud_config; mod code_mode; mod code_mode_elicitation; +mod codex_apps_protocol; mod codex_delegate; mod collaboration_instructions; mod compact; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 0c91cda3b0..fffb7b51ae 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -209,6 +209,8 @@ pub enum Feature { EnableMcpApps, /// Enable MCP protocol version 2026-07-28 support. Mcp20260728, + /// Enable MCP protocol version 2026-07-28 for the host-owned Codex Apps server. + CodexAppsMcp20260728, /// Let RMCP coordinate OAuth refresh through the configured credential store. McpOAuthRefreshCoordination, /// Removed compatibility flag for the legacy Apps MCP path override. @@ -1303,6 +1305,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::CodexAppsMcp20260728, + key: "codex_apps_mcp_2026_07_28", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::McpOAuthRefreshCoordination, key: "mcp_oauth_refresh_coordination", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 90c7b40efa..74e2e31b89 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -93,6 +93,30 @@ fn cwd_relative_turn_diffs_is_an_opt_in_map_feature() { assert!(!features.enabled(Feature::CwdRelativeTurnDiffs)); } +#[test] +fn codex_apps_mcp_protocol_can_be_enabled_independently_of_generic_mcp() { + let features_toml = FeaturesToml::from(BTreeMap::from([ + (Feature::CodexAppsMcp20260728.key().to_string(), true), + (Feature::Mcp20260728.key().to_string(), false), + ])); + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!( + ( + features.enabled(Feature::CodexAppsMcp20260728), + features.enabled(Feature::Mcp20260728), + ), + (true, false), + ); +} + #[test] fn default_enabled_features_are_stable() { for spec in crate::FEATURES {