Give hosted Codex Apps an independent MCP protocol opt-in (#44318)

## Why

Hosted Codex Apps shared the `mcp_2026_07_28` protocol setting with other MCP servers, preventing independent protocol selection.

## What changed

- Add `features.codex_apps_mcp_2026_07_28`, disabled by default, for the host-owned HTTP `codex_apps` server. Enabling it attempts 2026-07-28 discovery with Legacy fallback.
- Keep other servers, including third-party registrations with Apps-like names or URLs and local stdio servers, governed by `mcp_2026_07_28`.
- Support the new flag through `experimentalFeature/enablement/set` and refresh protocol settings in existing sessions. Track protocol mode per server when deciding whether to reuse connections.

## Testing

Add coverage for independent feature and protocol selection, hosted event-stream protocol selection, standalone Legacy defaults and discovery fallback, and runtime enablement refreshing an existing app-server thread.

GitOrigin-RevId: 640e8e251f0c45e5f4829cc2b73c3082cf42f715
This commit is contained in:
thomas
2026-09-09 22:22:08 +00:00
committed by copyberry
parent e1b23086ac
commit eb680c0558
18 changed files with 431 additions and 19 deletions
+12
View File
@@ -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
@@ -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",
@@ -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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(
methods,
[
"initialize",
"notifications/initialized",
"tools/list",
"tools/call",
"server/discover",
"initialize",
"notifications/initialized",
"tools/list",
"tools/call",
]
);
Ok(())
}
@@ -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(),
})
});
@@ -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;
@@ -83,9 +83,8 @@ impl ServerHandler for VerificationServer {
) -> Result<CallToolResponse, rmcp::ErrorData> {
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": {}})),
+16 -8
View File
@@ -160,6 +160,7 @@ impl Drop for McpServerConnection {
#[derive(Clone)]
struct McpServerView {
connection: Arc<McpServerConnection>,
protocol_mode: crate::McpProtocolMode,
metadata: McpServerMetadata,
tool_filter: ToolFilter,
tool_timeout: Option<Duration>,
@@ -186,7 +187,6 @@ pub(crate) struct McpConnectionSet {
servers: HashMap<String, McpServerView>,
pub(crate) event_stream_connection: Option<Arc<EventStreamConnectionSettings>>,
disabled_servers: Vec<String>,
protocol_mode: crate::McpProtocolMode,
required_servers: Vec<String>,
optional_startup_deadline: OnceLock<tokio::time::Instant>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
@@ -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()),
@@ -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 {
+3 -1
View File
@@ -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<String>,
/// 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.
+1
View File
@@ -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(),
+6
View File
@@ -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"
},
+33 -4
View File
@@ -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(())
+5
View File
@@ -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())
+24
View File
@@ -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),
@@ -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::<Vec<_>>();
assert_eq!(methods, expected_methods);
Ok(())
}
+1
View File
@@ -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;
+8
View File
@@ -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",
+24
View File
@@ -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 {