Negotiate MCP extensions per app-server session (#36910)

## Why

App-server clients need to advertise structured MCP extension settings, including supported MCP App UI MIME types, rather than only opting into OpenAI form elicitation with a boolean.

## What changed

- Add an `extensions` map to initialize capabilities and preserve `mcpServerOpenaiFormElicitation` as a legacy alias for `openai/form`.
- Capture the declared extension profile when a thread is started, resumed, or forked, propagate it to subagents, and advertise it to downstream MCP servers during initialization.
- Keep the profile stable for the lifetime of the loaded session instead of changing it on later turns or direct tool calls.

## Testing

- Cover extension conversion, downstream MCP initialization, session isolation, legacy form support, and subagent inheritance.

GitOrigin-RevId: fbcedbb74ce788e574b0f884a4c45c4cedb9de54
This commit is contained in:
Martin Au-Yeung authored and copyberry committed 2026-08-04 15:23:43 +00:00
1 parent bab7c2dcc1
commit ee46c5ba0e
61 files changed
+729 -284

No files matched your search

@@ -17,6 +17,7 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::WarningEvent;
@@ -830,7 +831,7 @@ async fn resume_conversation(
path,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
))
.await
.expect("resume conversation")
+3 -2
View File
@@ -5,6 +5,7 @@ use codex_core::NewThread;
use codex_core::ThreadConfigSnapshot;
use codex_core::parse_turn_item;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
@@ -238,7 +239,7 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto
}),
/*thread_source*/ None,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await
.expect("fork from stored history");
@@ -274,7 +275,7 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto
codex_login::CodexAuth::from_api_key("dummy"),
),
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await
.expect("resume copied paginated fork")
@@ -17,6 +17,7 @@ use codex_mcp::McpRuntime;
use codex_mcp::McpRuntimeContext;
use codex_mcp::McpRuntimeInput;
use codex_mcp::McpToolCatalogCache;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::AskForApproval;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::start_mock_server;
@@ -102,7 +103,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token(
codex_apps_tools_cache: CodexAppsToolsCache::default(),
tool_catalog_cache: McpToolCatalogCache::default(),
codex_apps_tools_cache_key: codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)),
supports_openai_form_elicitation: false,
client_mcp_extensions: ClientMcpExtensions::default(),
auth: Some(expected_auth.clone()),
codex_apps_auth_manager: Some(Arc::clone(&auth_manager)),
elicitation_reviewer: None,
@@ -6,6 +6,7 @@ use std::time::Duration;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_protocol::protocol::Op;
use core_test_support::process::process_is_alive;
use core_test_support::process::wait_for_pid_file;
use core_test_support::process::wait_for_process_exit;
@@ -112,10 +113,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh
]),
)
.await;
fixture
.codex
.set_openai_form_elicitation_support(/*supported*/ true)
.await?;
fixture.codex.submit(Op::RefreshMcpServers).await?;
fixture.submit_turn("refresh MCP servers").await?;
let replacement_pid = wait_for_pid_file(&pid_file).await?;
@@ -6,6 +6,7 @@ use codex_core::test_support::auth_manager_from_auth;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_protocol::ThreadId;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CodexErrorInfo;
@@ -2726,7 +2727,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
InitialHistory::Forked(history),
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await?;
let codex = resumed_thread.thread;
+2 -1
View File
@@ -7,6 +7,7 @@ use codex_login::CodexAuth;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
@@ -116,7 +117,7 @@ async fn emits_warning_when_resumed_model_differs() {
initial_history,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await
.expect("resume conversation");
-52
View File
@@ -426,58 +426,6 @@ async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result
assert_openai_form_capability_advertisement(/*expected*/ false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
let server = start_mock_server().await;
let server_name = "capabilities";
let command = stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let unsupported = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities-unsupported",
)
.await?;
assert_eq!(
unsupported,
json!({ "supportsOpenaiFormElicitation": false })
);
fixture
.codex
.set_openai_form_elicitation_support(/*supported*/ true)
.await?;
let supported = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities-supported",
)
.await?;
assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true }));
Ok(())
}
async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
@@ -4,6 +4,7 @@ use codex_config::CONFIG_TOML_FILE;
use codex_core::NewThread;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::WarningEvent;
@@ -50,7 +51,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
InitialHistory::New,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await
.expect("spawn conversation");
@@ -100,7 +101,7 @@ async fn suppresses_warning_when_configured() {
InitialHistory::New,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
ClientMcpExtensions::default(),
)
.await
.expect("spawn conversation");