Skip to content

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

Open

Description

@alario-tang

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 ...
  4. 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

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.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    mcpIssues related to the use of model context protocol (MCP) servers
    tool-callsIssues related to tool calling
    on Apr 22, 2026
  2. github-actions commented on Apr 22, 2026

    @github-actions
    Contributor

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

    Powered by Codex Action

  3. alario-tang commented on Apr 22, 2026

    @alario-tang
    Author

    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.

  4. removed
    CLIIssues related to the Codex CLI
    tool-callsIssues related to tool calling
    on Apr 22, 2026
  5. m13v commented on Apr 22, 2026

    @m13v

    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.

  6. MarcelSimon commented on Apr 23, 2026

    @MarcelSimon

    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:

    /tmp/minimal_stdio_mcp_exit_after_first_call.py
    #!/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}"},
            })

    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.

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

    bugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions