From d0ca4a9b635a6309c352fef59a07c19f62c73a6f Mon Sep 17 00:00:00 2001 From: Brian Brunner Date: Wed, 24 Jun 2026 17:47:38 +0000 Subject: [PATCH] [codex] preserve runtime MCP servers across client capability refresh --- codex-rs/app-server/README.md | 2 +- .../tests/suite/v2/mcp_server_status.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/session/mcp.rs | 3 +- .../core/tests/suite/mcp_refresh_cleanup.rs | 60 +++++++++++++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c7ec2c15cf..c610bcc9cb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -232,7 +232,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. With `threadId`, the response is a point-in-time snapshot of the MCP manager generation currently installed for that thread. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 8d813a82b0..480427fc8d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -214,7 +214,7 @@ url = "{mcp_server_url}/mcp" } #[tokio::test] -async fn mcp_server_status_list_never_advertises_uninstalled_codex_apps() -> Result<()> { +async fn mcp_server_status_list_reflects_installed_manager_generation() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let (apps_server_url, apps_server_handle) = start_apps_mcp_server("calendar_lookup").await?; let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 026fd030b1..86c8fe9e8c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -593,7 +593,7 @@ impl CodexThread { self.codex.session.runtime_mcp_config(config).await } - /// Returns the MCP inventory installed for this thread. + /// Returns a point-in-time snapshot of the MCP inventory installed for this thread. pub async fn mcp_server_status_snapshot( &self, config: &crate::config::Config, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 9b36be5366..bb4956e561 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -444,8 +444,9 @@ impl Session { } let config = self.get_config().await; + let mcp_servers = self.runtime_mcp_servers(config.as_ref()).await; let refresh_config = McpServerRefreshConfig { - mcp_servers: serde_json::to_value(config.mcp_servers.get())?, + mcp_servers: serde_json::to_value(mcp_servers)?, mcp_oauth_credentials_store_mode: serde_json::to_value( config.mcp_oauth_credentials_store_mode, )?, diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index a8e06bb35b..0b355dc00c 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -6,6 +6,10 @@ use std::time::Duration; use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_protocol::mcp::CallToolResult; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::apps_enabled_builder; 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; @@ -14,6 +18,62 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_mcp_server; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn elicitation_capability_refresh_preserves_host_owned_codex_apps() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let fixture = apps_enabled_builder(apps_server.chatgpt_base_url) + .build(&server) + .await?; + wait_for_mcp_server(&fixture.codex, CODEX_APPS_MCP_SERVER_NAME).await?; + + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + fixture + .codex + .set_openai_form_elicitation_support(/*supported*/ true) + .await?; + fixture.submit_turn("refresh MCP servers").await?; + + let result = fixture + .codex + .call_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some(serde_json::json!({ + "title": "Team sync", + "starts_at": "2026-06-24T17:00:00Z", + })), + /*meta*/ None, + ) + .await?; + assert_eq!( + result, + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "called calendar_create_event for Team sync at 2026-06-24T17:00:00Z with ", + })], + structured_content: Some(serde_json::json!({ "_codex_apps": null })), + is_error: Some(false), + meta: None, + } + ); + + fixture.codex.shutdown_and_wait().await?; + Ok(()) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()> {