codex exec on macOS initializes external MCP and lists tools but never sends tools/call

Open 💬 5 comments Opened Mar 9, 2026 by thiagohirai

Summary

codex exec on macOS can initialize an external stdio MCP server and successfully list its tools, but then never dispatch a tools/call request. The session hangs after the model says it is about to use the MCP tool.

This reproduces with:

  • a real Playwright MCP server
  • a fake one-tool MCP server that does not depend on Playwright or browsers at all

That suggests the bug is in Codex CLI external MCP dispatch, not in Playwright.

Environment

  • Codex CLI: 0.111.0
  • OS: macOS 26.2 (25C56)
  • Hardware: Apple Silicon (arm64)

Minimal repro

Create a fake stdio MCP server:

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

tools = [{
    "name": "browser_navigate",
    "description": "Navigate to a URL",
    "inputSchema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {"url": {"type": "string"}},
        "required": ["url"],
        "additionalProperties": False,
    },
    "annotations": {
        "title": "Navigate to a URL",
        "readOnlyHint": False,
        "destructiveHint": True,
        "openWorldHint": True,
    },
}]

for raw in sys.stdin:
    msg = json.loads(raw)
    method = msg.get("method")
    msg_id = msg.get("id")
    if method == "initialize":
        print(json.dumps({
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": {
                "protocolVersion": "2025-06-18",
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "FakeBrowser", "version": "0.0.1"},
            },
        }), flush=True)
    elif method == "tools/list":
        print(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": {"tools": tools}}), flush=True)
    elif method == "tools/call":
        print(json.dumps({
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": {"content": [{"type": "text", "text": "ok"}]},
        }), flush=True)

Then run:

codex -a never -s workspace-write exec --json --ephemeral --skip-git-repo-check \
  -C /tmp/codex-mcp-repro \
  -c 'sandbox_workspace_write.network_access=true' \
  -c 'mcp_servers.playwright.command="/tmp/fake_browser_mcp.py"' \
  -c 'mcp_servers.playwright.args=[]' \
  'Use the Playwright MCP tool browser_navigate exactly once to open http://127.0.0.1:52922/app/. Do not run shell commands. After the tool returns, reply with DONE.'

Observed behavior

  • Codex starts normally.
  • The model emits an assistant message like "Opening the requested URL with Playwright once..."
  • The external MCP server receives:
  • initialize
  • notifications/initialized
  • tools/list
  • The external MCP server never receives tools/call.
  • codex exec hangs until killed.

Example fake-server log:

recv {"jsonrpc":"2.0","id":0,"method":"initialize",...}
send {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18",...}}
recv {"jsonrpc":"2.0","method":"notifications/initialized"}
recv {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}
send {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"browser_navigate",...}]}}

tools/call never arrives.

Expected behavior

After tool discovery, Codex should send a normal tools/call request to the external MCP server, or return a surfaced error if dispatch fails internally.

Additional notes

  • I reproduced the same pattern with @playwright/mcp as the external server.
  • A direct stdio MCP client can talk to the same Playwright MCP server and successfully call browser_navigate, so the server side appears healthy.
  • Related issues I found: #4117, #6127, #6664, #5914.

View original on GitHub ↗

5 Comments

thiagohirai · 5 months ago

Author note: this comment text was authored by Codex, a GPT-5-based coding agent, and posted via @thiagohirai's GitHub account. The exact internal model variant is not exposed in this environment. The local repros below were run by the Codex agent on March 18-19, 2026.

Follow-up: I reran this on a newer local setup and the failure mode appears to have shifted, but it still looks like the same bug family.

Tested CLI versions:

  • codex-cli 0.115.0 (globally installed)
  • codex-cli 0.116.0 (installed into /tmp just for repro)

With the same kind of fake stdio MCP server used in the original report, codex exec no longer gets as far as initialize / tools/list. Instead, in both versions above:

  • the fake MCP server log stays completely empty
  • no initialize
  • no notifications/initialized
  • no tools/list
  • no tools/call
  • codex exec exits normally and the model replies UNAVAILABLE instead of hanging

I reproduced that with:

  • CLI-injected config via -c 'mcp_servers.fake.command=...' and -c 'mcp_servers.fake.args=[...]'
  • project-scoped .codex/config.toml
  • required = true

So compared with the original March 9, 2026 report:

  • 0.111.0: external MCP server initializes and lists tools, but tools/call never arrives and the session hangs
  • 0.115.0 / 0.116.0: the external MCP server is not started at all in codex exec, and the run falls back to a normal text reply

This still points to codex exec external MCP handling rather than Playwright itself, but the regression point now appears to be earlier in the pipeline than what I originally reported.

If useful, I can add the exact smoke-test script I used for the March 18-19, 2026 repro. It is a tiny one-tool stdio MCP server that logs every request.

thiagohirai · 5 months ago

Author note: this comment text was authored by Codex, a GPT-5-based coding agent, and posted via @thiagohirai's GitHub account. The exact internal model variant is not exposed in this environment.

As promised, here is the exact tiny stdio MCP repro I used for the March 18-19, 2026 follow-up.

It logs every MCP request to a file so it is easy to distinguish these cases:

  • server never started
  • server initialized but tools/call never arrived
  • tool call succeeded

Fake MCP server

Save as fake_mcp_server.py:

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


LOG_PATH = "/tmp/fake_mcp_server.log"


def _read_message():
    headers = {}
    while True:
        line = sys.stdin.buffer.readline()
        if not line:
            return None
        if line in (b"\r\n", b"\n"):
            break
        name, _, value = line.decode("utf-8").partition(":")
        headers[name.strip().lower()] = value.strip()
    content_length = int(headers.get("content-length", "0"))
    if content_length <= 0:
        return None
    payload = sys.stdin.buffer.read(content_length)
    if not payload:
        return None
    return json.loads(payload.decode("utf-8"))


def _write_message(message):
    body = json.dumps(message).encode("utf-8")
    sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8"))
    sys.stdout.buffer.write(body)
    sys.stdout.buffer.flush()


def _log(payload):
    with open(LOG_PATH, "a", encoding="utf-8") as handle:
        handle.write(json.dumps(payload, sort_keys=True))
        handle.write("\n")


def _tool_response(text):
    return {
        "content": [
            {
                "type": "text",
                "text": text,
            }
        ],
        "isError": False,
    }


while True:
    request = _read_message()
    if request is None:
        _log({"event": "eof"})
        break

    _log({"event": "request", "payload": request})
    method = request.get("method")
    request_id = request.get("id")
    params = request.get("params") or {}

    if method == "initialize":
        _write_message(
            {
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "protocolVersion": params.get("protocolVersion", "2025-03-26"),
                    "capabilities": {"tools": {}},
                    "serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
                },
            }
        )
        _log({"event": "response", "method": method, "id": request_id})
        continue

    if method == "notifications/initialized":
        _log({"event": "notification", "method": method})
        continue

    if method == "tools/list":
        _write_message(
            {
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "tools": [
                        {
                            "name": "ping",
                            "description": "Return a predictable text response.",
                            "inputSchema": {
                                "type": "object",
                                "properties": {
                                    "message": {"type": "string"}
                                },
                                "additionalProperties": False,
                            },
                        }
                    ]
                },
            }
        )
        _log({"event": "response", "method": method, "id": request_id})
        continue

    if method == "tools/call":
        name = params.get("name")
        arguments = params.get("arguments") or {}
        if name == "ping":
            text = f"pong:{arguments.get('message', '')}"
            _write_message(
                {
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "result": _tool_response(text),
                }
            )
            _log(
                {
                    "event": "response",
                    "method": method,
                    "id": request_id,
                    "tool": name,
                    "arguments": arguments,
                }
            )
        else:
            _write_message(
                {
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "error": {
                        "code": -32601,
                        "message": f"Unknown tool: {name}",
                    },
                }
            )
            _log(
                {
                    "event": "response",
                    "method": method,
                    "id": request_id,
                    "tool": name,
                    "error": "unknown_tool",
                }
            )
        continue

    if request_id is not None:
        _write_message(
            {
                "jsonrpc": "2.0",
                "id": request_id,
                "error": {
                    "code": -32601,
                    "message": f"Unsupported method: {method}",
                },
            }
        )
        _log({"event": "response", "method": method, "id": request_id, "error": "unsupported"})

Repro command

rm -f /tmp/fake_mcp_server.log
codex exec --json \
  -c 'mcp_servers.fake.command="python3"' \
  -c 'mcp_servers.fake.args=["/tmp/fake_mcp_server.py"]' \
  'You must call the external MCP tool named ping with message hello. Reply with exactly the tool result. If the tool is unavailable, reply UNAVAILABLE.'

Expected if MCP dispatch works

The log should contain at least:

  • initialize
  • notifications/initialized
  • tools/list
  • tools/call

And the model should reply:

pong:hello

What I observed on March 18-19, 2026

On codex-cli 0.115.0 and 0.116.0 on macOS, the log stayed completely empty and the model replied:

UNAVAILABLE

So in that environment the external MCP server was not even initialized.

thiagohirai · 4 months ago

Author note: this comment text was authored by Codex, a GPT-5-based coding agent, and posted via @thiagohirai's GitHub account. The exact internal model variant is not exposed in this environment.

I reran this on the current local stable CLI:

  • codex-cli 0.122.0
  • macOS arm64

I found that there are now at least two distinct behaviors mixed together under this issue.

1. External stdio MCP can still fail before tools/call, but the exact cause matters

With a tiny line-delimited JSON fake MCP server, codex exec does successfully:

  • initialize
  • notifications/initialized
  • tools/list

and the server/tool are exposed under the expected namespaced tool name form, e.g.:

  • server fake
  • tool ping
  • model-visible tool name mcp__fake__ping

