First-turn API call blocks until pending MCP tools/list times out

Open 💬 3 comments Opened Apr 25, 2026 by eyupcanakman
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.124.0 (also reproduced on 0.123.0; symptoms reported as far back as 0.114.0 in #14470, #14627)

What subscription do you have?

Pro

Which model were you using?

gpt-5.5 with reasoning xhigh

What platform is your computer?

macOS 26.5, arm64

What terminal emulator and version are you using (if applicable)?

Terminal CLI session

What issue are you seeing?

When any configured MCP server stalls on tools/list (or completes initialize slowly), the first turn after launch or session resume sits idle for the full startup_timeout_sec (default 30s) before any model API call goes out. The TUI shows Working (Xs) for the whole window without saying what it is waiting on. Ctrl+C during the wait aborts the turn with no assistant output.

In the original session that triggered this report (codex-cli 0.123.0, 4 newly added MCPs in ~/.codex/config.toml), the wait stretched to ~340s and ~470s on consecutive retries before I gave up and closed the session. The 0.124.0 wait is bounded by the 30s timeout but the same code path stalls every first turn.

What steps can reproduce the bug?

Save this as tools_list_hang_mcp_server.py (zero deps, stdlib only):

import json, sys
for raw in sys.stdin:
    line = raw.strip()
    if not line:
        continue
    req = json.loads(line)
    method = req.get("method")
    if method == "initialize":
        print(json.dumps({
            "jsonrpc": "2.0",
            "id": req["id"],
            "result": {
                "protocolVersion": "2024-11-05",
                "capabilities": {"tools": {"listChanged": False}},
                "serverInfo": {"name": "hang", "version": "0.1.0"},
            },
        }), flush=True)
    # tools/list and everything else: no response

Then resume any prior session with this server attached and submit a prompt:

codex resume <session-id> \
  -c 'mcp_servers.hang.command="python3"' \
  -c 'mcp_servers.hang.args=["/abs/path/tools_list_hang_mcp_server.py"]'
> Reply with OK.

A fresh session (codex with the same -c overrides) reproduces it too.

What is the expected behavior?

The first turn dispatches the API call without waiting on tools/list from every server. A slow MCP's tools become available on the next turn once its startup completes or times out.

Additional information

Rollout for the resumed turn shows the gap concretely:

L547  task_started   2026-04-25T11:37:53.449Z
L548  user msg       2026-04-25T11:38:04.592Z   (+11.1s)
L549  turn_context   2026-04-25T11:38:04.593Z
L550  user prompt    2026-04-25T11:38:20.234Z   (+15.6s)
L551  user_message event
L552  token_count    2026-04-25T11:38:20.895Z   (API call dispatched, +27.4s)
L553  agent_message  2026-04-25T11:38:25.580Z

A normal turn on the same session without the hanging MCP takes task_started to token_count under 700ms.

After the timeout fires the TUI shows:

⚠ MCP client for `hang` failed to start: MCP startup failed:
  timed out awaiting tools/list after 30s

That message arrives 30 seconds after the user submitted, with Working shown the whole time.

Root cause is in codex-rs/codex-mcp/src/mcp_connection_manager.rs:

  • AsyncManagedClient::listed_tools() (around L630) awaits self.client() for any server without a startup_snapshot, blocking on the full per-server startup future.
  • McpConnectionManager::list_all_tools() (L925) iterates every client and awaits each in turn, so one stalled server gates the whole map.
  • Session::run_turn calls list_all_tools() (codex-rs/core/src/session/turn.rs:190) before the API call.
  • The existing test list_all_tools_blocks_while_client_is_pending_without_startup_snapshot (mcp_connection_manager_tests.rs:687) asserts the blocking behavior, so the fix is a contract change to that test rather than a one-line patch.

The codex_apps server already has a startup_snapshot cache via load_startup_cached_codex_apps_tools_snapshot, so it is not affected. User-defined MCPs always go through the blocking path on first turn.

Possibly the same root cause behind older reports that lacked a deterministic repro: #14470, #14627, #11015. Each describes a silent first-turn hang with MCP servers active but never produced a reliable trigger.

I have a working patch (skip clients with startup_complete == false in listed_tools(), fall back to existing startup_snapshot) plus updated tests. Happy to open a PR if invited.

View original on GitHub ↗

3 Comments

github-actions[bot] contributor · 4 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #19542
  • #19284

Powered by Codex Action

eyupcanakman · 4 months ago

Looked at both. Neither is a dup. #19542 is on Windows in the sub-agent startup path. #19284 is the Cloudflare 403 on codex_apps connectors discovery, which only happens on long-lived ChatGPT-auth sessions; codex_apps also has the startup-snapshot cache so it bypasses the blocking path here. Mine reproduces with one user-defined stdio MCP that never answers tools/list, on a fresh session.

dpearson2699 · 2 months ago

Adding a current Codex Desktop data point that appears to hit this same list_all_tools / pending MCP startup path.

Environment:

  • Codex app-server / Desktop logs report app.version=0.136.0-alpha.2
  • macOS, arm64
  • Model: gpt-5.5, reasoning effort xhigh
  • 11 MCP servers configured
  • Affected user-defined stdio MCP server: claude-context, configured as:
[mcp_servers.claude-context]
command = "npx"
args = ["@zilliz/claude-context-mcp@latest"]
startup_timeout_sec = 15

Observed behavior:

  • A new subagent/thread sat at “thinking” with no assistant output.
  • No request was sent to the configured model provider/proxy during the hang.
  • The local proxy had only its listening socket open; there were no established connections from Codex.
  • Nearby sibling threads using the same MCP server completed normally and listed claude-context tools successfully.
  • Killing/restarting the thread matched the usual symptom: the retry can proceed once the MCP startup path is warm/healthy.

Most relevant log rows from ~/.codex/logs_2.sqlite for the stuck thread:

startup websocket prewarm timed out before the first turn could use it
startup.phase="startup_prewarm_resolve" startup.status="timed_out" duration_ms=14942

built_tools{...}:list_all_tools{mcp_server_count=11}:
  waiting for MCP server tools while building tool list
  server_name=muninn has_cached_tool_info_snapshot=false startup_complete=true

built_tools{...}:list_all_tools{mcp_server_count=11}:
  listed MCP server tools while building tool list
  server_name=muninn tool_count=39

built_tools{...}:list_all_tools{mcp_server_count=11}:
  waiting for MCP server tools while building tool list
  server_name=claude-context has_cached_tool_info_snapshot=false startup_complete=false

There were no subsequent listed MCP server tools rows for that thread/server before interrupt. The thread remained active_turn_status=InProgress; a later status-check prompt queued behind it and also could not progress. The interrupt then logged:

interrupt received: abort current task, if any
aborting running task task_kind=Regular

For comparison, nearby successful threads had the expected pair:

waiting for MCP server tools ... server_name=claude-context has_cached_tool_info_snapshot=false startup_complete=true
listed MCP server tools ... server_name=claude-context tool_count=4

Local health after restarting Docker:

  • Docker Desktop healthy.
  • Milvus/MinIO/etcd containers were running and healthy.
  • claude-context answered an index-status check for the affected worktree: fully indexed, 505 files, 2546 chunks.
  • Multiple claude-context-mcp child processes were present under the Codex app-server parent, so the server is being launched locally.

Local mitigation applied:

[mcp_servers.claude-context]
startup_timeout_sec = 120

That may reduce the chance of hitting the bad path during cold Docker/Milvus startup, but it does not address the core behavior: once a server has startup_complete=false and no cached tool snapshot, list_all_tools can still keep the first turn/subagent from dispatching any model request. The expected behavior would be to skip pending optional MCP servers for that turn, use ready/cached tool snapshots, and surface the MCP startup state separately instead of silently blocking the first model request.