From e7e5fe91c8e4d80935d1f554cf100c4417b0f03d Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 19 Aug 2025 09:00:31 -0700 Subject: [PATCH] [tui] Support /mcp command (#2430) ## Summary Adds a `/mcp` command to list active tools. We can extend this command to allow configuration of MCP tools, but for now a simple list command will help debug if your config.toml and your tools are working as expected. --- codex-rs/core/src/codex.rs | 16 ++++ .../src/event_processor_with_human_output.rs | 3 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 15 ++++ codex-rs/tui/src/app.rs | 5 ++ codex-rs/tui/src/chatwidget.rs | 14 ++++ codex-rs/tui/src/history_cell.rs | 81 +++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 2 + 8 files changed, 137 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 397246a7..6423fb44 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1199,6 +1199,22 @@ async fn submission_loop( } }); } + Op::ListMcpTools => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + // This is a cheap lookup from the connection manager's cache. + let tools = sess.mcp_connection_manager.list_all_tools(); + let event = Event { + id: sub_id, + msg: EventMsg::McpListToolsResponse( + crate::protocol::McpListToolsResponseEvent { tools }, + ), + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send McpListToolsResponse event: {e}"); + } + } Op::Compact => { // Create a summarization request as user input const SUMMARIZATION_PROMPT: &str = include_str!("prompt_for_compact_command.md"); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 98d7a1fb..498b1d29 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -523,6 +523,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } + EventMsg::McpListToolsResponse(_) => { + // Currently ignored in exec output. + } EventMsg::TurnAborted(abort_reason) => match abort_reason.reason { TurnAbortReason::Interrupted => { ts_println!(self, "task interrupted"); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c0d14ece..e1dfd08c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,6 +263,7 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) + | EventMsg::McpListToolsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5d32b8c3..65ef30aa 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use std::time::Duration; use mcp_types::CallToolResult; +use mcp_types::Tool as McpTool; use serde::Deserialize; use serde::Serialize; use serde_bytes::ByteBuf; @@ -136,6 +137,10 @@ pub enum Op { /// Request a single history entry identified by `log_id` + `offset`. GetHistoryEntryRequest { offset: usize, log_id: u64 }, + /// Request the list of MCP tools available across all configured servers. + /// Reply is delivered via `EventMsg::McpListToolsResponse`. + ListMcpTools, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -453,6 +458,9 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + /// List of MCP tools available to the agent. + McpListToolsResponse(McpListToolsResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -749,6 +757,13 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } +/// Response payload for `Op::ListMcpTools`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpListToolsResponseEvent { + /// Fully qualified tool name -> tool definition. + pub tools: std::collections::HashMap, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct SessionConfiguredEvent { /// Unique id for this session. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d12eb76e..810d6cb1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -421,6 +421,11 @@ impl App<'_> { widget.add_status_output(); } } + SlashCommand::Mcp => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_mcp_output(); + } + } #[cfg(debug_assertions)] SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88b816d9..bbb99f52 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -18,6 +18,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::InputItem; +use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; @@ -647,6 +648,7 @@ impl ChatWidget<'_> { EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), + EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { @@ -685,6 +687,14 @@ impl ChatWidget<'_> { )); } + pub(crate) fn add_mcp_output(&mut self) { + if self.config.mcp_servers.is_empty() { + self.add_to_history(&history_cell::empty_mcp_output()); + } else { + self.submit_op(Op::ListMcpTools); + } + } + /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -726,6 +736,10 @@ impl ChatWidget<'_> { } } + fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { + self.add_to_history(&history_cell::new_mcp_tools_output(&self.config, ev.tools)); + } + /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 555226dd..d9ce9ad5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -634,6 +634,87 @@ pub(crate) fn new_status_output( PlainHistoryCell { lines } } +/// Render a summary of configured MCP servers from the current `Config`. +pub(crate) fn empty_mcp_output() -> PlainHistoryCell { + let lines: Vec> = vec![ + Line::from("/mcp".magenta()), + Line::from(""), + Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), + Line::from(""), + Line::from(" • No MCP servers configured.".italic()), + Line::from(""), + ]; + + PlainHistoryCell { lines } +} + +/// Render MCP tools grouped by connection using the fully-qualified tool names. +pub(crate) fn new_mcp_tools_output( + config: &Config, + tools: std::collections::HashMap, +) -> PlainHistoryCell { + let mut lines: Vec> = vec![ + Line::from("/mcp".magenta()), + Line::from(""), + Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), + Line::from(""), + ]; + + if tools.is_empty() { + lines.push(Line::from(" • No MCP tools available.".italic())); + lines.push(Line::from("")); + return PlainHistoryCell { lines }; + } + + for (server, cfg) in config.mcp_servers.iter() { + let prefix = format!("{server}__"); + let mut names: Vec = tools + .keys() + .filter(|k| k.starts_with(&prefix)) + .map(|k| k[prefix.len()..].to_string()) + .collect(); + names.sort(); + + lines.push(Line::from(vec![ + " • Server: ".into(), + server.clone().into(), + ])); + + if !cfg.command.is_empty() { + let cmd_display = format!("{} {}", cfg.command, cfg.args.join(" ")); + + lines.push(Line::from(vec![ + " • Command: ".into(), + cmd_display.into(), + ])); + } + + if let Some(env) = cfg.env.as_ref() { + if !env.is_empty() { + let mut env_pairs: Vec = + env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(Line::from(vec![ + " • Env: ".into(), + env_pairs.join(" ").into(), + ])); + } + } + + if names.is_empty() { + lines.push(Line::from(" • Tools: (none)")); + } else { + lines.push(Line::from(vec![ + " • Tools: ".into(), + names.join(", ").into(), + ])); + } + lines.push(Line::from("")); + } + + PlainHistoryCell { lines } +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { let lines: Vec> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()]; PlainHistoryCell { lines } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 71d60237..56a6c316 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -18,6 +18,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Mcp, Logout, Quit, #[cfg(debug_assertions)] @@ -35,6 +36,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request",