mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
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:
@@ -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": {}})),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user