So on 0.122.0, the current tool name is not bare ping; it is the namespaced MCP form.

2. The current headless failure mode can be caused by MCP approval, not raw dispatch

Using that same line-delimited fake server, I saw this behavior:

  • codex exec recognizes the MCP tool and starts an MCP tool call item
  • but the call is cancelled locally before tools/call is sent
  • the item error is user cancelled MCP tool call

When I added this config override:

-c 'mcp_servers.fake.default_tools_approval_mode="approve"'

the exact same repro succeeded end-to-end and the fake server received:

  • initialize
  • notifications/initialized
  • tools/list
  • tools/call

and returned:

pong:hello

So at least on my current machine, one real cause of the “never sends tools/call” symptom in codex exec is:

  • external MCP tool requires approval in a headless exec session
  • exec cancels it instead of surfacing a clear error

3. There may still be a separate stdio transport/protocol compatibility issue

When I used a fake server that speaks MCP-style Content-Length framing instead of newline-delimited JSON, the process was launched but initialization did not complete.

So there may still be a separate stdio framing/transport issue here as well, depending on what protocol shape the external server uses.

Suggested product fix

Even if the approval behavior is intentional, codex exec should not silently degrade into “tool unavailable” / a hung-looking session for headless external MCP calls.

It would be much easier to diagnose if codex exec surfaced something explicit like:

  • this MCP tool requires approval in the current session
  • set default_tools_approval_mode = "approve" or a tool-specific override for headless use

In short:

  • 0.122.0 can send tools/call successfully for external stdio MCP
  • but headless approval can prevent that and currently looks like a dispatch failure
  • there may also still be a separate framing compatibility issue for some stdio servers
dryoungdo · 3 months ago

Adding a data point — same symptom, different MCP server, persisted across 0.130.0 and 0.133.0 (the current local stable).

Environment

  • Codex CLI: 0.130.0 (when first observed 2026-05-24) → 0.133.0 (still present 2026-05-25)
  • OS: macOS 15.x arm64 (Apple Silicon)
  • MCP server: arra-oracle — Bun/TypeScript, stdio, line-delimited JSON (NOT Content-Length framing)

Symptom (identical to original report)

codex review --uncommitted
# → hangs indefinitely at "mcp: arra-oracle/arra_search started"

Codex initializes the MCP, lists tools successfully, then never dispatches tools/call. Same shape as the OP.

Working-case isolation (independent MCP client)

A standard @modelcontextprotocol/sdk client against the same arra-oracle MCP server completes the full flow in 218ms:

[transport-create] 2ms
[connect] 97ms         ← initialize + initialized + tools/list
[listTools] 2ms — 20 tools
[callTool arra_search] 113ms   ← tools/call works perfectly
[close] 4ms
=== TOTAL: 218ms ===

Repro source: <https://github.com/dryoungdo/arra-oracle-v3/blob/fix/mcp-tool-timeout-2026-05-24-glueboy-58/test/manual-mcp-client.ts>

So the server speaks line-delimited JSON correctly + answers tools/call correctly. This points to codex's MCP/approval dispatch path rather than the server's basic tools/call handling.

Cross-reference to your 0.122.0 analysis (excellent breakdown)

Your comment narrowed the symptom to two distinct causes:

  1. Headless approval cancellation — silent
  2. Possible stdio framing compatibility issue (Content-Length vs line-delimited)

For my case:

  • ✅ Server is line-delimited JSON (so framing cause #2 should NOT apply)
  • ❓ I have NOT yet tested whether default_tools_approval_mode = "approve" on the [mcp_servers.arra-oracle] block resolves it — will follow up

If approval-mode IS the fix for line-delimited servers too, then the product-fix suggestion in your comment is the right one: codex exec should surface "this MCP tool requires approval in headless session" instead of silent cancellation that looks like a hang.

Suggested fix priority (echoing your comment)

A clear diagnostic message in codex exec when headless approval blocks a tools/call would prevent this from looking like a "codex CLI is broken" bug. Related MCP-hang reports (#14115, #16086, #11015, #19542) would be easier to triage with an explicit headless-approval diagnostic where applicable.

My current workaround

[mcp_servers.arra-oracle] commented out in ~/.codex/config.toml. Will retest with default_tools_approval_mode = "approve" when I have a safe time to test (codex tile in a worktree session, isolated from my main work).

TunaTung · 27 days ago

Same symptom on Windows / 0.146.0 / DeepSeek official setup: the built-in list_mcp_resources wrapper was present but the actual MCP tools never reached the model ("MCP server 'hermes' was not ready" / no tools in the model's toolset). Root cause in our case turned out to be models.json declaring supports_search_tool=true + tool_mode=null for the deepseek models — that combination silently hides MCP tools in exec mode. Setting supports_search_tool: false exposed all tools and calls then completed (29s round trip, real answer). Full writeup in #36382.