From 7d8b38b37b4e82e72e0e929135c0bb7b2bf125fe Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 6 May 2025 16:52:43 -0700 Subject: [PATCH] feat: show MCP tool calls in `codex exec` subcommand (#841) This is analogous to the change for the TUI in https://github.com/openai/codex/pull/836, but for `codex exec`. To test, I ran: ``` cargo run --bin codex-exec -- 'what is the weather in wellesley ma tomorrow' ``` and saw: ![image](https://github.com/user-attachments/assets/5714e07f-88c7-4dd9-aa0d-be54c1670533) --- codex-rs/Cargo.lock | 2 + codex-rs/exec/Cargo.toml | 2 + codex-rs/exec/src/event_processor.rs | 88 ++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 184c316f..6df8bb06 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -531,7 +531,9 @@ dependencies = [ "chrono", "clap", "codex-core", + "mcp-types", "owo-colors 4.2.0", + "serde_json", "shlex", "tokio", "tracing", diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index f0258a12..fdd75dbd 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -16,7 +16,9 @@ anyhow = "1" chrono = "0.4.40" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core", features = ["cli"] } +mcp-types = { path = "../mcp-types" } owo-colors = "4.2.0" +serde_json = "1" shlex = "1.3.0" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 41b0af66..a8208883 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -15,6 +15,11 @@ pub(crate) struct EventProcessor { call_id_to_command: HashMap, call_id_to_patch: HashMap, + /// Tracks in-flight MCP tool calls so we can calculate duration and print + /// a concise summary when the corresponding `McpToolCallEnd` event is + /// received. + call_id_to_tool_call: HashMap, + // To ensure that --color=never is respected, ANSI escapes _must_ be added // using .style() with one of these fields. If you need a new style, add a // new field here. @@ -30,6 +35,7 @@ impl EventProcessor { pub(crate) fn create_with_ansi(with_ansi: bool) -> Self { let call_id_to_command = HashMap::new(); let call_id_to_patch = HashMap::new(); + let call_id_to_tool_call = HashMap::new(); if with_ansi { Self { @@ -40,6 +46,7 @@ impl EventProcessor { magenta: Style::new().magenta(), red: Style::new().red(), green: Style::new().green(), + call_id_to_tool_call, } } else { Self { @@ -50,6 +57,7 @@ impl EventProcessor { magenta: Style::new(), red: Style::new(), green: Style::new(), + call_id_to_tool_call, } } } @@ -60,6 +68,14 @@ struct ExecCommandBegin { start_time: chrono::DateTime, } +/// Metadata captured when an `McpToolCallBegin` event is received. +struct McpToolCallBegin { + /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. + invocation: String, + /// Timestamp when the call started so we can compute duration later. + start_time: chrono::DateTime, +} + struct PatchApplyBegin { start_time: chrono::DateTime, auto_approved: bool, @@ -154,6 +170,78 @@ impl EventProcessor { } println!("{}", truncated_output.style(self.dimmed)); } + + // Handle MCP tool calls (e.g. calling external functions via MCP). + EventMsg::McpToolCallBegin { + call_id, + server, + tool, + arguments, + } => { + // Build fully-qualified tool name: server.tool + let fq_tool_name = format!("{server}.{tool}"); + + // Format arguments as compact JSON so they fit on one line. + let args_str = arguments + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) + .unwrap_or_default(); + + let invocation = if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + }; + + self.call_id_to_tool_call.insert( + call_id.clone(), + McpToolCallBegin { + invocation: invocation.clone(), + start_time: Utc::now(), + }, + ); + + ts_println!( + "{} {}", + "tool".style(self.magenta), + invocation.style(self.bold), + ); + } + EventMsg::McpToolCallEnd { + call_id, + success, + result, + } => { + // Retrieve start time and invocation for duration calculation and labeling. + let info = self.call_id_to_tool_call.remove(&call_id); + + let (duration, invocation) = if let Some(McpToolCallBegin { + invocation, + start_time, + .. + }) = info + { + (format_duration(start_time), invocation) + } else { + (String::new(), format!("tool('{call_id}')")) + }; + + let status_str = if success { "success" } else { "failed" }; + let title_style = if success { self.green } else { self.red }; + let title = format!("{invocation} {status_str}{duration}:"); + + ts_println!("{}", title.style(title_style)); + + if let Some(res) = result { + let val: serde_json::Value = res.into(); + let pretty = + serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); + + for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { + println!("{}", line.style(self.dimmed)); + } + } + } EventMsg::PatchApplyBegin { call_id, auto_approved,