Skip to content

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" #22300

@thangaram611

Description

@thangaram611

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    TUIIssues related to the terminal user interface: text input, menus and dialogs, and terminal displaybugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions