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_completed → handle_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 status — app-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)
TUI: status-blind
McpToolCallarm inhandle_thread_itemroutesInProgressitems to the "completed" handler, surfacing spurious "MCP tool call completed without a result"Summary
In
codex-rs/tui/src/chatwidget.rs,handle_thread_itemdispatchesThreadItem::McpToolCallstraight to the completed handler without checkingstatus. Its sibling arms (CommandExecution,FileChange) explicitly gateInProgressto a different path. When anInProgressMCP item reacheshandle_thread_item(e.g. viareplay_thread_itemduring transcript replay, or any other future caller that fans both lifecycle stages through this entry point), it falls intoon_mcp_tool_call_completed→handle_mcp_tool_call_completed_now, which destructuresresult/error(bothOption, bothNoneonInProgress) 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-6117https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/tui/src/chatwidget.rs#L6107-L6117
Note the asymmetry on line 6117:
McpToolCallhas a single catch-all arm with nostatus:discriminator, even thoughThreadItem::McpToolCallcarries one (see below).ThreadItem::McpToolCalldoes carry astatus—app-server-protocol/src/protocol/v2/item.rs:280-294https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/item.rs#L280-L294
with
McpToolCallStatusdefined atitem.rs:976-980:The error path —
chatwidget.rs:4755-4790https://github.com/openai/codex/blob/rust-v0.130.0/codex-rs/tui/src/chatwidget.rs#L4755-L4790
On an
InProgresspayload,resultanderrorare bothNone→ the(None, None)arm fires.How
InProgressreacheshandle_thread_itemreplay_thread_itemforwards items straight through (chatwidget.rs:6041-6048):Replay does not filter on
statusfirst, so anInProgressMcpToolCall 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'sStdioServerTransport):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=traceconfirms the result arrived intact, but the TUI rendered the(None, None)error string anyway.Expected
The
McpToolCallarm inhandle_thread_itemshould mirror itsCommandExecution/FileChangesiblings: gatestatus: InProgresstoon_mcp_tool_call_started(or to a no-op + queue), and only routeCompleted/Failedtoon_mcp_tool_call_completed.Proposed fix
on_mcp_tool_call_startedalready exists atchatwidget.rs:3951and is the symmetric counterpart toon_mcp_tool_call_completed(thehandle_item_started_notificationpath atchatwidget.rs:6518already routes there). This change makes both lifecycle entry points behave consistently for MCP tool calls.Environment
rust-v0.130.0(also reproduced onmainat the time of report)codex --dangerously-bypass-approvals-and-sandbox@modelcontextprotocol/sdkover stdioFeature::MultiAgentV2not enabled)