From 4785344c9c17e5580a54d7abb6fdeb118ebad61f Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 3 Dec 2025 09:51:46 -0800 Subject: [PATCH] feat: support list mcp servers in app server (#7505) ### Summary Added `mcp/servers/list` which is equivalent to `/mcp` slash command in CLI for response. This will be used in VSCE MCP settings to show log in status, available tools etc. --- .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 43 +++++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 88 +++++++++ codex-rs/core/src/codex.rs | 27 +-- codex-rs/core/src/mcp/mod.rs | 167 ++++++++++++++++++ 6 files changed, 312 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 51d1bd236..285836673 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServersList => "mcpServers/list" { + params: v2::ListMcpServersParams, + response: v2::ListMcpServersResponse, + }, + LoginAccount => "account/login/start" { params: v2::LoginAccountParams, response: v2::LoginAccountResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0e2be70c9..e3990432f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -22,6 +22,9 @@ use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; +use mcp_types::Tool as McpTool; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -138,6 +141,15 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -615,6 +627,37 @@ pub struct ModelListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServer { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 4ffe2d891..99d5a7a14 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -31,6 +31,7 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cd8aa9b70..245486e48 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -45,6 +45,8 @@ use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::ListMcpServersParams; +use codex_app_server_protocol::ListMcpServersResponse; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; @@ -52,6 +54,7 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; +use codex_app_server_protocol::McpServer; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -119,6 +122,8 @@ use codex_core::exec_env::create_env; use codex_core::features::Feature; use codex_core::find_conversation_path_by_id_str; use codex_core::git_info::git_diff_to_remote; +use codex_core::mcp::collect_mcp_snapshot; +use codex_core::mcp::group_tools_by_server; use codex_core::parse_cursor; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -136,6 +141,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -363,6 +369,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServersList { request_id, params } => { + self.list_mcp_servers(request_id, params).await; + } ClientRequest::LoginAccount { request_id, params } => { self.login_v2(request_id, params).await; } @@ -1908,6 +1917,85 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { + let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; + + let tools_by_server = group_tools_by_server(&snapshot.tools); + + let mut server_names: Vec = self + .config + .mcp_servers + .keys() + .cloned() + .chain(snapshot.auth_statuses.keys().cloned()) + .chain(snapshot.resources.keys().cloned()) + .chain(snapshot.resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); + + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => 0, + }; + + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total MCP servers {total}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServer { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: snapshot.resources.get(name).cloned().unwrap_or_default(), + resource_templates: snapshot + .resource_templates + .get(name) + .cloned() + .unwrap_or_default(), + auth_status: snapshot + .auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); + + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + let response = ListMcpServersResponse { data, next_cursor }; + + self.outgoing.send_response(request_id, response).await; + } + async fn handle_resume_conversation( &self, request_id: RequestId, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f76cd7de7..682861d64 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1493,6 +1493,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; + use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -1689,30 +1690,18 @@ mod handlers { pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let (tools, auth_status_entries, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), + let snapshot = collect_mcp_snapshot_from_manager( + &mcp_connection_manager, compute_auth_statuses( config.mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, - ), - mcp_connection_manager.list_all_resources(), - mcp_connection_manager.list_all_resource_templates(), - ); - let auth_statuses = auth_status_entries - .iter() - .map(|(name, entry)| (name.clone(), entry.auth_status)) - .collect(); + ) + .await, + ) + .await; let event = Event { id: sub_id, - msg: EventMsg::McpListToolsResponse(crate::protocol::McpListToolsResponseEvent { - tools: tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - resources, - resource_templates, - auth_statuses, - }), + msg: EventMsg::McpListToolsResponse(snapshot), }; sess.send_event_raw(event).await; } diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 0e4a05d59..ed5f2ea69 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -1 +1,168 @@ pub mod auth; +use std::collections::HashMap; + +use async_channel::unbounded; +use codex_protocol::protocol::McpListToolsResponseEvent; +use mcp_types::Tool as McpTool; +use tokio_util::sync::CancellationToken; + +use crate::config::Config; +use crate::mcp::auth::compute_auth_statuses; +use crate::mcp_connection_manager::McpConnectionManager; + +const MCP_TOOL_NAME_PREFIX: &str = "mcp"; +const MCP_TOOL_NAME_DELIMITER: &str = "__"; + +pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { + if config.mcp_servers.is_empty() { + return McpListToolsResponseEvent { + tools: HashMap::new(), + resources: HashMap::new(), + resource_templates: HashMap::new(), + auth_statuses: HashMap::new(), + }; + } + + let auth_status_entries = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; + + let mut mcp_connection_manager = McpConnectionManager::default(); + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + let cancel_token = CancellationToken::new(); + + mcp_connection_manager + .initialize( + config.mcp_servers.clone(), + config.mcp_oauth_credentials_store_mode, + auth_status_entries.clone(), + tx_event, + cancel_token.clone(), + ) + .await; + + let snapshot = + collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; + + cancel_token.cancel(); + + snapshot +} + +pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> { + let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER); + let prefix = parts.next()?; + if prefix != MCP_TOOL_NAME_PREFIX { + return None; + } + let server_name = parts.next()?; + let tool_name: String = parts.collect::>().join(MCP_TOOL_NAME_DELIMITER); + if tool_name.is_empty() { + return None; + } + Some((server_name.to_string(), tool_name)) +} + +pub fn group_tools_by_server( + tools: &HashMap, +) -> HashMap> { + let mut grouped = HashMap::new(); + for (qualified_name, tool) in tools { + if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) { + grouped + .entry(server_name) + .or_insert_with(HashMap::new) + .insert(tool_name, tool.clone()); + } + } + grouped +} + +pub(crate) async fn collect_mcp_snapshot_from_manager( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, +) -> McpListToolsResponseEvent { + let (tools, resources, resource_templates) = tokio::join!( + mcp_connection_manager.list_all_tools(), + mcp_connection_manager.list_all_resources(), + mcp_connection_manager.list_all_resource_templates(), + ); + + let auth_statuses = auth_status_entries + .iter() + .map(|(name, entry)| (name.clone(), entry.auth_status)) + .collect(); + + McpListToolsResponseEvent { + tools: tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + resources, + resource_templates, + auth_statuses, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ToolInputSchema; + use pretty_assertions::assert_eq; + + fn make_tool(name: &str) -> McpTool { + McpTool { + annotations: None, + description: None, + input_schema: ToolInputSchema { + properties: None, + required: None, + r#type: "object".to_string(), + }, + name: name.to_string(), + output_schema: None, + title: None, + } + } + + #[test] + fn split_qualified_tool_name_returns_server_and_tool() { + assert_eq!( + split_qualified_tool_name("mcp__alpha__do_thing"), + Some(("alpha".to_string(), "do_thing".to_string())) + ); + } + + #[test] + fn split_qualified_tool_name_rejects_invalid_names() { + assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); + assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); + } + + #[test] + fn group_tools_by_server_strips_prefix_and_groups() { + let mut tools = HashMap::new(); + tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); + tools.insert( + "mcp__alpha__nested__op".to_string(), + make_tool("nested__op"), + ); + tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); + + let mut expected_alpha = HashMap::new(); + expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); + expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); + + let mut expected_beta = HashMap::new(); + expected_beta.insert("do_other".to_string(), make_tool("do_other")); + + let mut expected = HashMap::new(); + expected.insert("alpha".to_string(), expected_alpha); + expected.insert("beta".to_string(), expected_beta); + + assert_eq!(group_tools_by_server(&tools), expected); + } +}