Skip to content

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 #39149

Description

@colonelpanic8

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-server driven over stdio by a third-party host daemon (Paseo); one app-server process per agent thread
  • Thread config: mcp_servers entry 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app-serverIssues involving app server protocol or interfacesbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) serverstool-callsIssues related to tool calling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions