MCP tool approval elicitation can pend forever with no timeout or turn-state signal (headless app-server clients hang silently); rmcp is_closed() misses self-terminated service tasks
_This issue was rewritten after the investigation concluded, so it stands alone as a single correct report; the earlier body and follow-up comments described mechanisms that turned out to be wrong._
Summary
An MCP tool approval elicitation can pend indefinitely: no timeout, no model-visible signal, and no turn-state indication that an approval is being awaited. For an app-server client that drives a headless/hidden agent with no approval surface, this presents as a tool call that hangs forever with no error, no log line, and no HTTP request ever leaving the process.
A second, adjacent bug found en route: rmcp 3.0.0's RunningService::is_closed() cannot observe a service task that terminated on its own, and codex's connection reconciliation reuses connections based on that flag.
Version / environment
codex-cli 0.147.0(official release binary), Linux x86_64 (NixOS)codex app-serverdriven over stdio by a third-party host daemon (Paseo); one app-server process per agent thread- Thread config:
mcp_serversentry for a local streamable-HTTP MCP server,approval_policy=on-request,sandbox=workspace-write - Models:
gpt-5.6-*family,model_info.tool_mode = code_mode_only - Realtime conversations (
thread/realtime/start,version: "v3") in the affected flow
Symptom
Our daemon spawns an internal codex agent as a Live Voice host. This agent is hidden: it has no UI, and the client presents no approval surface for it (normal, user-visible agents do).
When that hidden agent issues an MCP tool call:
- The call hangs forever (observed 801.8 s, until the user hung up). No error, no timeout.
- No HTTP request ever reaches the MCP server — confirmed server-side and via
netstat(no TCP connection from the app-server process). - Nothing is logged, and nothing in the turn state indicates that anything is being awaited. The model sees only an unresolved tool call.
- On turn abort, the rollout records:
mcp_tool_call_end: result Err "user cancelled MCP tool call", duration { secs: 0, nanos: 0 }
Root cause
The call reaches maybe_request_mcp_tool_approval in core/src/mcp_tool_call.rs. Tools that lack auto-approving annotations raise an MCP tool approval elicitation. Our client surfaces those elicitations for ordinary agents, but the hidden voice host had no surface for them — so the elicitation pended forever.
The "user cancelled MCP tool call" / duration: 0s record above is verbatim the McpToolApprovalDecision::Cancel arm firing on turn abort. It is the epitaph of an unanswered approval, not evidence of a transport failure.
Read-annotated tools auto-approve. That is why the failure appeared to correlate with realtime/voice sessions and with the MCP transport for days: voice-host calls hit non-read tools while every control probe called read tools and passed.
This is resolved on our side by pre-approving the relevant tools for the hidden host ([mcp_servers.<server>.tools.<name>] approval_mode = "approve"; a per-thread config merge works for this).
What we'd like from codex
(a) A pending approval elicitation should be observable and bounded.
Any of these would have turned a multi-day diagnosis into a few minutes:
- a bounded wait on the elicitation, surfacing a normal tool error on expiry;
- a narratable "awaiting approval" state in the turn, so a client (and the model) can see why nothing is progressing;
- at minimum, a log line when an approval elicitation is raised and when it is answered.
More generally: a tool call blocked on a request that the client may never be able to answer should fail fast rather than wait forever. We added a 60 s timeout on code-mode nested dispatch locally, and it cleanly converts the silent hang into a model-visible tool error.
(b) rmcp 3.0.0 RunningService::is_closed() misses self-terminated service tasks.
is_closed() is handle.is_none() || cancellation_token.is_cancelled(), so a service task that terminated on its own is still reported as open. Checking JoinHandle::is_finished() fixes it. This belongs in modelcontextprotocol/rust-sdk, but it matters here because codex's connection reconciliation reuses connections based on this flag, and a dead-but-"open" connection can be retained.
Regression coverage we have
In codex-rs/app-server/tests/suite/v2/:
- realtime v3 handoff → delegated turn → nested code-mode MCP call
- HTTP MCP server surviving a runtime refresh (
config/mcpServer/reload) with a bounded-timeout call afterwards - terminated-service fail-fast (a call against a terminated service must error rather than hang)
Happy to open PRs for the nested-dispatch timeout, the rmcp is_closed() fix, and these tests.