codex - 💡(How to fix) Fix TUI: status-blind `McpToolCall` arm in `handle_thread_item` routes `InProgress` items to the "completed" handler, surfacing spurious "MCP tool call completed without a result"

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

In codex-rs/tui/src/chatwidget.rs, handle_thread_item dispatches ThreadItem::McpToolCall straight to the completed handler without checking status. Its sibling arms (CommandExecution, FileChange) explicitly gate InProgress to a different path. When an InProgress MCP item reaches handle_thread_item (e.g. via replay_thread_item during transcript replay, or any other future caller that fans both lifecycle stages through this entry point), it falls into on_mcp_tool_call_completedhandle_mcp_tool_call_completed_now, which destructures result/error (both Option, both None on InProgress) and matches (None, None) => Err("MCP tool call completed without a result").

Verified against tag rust-v0.130.0.

Error Message

McpToolCall { id: String, server: String, tool: String, status: McpToolCallStatus, arguments: JsonValue, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] mcp_app_resource_uri: Option<String>, result: Option<Box<McpToolCallResult>>, error: Option<McpToolCallError>, #[ts(type = "number | null")] duration_ms: Option<i64>, },

Root Cause

In codex-rs/tui/src/chatwidget.rs, handle_thread_item dispatches ThreadItem::McpToolCall straight to the completed handler without checking status. Its sibling arms (CommandExecution, FileChange) explicitly gate InProgress to a different path. When an InProgress MCP item reaches handle_thread_item (e.g. via replay_thread_item during transcript replay, or any other future caller that fans both lifecycle stages through this entry point), it falls into on_mcp_tool_call_completedhandle_mcp_tool_call_completed_now, which destructures result/error (both Option, both None on InProgress) and matches (None, None) => Err("MCP tool call completed without a result").

Verified against tag rust-v0.130.0.

Fix Action

Fix / Workaround

In codex-rs/tui/src/chatwidget.rs, handle_thread_item dispatches ThreadItem::McpToolCall straight to the completed handler without checking status. Its sibling arms (CommandExecution, FileChange) explicitly gate InProgress to a different path. When an InProgress MCP item reaches handle_thread_item (e.g. via replay_thread_item during transcript replay, or any other future caller that fans both lifecycle stages through this entry point), it falls into on_mcp_tool_call_completedhandle_mcp_tool_call_completed_now, which destructures result/error (both Option, both None on InProgress) and matches (None, None) => Err("MCP tool call completed without a result").

item @ ThreadItem::CommandExecution {
    status: codex_app_server_protocol::CommandExecutionStatus::InProgress,
    ..
} => self.on_command_execution_started(item),
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
ThreadItem::FileChange {
    status: codex_app_server_protocol::PatchApplyStatus::InProgress,
    ..
} => {}
item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),

Code Example

item @ ThreadItem::CommandExecution {
    status: codex_app_server_protocol::CommandExecutionStatus::InProgress,
    ..
} => self.on_command_execution_started(item),
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
ThreadItem::FileChange {
    status: codex_app_server_protocol::PatchApplyStatus::InProgress,
    ..
} => {}
item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),

---

McpToolCall {
    id: String,
    server: String,
    tool: String,
    status: McpToolCallStatus,
    arguments: JsonValue,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    mcp_app_resource_uri: Option<String>,
    result: Option<Box<McpToolCallResult>>,
    error: Option<McpToolCallError>,
    #[ts(type = "number | null")]
    duration_ms: Option<i64>,
},

---

pub enum McpToolCallStatus {
    InProgress,
    Completed,
    Failed,
}

---

pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) {
    // ...
    let ThreadItem::McpToolCall {
        id, server, tool, arguments, result, error, duration_ms, ..
    } = item else { return; };
    // ...
    let result = match (result, error) {
        (_, Some(error)) => Err(error.message),
        (Some(result), None) => { /* Ok branch */ }
        (None, None) => Err("MCP tool call completed without a result".to_string()),
    };
    // ...
}

---

pub(crate) fn replay_thread_item(
    &mut self, item: ThreadItem, turn_id: String, replay_kind: ReplayKind,
) {
    self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind));
}

---

Called copilot-bridge.copilot({"action":"wait","job_id":"copilot-...","max_wait_sec":90})
Error: MCP tool call completed without a result

Called copilot-bridge.copilot({"action":"wait","job_id":"copilot-...","max_wait_sec":90})
{"ok": true, "action": "wait", "status": "still_running", ...}

---

item @ ThreadItem::McpToolCall {
    status: codex_app_server_protocol::McpToolCallStatus::InProgress,
    ..
} => self.on_mcp_tool_call_started(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),
RAW_BUFFERClick to expand / collapse

TUI: status-blind McpToolCall arm in handle_thread_item routes InProgress items to the "completed" handler, surfacing spurious "MCP tool call completed without a result"

Summary

In codex-rs/tui/src/chatwidget.rs, handle_thread_item dispatches ThreadItem::McpToolCall straight to the completed handler without checking status. Its sibling arms (CommandExecution, FileChange) explicitly gate InProgress to a different path. When an InProgress MCP item reaches handle_thread_item (e.g. via replay_thread_item during transcript replay, or any other future caller that fans both lifecycle stages through this entry point), it falls into on_mcp_tool_call_completedhandle_mcp_tool_call_completed_now, which destructures result/error (both Option, both None on InProgress) and matches (None, None) => Err("MCP tool call completed without a result").

Verified against tag rust-v0.130.0.

Source evidence

The status-blind arm — chatwidget.rs:6107-6117

https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/tui/src/chatwidget.rs#L6107-L6117

item @ ThreadItem::CommandExecution {
    status: codex_app_server_protocol::CommandExecutionStatus::InProgress,
    ..
} => self.on_command_execution_started(item),
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
ThreadItem::FileChange {
    status: codex_app_server_protocol::PatchApplyStatus::InProgress,
    ..
} => {}
item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),

Note the asymmetry on line 6117: McpToolCall has a single catch-all arm with no status: discriminator, even though ThreadItem::McpToolCall carries one (see below).

ThreadItem::McpToolCall does carry a statusapp-server-protocol/src/protocol/v2/item.rs:280-294

https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/item.rs#L280-L294

McpToolCall {
    id: String,
    server: String,
    tool: String,
    status: McpToolCallStatus,
    arguments: JsonValue,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    mcp_app_resource_uri: Option<String>,
    result: Option<Box<McpToolCallResult>>,
    error: Option<McpToolCallError>,
    #[ts(type = "number | null")]
    duration_ms: Option<i64>,
},

with McpToolCallStatus defined at item.rs:976-980:

pub enum McpToolCallStatus {
    InProgress,
    Completed,
    Failed,
}

The error path — chatwidget.rs:4755-4790

https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/tui/src/chatwidget.rs#L4755-L4790

pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) {
    // ...
    let ThreadItem::McpToolCall {
        id, server, tool, arguments, result, error, duration_ms, ..
    } = item else { return; };
    // ...
    let result = match (result, error) {
        (_, Some(error)) => Err(error.message),
        (Some(result), None) => { /* Ok branch */ }
        (None, None) => Err("MCP tool call completed without a result".to_string()),
    };
    // ...
}

On an InProgress payload, result and error are both None → the (None, None) arm fires.

How InProgress reaches handle_thread_item

replay_thread_item forwards items straight through (chatwidget.rs:6041-6048):

pub(crate) fn replay_thread_item(
    &mut self, item: ThreadItem, turn_id: String, replay_kind: ReplayKind,
) {
    self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind));
}

Replay does not filter on status first, so an InProgress McpToolCall that was captured mid-call in a serialized transcript will hit line 6117.

Reproduction

Triggered consistently in the wild against an MCP server whose tool handler blocks for ~20-30s on the first call after a transcript replay or session resume.

Observed in copilot-companion (Node-based MCP server using @modelcontextprotocol/sdk's StdioServerTransport):

• Called copilot-bridge.copilot({"action":"wait","job_id":"copilot-...","max_wait_sec":90})
  └ Error: MCP tool call completed without a result

• Called copilot-bridge.copilot({"action":"wait","job_id":"copilot-...","max_wait_sec":90})
  └ {"ok": true, "action": "wait", "status": "still_running", ...}

First call errors; subsequent identical calls succeed. The MCP server's stdio transport wrote a well-formed JSON-RPC response in both cases; RUST_LOG=rmcp=trace confirms the result arrived intact, but the TUI rendered the (None, None) error string anyway.

Expected

The McpToolCall arm in handle_thread_item should mirror its CommandExecution / FileChange siblings: gate status: InProgress to on_mcp_tool_call_started (or to a no-op + queue), and only route Completed / Failed to on_mcp_tool_call_completed.

Proposed fix

item @ ThreadItem::McpToolCall {
    status: codex_app_server_protocol::McpToolCallStatus::InProgress,
    ..
} => self.on_mcp_tool_call_started(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),

on_mcp_tool_call_started already exists at chatwidget.rs:3951 and is the symmetric counterpart to on_mcp_tool_call_completed (the handle_item_started_notification path at chatwidget.rs:6518 already routes there). This change makes both lifecycle entry points behave consistently for MCP tool calls.

Environment

  • Codex CLI: tagged rust-v0.130.0 (also reproduced on main at the time of report)
  • Host: macOS 25.4.0, Codex spawned via codex --dangerously-bypass-approvals-and-sandbox
  • MCP server: Node 20+ @modelcontextprotocol/sdk over stdio
  • Multi-agent mode: V1 (default; Feature::MultiAgentV2 not enabled)

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

codex - 💡(How to fix) Fix TUI: status-blind `McpToolCall` arm in `handle_thread_item` routes `InProgress` items to the "completed" handler, surfacing spurious "MCP tool call completed without a result"