mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +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:
1 parent
e1b23086ac
commit
eb680c0558
18 files changed
+431
-19
No files matched your search
@@ -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,
|
runs per app-server; if an OS call remains active after cancellation or timeout,
|
||||||
subsequent local operations return `failed/providerError` until that worker exits.
|
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 removal
|
||||||
|
|
||||||
`thread/archive` and `thread/delete` reject attempts to remove a live internal
|
`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] = &[
|
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||||
"auth_elicitation",
|
"auth_elicitation",
|
||||||
BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE,
|
BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE,
|
||||||
|
"codex_apps_mcp_2026_07_28",
|
||||||
"mcp_2026_07_28",
|
"mcp_2026_07_28",
|
||||||
"memories",
|
"memories",
|
||||||
"mentions_v2",
|
"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")
|
.get("threadId")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let client_capabilities = context.peer.peer_info().map(|request| {
|
let client_capabilities = context.client_capabilities().map(|capabilities| {
|
||||||
json!({
|
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_remove;
|
||||||
mod marketplace_upgrade;
|
mod marketplace_upgrade;
|
||||||
mod mcp_event_stream;
|
mod mcp_event_stream;
|
||||||
|
mod mcp_protocol_default;
|
||||||
mod mcp_resource;
|
mod mcp_resource;
|
||||||
mod mcp_resource_origin;
|
mod mcp_resource_origin;
|
||||||
mod mcp_server_elicitation;
|
mod mcp_server_elicitation;
|
||||||
|
|||||||
@@ -83,9 +83,8 @@ impl ServerHandler for VerificationServer {
|
|||||||
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
context
|
context
|
||||||
.peer
|
.client_capabilities()
|
||||||
.peer_info()
|
.and_then(|capabilities| capabilities.extensions)
|
||||||
.and_then(|info| info.capabilities.extensions.clone())
|
|
||||||
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID).cloned())
|
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID).cloned())
|
||||||
.map(Value::Object),
|
.map(Value::Object),
|
||||||
Some(json!({"userVerification": {}})),
|
Some(json!({"userVerification": {}})),
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ impl Drop for McpServerConnection {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct McpServerView {
|
struct McpServerView {
|
||||||
connection: Arc<McpServerConnection>,
|
connection: Arc<McpServerConnection>,
|
||||||
|
protocol_mode: crate::McpProtocolMode,
|
||||||
metadata: McpServerMetadata,
|
metadata: McpServerMetadata,
|
||||||
tool_filter: ToolFilter,
|
tool_filter: ToolFilter,
|
||||||
tool_timeout: Option<Duration>,
|
tool_timeout: Option<Duration>,
|
||||||
@@ -186,7 +187,6 @@ pub(crate) struct McpConnectionSet {
|
|||||||
servers: HashMap<String, McpServerView>,
|
servers: HashMap<String, McpServerView>,
|
||||||
pub(crate) event_stream_connection: Option<Arc<EventStreamConnectionSettings>>,
|
pub(crate) event_stream_connection: Option<Arc<EventStreamConnectionSettings>>,
|
||||||
disabled_servers: Vec<String>,
|
disabled_servers: Vec<String>,
|
||||||
protocol_mode: crate::McpProtocolMode,
|
|
||||||
required_servers: Vec<String>,
|
required_servers: Vec<String>,
|
||||||
optional_startup_deadline: OnceLock<tokio::time::Instant>,
|
optional_startup_deadline: OnceLock<tokio::time::Instant>,
|
||||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||||
@@ -231,7 +231,8 @@ impl McpConnectionSet {
|
|||||||
let codex_home = config.codex_home.clone();
|
let codex_home = config.codex_home.clone();
|
||||||
let prefix_mcp_tool_names = config.prefix_mcp_tool_names;
|
let prefix_mcp_tool_names = config.prefix_mcp_tool_names;
|
||||||
let non_prefixed_mcp_tool_servers = config.non_prefixed_mcp_tool_servers.clone();
|
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 client_elicitation_capability = config.client_elicitation_capability.clone();
|
||||||
let tool_plugin_provenance = crate::mcp::tool_plugin_provenance(&config);
|
let tool_plugin_provenance = crate::mcp::tool_plugin_provenance(&config);
|
||||||
let auth = auth.as_ref();
|
let auth = auth.as_ref();
|
||||||
@@ -313,6 +314,15 @@ impl McpConnectionSet {
|
|||||||
};
|
};
|
||||||
let metadata = McpServerMetadata::from(&server);
|
let metadata = McpServerMetadata::from(&server);
|
||||||
let configured_config = server.config().clone();
|
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 configured_tool_filter = ToolFilter::from_config(&configured_config);
|
||||||
let startup_timeout = configured_config
|
let startup_timeout = configured_config
|
||||||
.startup_timeout_sec
|
.startup_timeout_sec
|
||||||
@@ -445,13 +455,11 @@ impl McpConnectionSet {
|
|||||||
&& !connection.client.cancel_token.is_cancelled()
|
&& !connection.client.cancel_token.is_cancelled()
|
||||||
&& previous_view.catalog_item_limit == catalog_item_limit
|
&& previous_view.catalog_item_limit == catalog_item_limit
|
||||||
&& expected_protocol_mode.is_some()
|
&& expected_protocol_mode.is_some()
|
||||||
&& reusable_previous
|
&& previous_view.protocol_mode == protocol_mode;
|
||||||
.is_some_and(|previous| previous.protocol_mode == protocol_mode);
|
|
||||||
let unchanged_auth_failure = if connection.identity.as_ref()
|
let unchanged_auth_failure = if connection.identity.as_ref()
|
||||||
== Some(&connection_identity)
|
== Some(&connection_identity)
|
||||||
&& connection_identity.oauth_store_was_contended
|
&& connection_identity.oauth_store_was_contended
|
||||||
&& reusable_previous
|
&& previous_view.protocol_mode == protocol_mode
|
||||||
.is_some_and(|previous| previous.protocol_mode == protocol_mode)
|
|
||||||
&& connection.client.startup_complete.load(Ordering::Acquire)
|
&& connection.client.startup_complete.load(Ordering::Acquire)
|
||||||
{
|
{
|
||||||
connection
|
connection
|
||||||
@@ -480,6 +488,7 @@ impl McpConnectionSet {
|
|||||||
server_name.clone(),
|
server_name.clone(),
|
||||||
McpServerView {
|
McpServerView {
|
||||||
connection,
|
connection,
|
||||||
|
protocol_mode,
|
||||||
metadata,
|
metadata,
|
||||||
tool_filter: configured_tool_filter,
|
tool_filter: configured_tool_filter,
|
||||||
tool_timeout: configured_tool_timeout,
|
tool_timeout: configured_tool_timeout,
|
||||||
@@ -614,6 +623,7 @@ impl McpConnectionSet {
|
|||||||
startup_trigger,
|
startup_trigger,
|
||||||
_diagnostics_guard: LIVE_CONNECTIONS.track(),
|
_diagnostics_guard: LIVE_CONNECTIONS.track(),
|
||||||
}),
|
}),
|
||||||
|
protocol_mode,
|
||||||
metadata,
|
metadata,
|
||||||
tool_filter: configured_tool_filter,
|
tool_filter: configured_tool_filter,
|
||||||
tool_timeout: configured_tool_timeout,
|
tool_timeout: configured_tool_timeout,
|
||||||
@@ -738,7 +748,6 @@ impl McpConnectionSet {
|
|||||||
servers,
|
servers,
|
||||||
event_stream_connection,
|
event_stream_connection,
|
||||||
disabled_servers,
|
disabled_servers,
|
||||||
protocol_mode,
|
|
||||||
required_servers,
|
required_servers,
|
||||||
optional_startup_deadline: OnceLock::new(),
|
optional_startup_deadline: OnceLock::new(),
|
||||||
tool_plugin_provenance,
|
tool_plugin_provenance,
|
||||||
@@ -797,7 +806,6 @@ impl McpConnectionSet {
|
|||||||
servers: HashMap::new(),
|
servers: HashMap::new(),
|
||||||
event_stream_connection: None,
|
event_stream_connection: None,
|
||||||
disabled_servers: Vec::new(),
|
disabled_servers: Vec::new(),
|
||||||
protocol_mode: crate::McpProtocolMode::Legacy,
|
|
||||||
required_servers: Vec::new(),
|
required_servers: Vec::new(),
|
||||||
optional_startup_deadline: OnceLock::new(),
|
optional_startup_deadline: OnceLock::new(),
|
||||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ impl McpConnectionSet {
|
|||||||
servers: HashMap::new(),
|
servers: HashMap::new(),
|
||||||
event_stream_connection: None,
|
event_stream_connection: None,
|
||||||
disabled_servers: Vec::new(),
|
disabled_servers: Vec::new(),
|
||||||
protocol_mode: crate::McpProtocolMode::Legacy,
|
|
||||||
required_servers: Vec::new(),
|
required_servers: Vec::new(),
|
||||||
optional_startup_deadline: OnceLock::new(),
|
optional_startup_deadline: OnceLock::new(),
|
||||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||||
@@ -132,6 +131,7 @@ impl McpConnectionSet {
|
|||||||
name,
|
name,
|
||||||
McpServerView {
|
McpServerView {
|
||||||
tool_filter: ToolFilter::default(),
|
tool_filter: ToolFilter::default(),
|
||||||
|
protocol_mode: crate::McpProtocolMode::Legacy,
|
||||||
connection: Arc::new(McpServerConnection {
|
connection: Arc::new(McpServerConnection {
|
||||||
identity: None,
|
identity: None,
|
||||||
client,
|
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]
|
#[tokio::test]
|
||||||
async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow::Result<()> {
|
async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow::Result<()> {
|
||||||
let codex_home = tempdir()?;
|
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 =
|
let server_config: McpServerConfig =
|
||||||
serde_json::from_value(serde_json::json!({ "url": "http://127.0.0.1:1" }))?;
|
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());
|
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();
|
let mut catalog = crate::ResolvedMcpCatalog::builder();
|
||||||
catalog.register(crate::McpServerRegistration::from_extension(
|
catalog.register(crate::McpServerRegistration::from_extension(
|
||||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
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;
|
.await;
|
||||||
|
|
||||||
let client = manager.test_client(CODEX_APPS_MCP_SERVER_NAME);
|
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!(
|
assert!(
|
||||||
client.codex_apps_tools_cache_context.is_none(),
|
client.codex_apps_tools_cache_context.is_none(),
|
||||||
"an extension must not receive the host-owned Apps cache"
|
"an extension must not receive the host-owned Apps cache"
|
||||||
@@ -4907,6 +5004,7 @@ async fn manager_with_reusable_ready_server(
|
|||||||
manager.servers.insert(
|
manager.servers.insert(
|
||||||
"docs".to_string(),
|
"docs".to_string(),
|
||||||
McpServerView {
|
McpServerView {
|
||||||
|
protocol_mode: crate::McpProtocolMode::Legacy,
|
||||||
connection: Arc::new(McpServerConnection {
|
connection: Arc::new(McpServerConnection {
|
||||||
identity: Some(reusable_server_identity("docs", config, runtime_context)),
|
identity: Some(reusable_server_identity("docs", config, runtime_context)),
|
||||||
client: create_ready_async_managed_client(tools).await,
|
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(
|
previous.servers.insert(
|
||||||
"docs".to_string(),
|
"docs".to_string(),
|
||||||
McpServerView {
|
McpServerView {
|
||||||
|
protocol_mode: crate::McpProtocolMode::Legacy,
|
||||||
connection: Arc::new(McpServerConnection {
|
connection: Arc::new(McpServerConnection {
|
||||||
identity: Some(reusable_server_identity("docs", &config, &runtime_context)),
|
identity: Some(reusable_server_identity("docs", &config, &runtime_context)),
|
||||||
client: AsyncManagedClient {
|
client: AsyncManagedClient {
|
||||||
|
|||||||
@@ -172,8 +172,10 @@ pub struct McpConfig {
|
|||||||
pub prefix_mcp_tool_names: bool,
|
pub prefix_mcp_tool_names: bool,
|
||||||
/// MCP servers whose model-visible tool namespaces omit the `mcp__` prefix.
|
/// MCP servers whose model-visible tool namespaces omit the `mcp__` prefix.
|
||||||
pub non_prefixed_mcp_tool_servers: Vec<String>,
|
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,
|
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.
|
/// Client-side elicitation capabilities advertised during MCP initialization.
|
||||||
pub client_elicitation_capability: ElicitationCapability,
|
pub client_elicitation_capability: ElicitationCapability,
|
||||||
/// Resolved MCP registrations keyed by logical server name.
|
/// 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,
|
prefix_mcp_tool_names: true,
|
||||||
non_prefixed_mcp_tool_servers: Vec::new(),
|
non_prefixed_mcp_tool_servers: Vec::new(),
|
||||||
protocol_mode: McpProtocolMode::Legacy,
|
protocol_mode: McpProtocolMode::Legacy,
|
||||||
|
host_owned_apps_protocol_mode: McpProtocolMode::Legacy,
|
||||||
client_elicitation_capability: ElicitationCapability::default(),
|
client_elicitation_capability: ElicitationCapability::default(),
|
||||||
mcp_server_catalog: ResolvedMcpCatalog::default(),
|
mcp_server_catalog: ResolvedMcpCatalog::default(),
|
||||||
connector_snapshot: codex_connectors::ConnectorSnapshot::default(),
|
connector_snapshot: codex_connectors::ConnectorSnapshot::default(),
|
||||||
|
|||||||
@@ -701,6 +701,9 @@
|
|||||||
"code_mode_prewarm": {
|
"code_mode_prewarm": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"codex_apps_mcp_2026_07_28": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"codex_git_commit": {
|
"codex_git_commit": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -6190,6 +6193,9 @@
|
|||||||
"code_mode_prewarm": {
|
"code_mode_prewarm": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"codex_apps_mcp_2026_07_28": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"codex_git_commit": {
|
"codex_git_commit": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7018,7 +7018,7 @@ async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 codex_home = TempDir::new()?;
|
||||||
let mut config = Config::load_from_base_config_with_overrides(
|
let mut config = Config::load_from_base_config_with_overrides(
|
||||||
ConfigToml::default(),
|
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));
|
plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None));
|
||||||
|
|
||||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
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 _ = config.features.enable(Feature::Mcp20260728);
|
||||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||||
assert_eq!(
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -1792,6 +1792,11 @@ impl Config {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
},
|
},
|
||||||
protocol_mode: self.mcp_protocol_mode(),
|
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) {
|
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
|
||||||
ElicitationCapability::new()
|
ElicitationCapability::new()
|
||||||
.with_form(FormElicitationCapability::new())
|
.with_form(FormElicitationCapability::new())
|
||||||
|
|||||||
@@ -1931,6 +1931,18 @@ impl Session {
|
|||||||
config.mcp_servers = next_config.mcp_servers.clone();
|
config.mcp_servers = next_config.mcp_servers.clone();
|
||||||
config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace;
|
config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace;
|
||||||
config.mcp_oauth_credentials_store_mode = next_config.mcp_oauth_credentials_store_mode;
|
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(
|
if let Err(err) = config.features.set_enabled(
|
||||||
Feature::SecretAuthStorage,
|
Feature::SecretAuthStorage,
|
||||||
next_config.features.enabled(Feature::SecretAuthStorage),
|
next_config.features.enabled(Feature::SecretAuthStorage),
|
||||||
@@ -1987,6 +1999,18 @@ impl Session {
|
|||||||
config.mcp_servers = next_config.mcp_servers;
|
config.mcp_servers = next_config.mcp_servers;
|
||||||
config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace;
|
config.mcp_optional_startup_grace = next_config.mcp_optional_startup_grace;
|
||||||
config.mcp_oauth_credentials_store_mode = next_config.mcp_oauth_credentials_store_mode;
|
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(
|
if let Err(err) = config.features.set_enabled(
|
||||||
Feature::SecretAuthStorage,
|
Feature::SecretAuthStorage,
|
||||||
next_config.features.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 cloud_config;
|
||||||
mod code_mode;
|
mod code_mode;
|
||||||
mod code_mode_elicitation;
|
mod code_mode_elicitation;
|
||||||
|
mod codex_apps_protocol;
|
||||||
mod codex_delegate;
|
mod codex_delegate;
|
||||||
mod collaboration_instructions;
|
mod collaboration_instructions;
|
||||||
mod compact;
|
mod compact;
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ pub enum Feature {
|
|||||||
EnableMcpApps,
|
EnableMcpApps,
|
||||||
/// Enable MCP protocol version 2026-07-28 support.
|
/// Enable MCP protocol version 2026-07-28 support.
|
||||||
Mcp20260728,
|
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.
|
/// Let RMCP coordinate OAuth refresh through the configured credential store.
|
||||||
McpOAuthRefreshCoordination,
|
McpOAuthRefreshCoordination,
|
||||||
/// Removed compatibility flag for the legacy Apps MCP path override.
|
/// Removed compatibility flag for the legacy Apps MCP path override.
|
||||||
@@ -1303,6 +1305,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
|||||||
stage: Stage::UnderDevelopment,
|
stage: Stage::UnderDevelopment,
|
||||||
default_enabled: false,
|
default_enabled: false,
|
||||||
},
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
id: Feature::CodexAppsMcp20260728,
|
||||||
|
key: "codex_apps_mcp_2026_07_28",
|
||||||
|
stage: Stage::UnderDevelopment,
|
||||||
|
default_enabled: false,
|
||||||
|
},
|
||||||
FeatureSpec {
|
FeatureSpec {
|
||||||
id: Feature::McpOAuthRefreshCoordination,
|
id: Feature::McpOAuthRefreshCoordination,
|
||||||
key: "mcp_oauth_refresh_coordination",
|
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));
|
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]
|
#[test]
|
||||||
fn default_enabled_features_are_stable() {
|
fn default_enabled_features_are_stable() {
|
||||||
for spec in crate::FEATURES {
|
for spec in crate::FEATURES {
|
||||||
|
|||||||
Reference in new issue
Block a user