macOS: stdio MCP tool calls can fail with raw `Transport closed` even when the underlying operation is healthy

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

Summary

On macOS, stdio MCP servers can fail with Transport closed even when the server logic itself is healthy and the same operation succeeds via a non-MCP parity path.

What looks wrong from the outside is that Codex's MCP host/transport lifecycle seems able to drop or replace the stdio connection in a way that leaves the tool call with a stale/closed transport handle.

In my case this showed up with local OMX MCP servers (omx_state, etc.), but the evidence suggests the more general problem is on the Codex MCP host/stdio lifecycle side, not in the specific state logic.

What version of Codex is running?

codex-cli 0.122.0

What subscription do you have?

Pro

Which model were you using?

Observed inside Codex App / Codex CLI session; model choice does not seem relevant to the failure.

What platform is your computer?

macOS (Apple Silicon)

What issue are you seeing?

Intermittent MCP tool failures like:

tool call error: tool call failed for `omx_state/state_write`

Caused by:
    Transport closed

Key observations:

  1. omx doctor reports the environment is healthy.
  2. codex mcp list shows the OMX MCP servers are enabled.
  3. The same state operations succeed via CLI parity:
  • omx state write ...
  • omx state read ...
  1. So the state logic itself is healthy; the failure is specific to the MCP tool transport path.

I also locally verified that the OMX stdio MCP server exits immediately when stdin/transport is closed, which means Codex-side transport churn can surface directly as Transport closed.

What steps can reproduce the bug?

I do not yet have a minimal reproduction using a public MCP server, but here is the concrete local evidence path:

  1. Run a Codex session with stdio MCP servers configured.
  2. Use MCP tools successfully for a while.
  3. Later in the same long-running session, a tool call may fail with Transport closed.
  4. The same operation still succeeds immediately via a direct CLI parity command outside the MCP tool path.

Additional local debugging:

  • The OMX MCP server uses StdioServerTransport and shuts down on:
  • stdin_end
  • stdin_close
  • transport_close
  • parent_gone
  • With OMX_MCP_TRANSPORT_DEBUG=1, manually closing stdin reproduces a clean server shutdown with:
[omx-state-server] transport shutdown: stdin_end
[omx-state-server] transport shutdown: exit

This does not prove Codex is always at fault, but it does show that if Codex drops/replaces the stdio transport, the MCP server will terminate and the tool call will surface as Transport closed.

What is the expected behavior?

For stdio MCP servers:

  1. Codex should avoid exposing stale/closed transport handles to tool calls where possible.
  2. If the host has restarted/replaced the MCP client process, Codex should reconnect/retry rather than surfacing a raw Transport closed immediately.
  3. At minimum, the error should distinguish between:
  • server exited intentionally
  • host transport was closed/replaced
  • handshake/startup failure
  • tool execution failed after a healthy connection

Why I think this is a Codex-side issue

Because the exact same logical operation succeeds through CLI parity using the same underlying handler, which strongly suggests:

  • the underlying operation is fine
  • the failure is in the MCP host/client transport lifecycle

Related but not identical issues

  • #7155 — Windows-specific Transport closed with heavy stderr output
  • #6128 — persistent MCP / per-call restart behavior

This report is different: it is about macOS stdio MCP lifecycle robustness and stale transport closure surfacing as raw Transport closed during otherwise healthy local tool usage.

Additional information

If helpful, I can provide a more minimal reproduction, but I wanted to file this first because the pattern seems broader than the specific OMX tool.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 4 months ago

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

  • #18486

Powered by Codex Action

alario-tang · 4 months ago

Additional concrete evidence from my machine:

Environment

  • Codex CLI: 0.122.0
  • oh-my-codex: 0.14.2
  • Node: v25.9.0
  • macOS: 26.4.1 (25E253)

What still works

  • omx doctor passes cleanly: 14 passed, 0 warnings, 0 failed
  • CLI parity for the same state operation succeeds:
  • omx state write --input '{"mode":"ralph","active":false,"current_phase":"complete","workingDirectory":"/Users/harrytang/Documents/GitHub/harry-study"}' --json
  • omx state read --input '{"mode":"ralph","workingDirectory":"/Users/harrytang/Documents/GitHub/harry-study"}' --json

Observed behavior in OMX's MCP bootstrap code

  • /opt/homebrew/lib/node_modules/oh-my-codex/dist/mcp/bootstrap.js
  • It uses StdioServerTransport.
  • The server shuts down on all of these transport/lifecycle events:
  • stdin_end (lines 243-245)
  • stdin_close (lines 246-248)
  • transport_close via transport.onclose (lines 263-266)
  • parent_gone watchdog path (lines 187-193, 217-241)
  • There is also duplicate-sibling self-exit logic (lines 195-214), which means process churn / host-side replacement can also cause the older MCP child to self-terminate.

Minimal local proof that the MCP server exits immediately when stdio dies

OMX_MCP_TRANSPORT_DEBUG=1 node /opt/homebrew/lib/node_modules/oh-my-codex/dist/mcp/state-server.js </dev/null

Output:

[omx-state-server] transport shutdown: stdin_end
[omx-state-server] transport shutdown: exit

Why I think this matters on the Codex side

  • The underlying operation is healthy when invoked through OMX CLI parity.
  • The OMX MCP server is intentionally coupled to stdio / parent lifecycle.
  • So when Codex/App drops, replaces, or races the MCP stdio transport, the downstream OMX server exits quickly and the tool call surfaces as raw Transport closed.

That makes this feel less like an OMX business-logic failure, and more like Codex's MCP host lifecycle needs to be more defensive around short-lived disconnects / transport replacement / child-process churn.

m13v · 4 months ago

hit this exact class of bug on macOS stdio MCP with a different host. the pattern that burned me worst was stderr backpressure. darwin pipes buffer ~64KB, once the host stops draining stderr (e.g. during a long tool call), the server's next log write blocks, the client's health probe trips, it replaces the transport, the old server sees transport_close from the sdk and exits cleanly. new tool call lands on the respawned child and from outside it looks exactly like yours. worth checking if codex actively drains stderr across long sessions, and whether parent_gone is PID-based since macOS recycles PIDs fast enough to false-positive during fork churn.

MarcelSimon · 4 months ago

I think I have a minimal non-OMX repro for this same underlying issue, plus a focused local fix/validation path.

The key difference from the original report is that this repro does not require OMX. It uses a tiny standalone stdio MCP server that returns one successful tool result, then exits. Installed Codex then reuses the stale stdio client and the next tool call fails with Transport closed.

The same local fix also resolved the OMX MCP failures I originally observed (omx_trace, omx_state, and omx_memory calls returning Transport closed), so the minimal repro below appears to capture the shared underlying stdio client lifecycle problem.

Minimal non-OMX repro

Create this temporary MCP server:

<details>
<summary><code>/tmp/minimal_stdio_mcp_exit_after_first_call.py</code></summary>

#!/usr/bin/env python3
import json
import sys
import threading
import os

calls = 0

def send(msg):
    sys.stdout.write(json.dumps(msg, separators=(",", ":")) + "\n")
    sys.stdout.flush()

def schedule_exit():
    def _exit():
        os._exit(0)
    threading.Timer(0.05, _exit).start()

for line in sys.stdin:
    if not line.strip():
        continue
    req = json.loads(line)
    if "id" not in req:
        continue
    method = req.get("method")
    req_id = req["id"]

    if method == "initialize":
        send({
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {
                "protocolVersion": req.get("params", {}).get("protocolVersion", "2025-06-18"),
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "minimal-exit-after-first-call", "version": "0.0.1"},
            },
        })
    elif method == "tools/list":
        send({
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {
                "tools": [{
                    "name": "echo",
                    "description": "Echo a message, then the server exits after the first call.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {"message": {"type": "string"}},
                        "required": ["message"],
                        "additionalProperties": False,
                    },
                }]
            },
        })
    elif method == "tools/call":
        calls += 1
        message = req.get("params", {}).get("arguments", {}).get("message", "")
        send({
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {
                "content": [{"type": "text", "text": f"ECHOING: {message}"}],
                "isError": False,
            },
        })
        if calls == 1:
            schedule_exit()
    else:
        send({
            "jsonrpc": "2.0",
            "id": req_id,
            "error": {"code": -32601, "message": f"Method not found: {method}"},
        })

</details>

Then run:

cat > /tmp/minimal_stdio_mcp_exit_after_first_call.py <<'PY'
# paste script above
PY
chmod +x /tmp/minimal_stdio_mcp_exit_after_first_call.py
python3 -m py_compile /tmp/minimal_stdio_mcp_exit_after_first_call.py

PROMPT='Use the configured MCP server minimal_stdio. Do not use any fallback if an MCP call fails. First call its echo tool with message "warmup". Then run shell command `sleep 0.2` only to allow the stdio server to exit. Then call the same echo tool again with message "recovered". Report the exact result or exact error for each MCP call.'

codex exec \
  --ephemeral \
  --skip-git-repo-check \
  --cd /tmp \
  --dangerously-bypass-approvals-and-sandbox \
  -c 'mcp_servers.minimal_stdio.command="python3"' \
  -c 'mcp_servers.minimal_stdio.args=["/tmp/minimal_stdio_mcp_exit_after_first_call.py"]' \
  -c 'mcp_servers.minimal_stdio.enabled=true' \
  -c 'mcp_servers.minimal_stdio.startup_timeout_sec=5' \
  -c 'mcp_servers.minimal_stdio.tool_timeout_sec=5' \
  "$PROMPT"

Observed behavior on installed Codex

Version tested locally: codex-cli 0.122.0.

The first MCP tool call succeeds:

[{"type":"text","text":"ECHOING: warmup"}]

After sleep 0.2, the second MCP call fails in the same session:

tool call error: tool call failed for `minimal_stdio/echo`

Caused by:
    Transport closed

This happens almost immediately in a fresh session, not only in a long-running session: launch, first MCP call, short sleep, second MCP call.

Local fix direction I validated

The behavior appears to be in the shared stdio RMCP client lifecycle rather than in a specific MCP server. A fresh stdio client can call the server successfully, but the cached client remains in a closed state after the server exits.

Likely affected area:

  • codex-rs/rmcp-client/src/rmcp_client.rs

I tested a focused local change that treats rmcp::service::ServiceError::TransportClosed as a recoverable disconnect and reuses the existing reinitialize-and-retry path for one retry.

Validation from local patched Codex:

  • focused regression test passes:
cargo test -p codex-rmcp-client stdio_transport_closed_recovers_and_retries_once
  • runtime A/B with the standalone Python server above:
  • installed Codex: second MCP call fails with Transport closed
  • patched local Codex: second MCP call reconnects and returns:
[{"type":"text","text":"ECHOING: recovered"}]
  • the same patched local build also stopped the observed OMX MCP Transport closed failures for direct omx_trace, omx_state, and omx_memory tool calls in my environment

Suggested acceptance criteria

  • Add a stdio recovery regression test where the stdio server exits after one successful tool call.
  • Preserve existing streamable HTTP recovery behavior.
  • Keep recovery in the shared RMCP client layer, not in any specific MCP server.
  • Retry at most once for this clear transport-closed path.

If this aligns with the intended MCP client lifecycle design, I can open a focused PR with the minimal fix and regression test.