Claude Code MCP Timeout in Headless Runs: 3 Knobs Measured
A scheduled claude -p run has three separate MCP timers, and the symptoms they cause look alike: the run takes thirty seconds longer than it should, or the model reports that a tool you configured does not exist, or the job exits 0 with a server that never connected. Which timer you reach for depends on which phase went wrong, and the docs spread the answer across four pages.
This article puts the knobs in one table, then measures each one against a stdio MCP server that we made deliberately slow. Everything below was run on Claude Code 2.1.274 (released 2026-09-16) on Windows 11 with Node 24, using the haiku model alias, --strict-mcp-config, and --output-format stream-json so that the system/init event could be timed and read.
The short answer
| Knob | What it bounds | Default | Server status when it fires |
|---|---|---|---|
MCP_TIMEOUT | One server's connect attempt | 30,000 ms | failed; the connection is abandoned |
CLAUDE_CODE_MCP_STARTUP_WAIT_MS (new in 2.1.274) | How long the first -p turn waits for servers still connecting | The MCP_TIMEOUT deadline | pending; the server keeps connecting in the background |
MCP_TOOL_TIMEOUT, or a per-server timeout | One tool call, after the server is connected | 100,000,000 ms (about 28 hours) | Not a startup knob; the call errors |
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT | Silence during a tool call | 300,000 ms network, 1,800,000 ms stdio | The call aborts early |
MCP_CONNECTION_NONBLOCKING=0 plus MCP_CONNECT_TIMEOUT_MS | A blocking phase before system/init is even sent | Off; cap 5,000 ms when on | Servers still pending keep connecting |
The row that matters most for a slow scheduled job is the second one. Until 2.1.274, the first non-interactive turn waited for every --mcp-config server up to the full MCP_TIMEOUT, so a stalled server cost you thirty seconds per run and the only lever was to lower MCP_TIMEOUT, which also gave up on the connection. The new variable separates the two decisions: how long to hold the first turn, and how long to keep trying to connect.
One caveat on provenance: as of 2026-09-17 the new variable appears in the 2.1.274 changelog ("bound how long the first non-interactive turn waits for connecting MCP servers, 0 = don't wait") but not yet on the environment variables reference page. The behavior below is what we measured, not what a reference page says.
How we measured it
The server is fifty lines of dependency-free Node that answers initialize, tools/list and tools/call over stdio, exposing one echo tool. Two environment variables make it misbehave on purpose: SLOW_MS delays the initialize response, and HANG=1 never answers it at all.
// slow-server.js (excerpt)
if (msg.method === 'initialize') {
if (process.env.HANG === '1') return;
const reply = { jsonrpc: '2.0', id: msg.id, result: {
protocolVersion: msg.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: 'slow', version: '0.0.1' },
} };
setTimeout(() => send(reply), Number(process.env.SLOW_MS || 0));
}
The MCP config names it as a stdio server, and a small runner spawns Claude Code with stdin closed, records the wall-clock moment the system/init line and the result line arrive, and reads mcp_servers[].status and the tools array out of the init event:
claude -p "Reply with exactly the word OK and nothing else." \
--mcp-config mcp.json --strict-mcp-config \
--output-format stream-json --verbose \
--model haiku --max-turns 2 --allowedTools mcp__slow__echo
Closing stdin matters for the timing. The first baseline run printed "Warning: no stdin data received in 3s, proceeding without it" and reported init at 4.0 s; the same run with stdin closed reported 1.1 s. If your scheduler leaves stdin open, you are paying that 3 s on every run before any MCP timer starts.
Results
Each row is one run. "Init" is when system/init arrived, "result" is when the final result event arrived, both measured from process spawn.
| Run | Server | Environment | Init | Status at init | Result |
|---|---|---|---|---|---|
| Baseline | instant | defaults | 1.1 s | connected, echo listed | 2.5 s |
| Slow, default wait | 8 s delay | defaults | 8.7 s | connected, echo listed | 10.2 s |
| Slow, capped wait | 8 s delay | CLAUDE_CODE_MCP_STARTUP_WAIT_MS=2000 | 3.3 s | pending, no MCP tools | 4.5 s |
| Slow, no wait | 8 s delay | CLAUDE_CODE_MCP_STARTUP_WAIT_MS=0 | 1.1 s | pending, no MCP tools | 3.2 s |
| Slow, short connect deadline | 8 s delay | MCP_TIMEOUT=2000 | 3.0 s | failed, no MCP tools | 4.2 s |
| Hung | never answers | MCP_TIMEOUT=5000 | 5.5 s | failed, no MCP tools | 6.7 s |
Every run returned the word OK and exited 0, including the two whose only MCP server failed.
The default wait tracks the server exactly
With no overrides, an 8 s initialize delay moved system/init from 1.1 s to 8.7 s, and the server arrived connected with its tool in the list. This is the documented behavior: with --mcp-config under -p, Claude Code waits for still-pending servers before the first turn, up to MCP_TIMEOUT. It is the right default when the run needs the tool. It is thirty seconds of dead time per run when the server is broken.
Startup wait and MCP_TIMEOUT fail in different directions
The two "2 s" runs land at almost the same moment (3.3 s and 3.0 s) and mean opposite things. With CLAUDE_CODE_MCP_STARTUP_WAIT_MS=2000 the server was pending: Claude Code stopped holding the turn but kept the connection attempt alive. With MCP_TIMEOUT=2000 the server was failed: the attempt itself was abandoned at the deadline, and for a stdio server there is no automatic reconnect, so it stays failed for the rest of the session.
Pick by intent. If a slow server should still be usable later in a long run, cap the wait and leave MCP_TIMEOUT alone. If a server that cannot connect within a few seconds is a fault you want surfaced, lower MCP_TIMEOUT and gate on the status.
A failed server does not fail the run
Both failed runs exited 0 with subtype: "success". That is consistent with the headless docs, which say a --mcp-config entry that fails validation is skipped and "the run continues and exits cleanly", and it means a scheduled job can lose its only tool for weeks without a red build. The docs recommend gating on the init event, and after seeing exit 0 next to failed we agree. This reads the first line of the stream and fails on any status other than connected:
// gate.js: node gate.js < claude-output.jsonl
const lines = require('fs').readFileSync(0, 'utf8').split('\n').filter(Boolean);
const init = lines.map((l) => JSON.parse(l)).find((m) => m.type === 'system' && m.subtype === 'init');
const bad = (init.mcp_servers || []).filter((s) => s.status !== 'connected');
if (bad.length || (init.mcp_server_errors || []).length) {
console.error('MCP not ready:', JSON.stringify({ bad, errors: init.mcp_server_errors }));
process.exit(1);
}
Note the pending case: if you cap the startup wait, a healthy-but-slow server legitimately shows pending at init, so a gate that demands connected will fail runs that would have worked. Decide which you want before you add both.
With no wait, the first turn is built without the server
We then asked for the tool instead of the word OK: "Call the echo tool with text hello, then reply with only what it returned", with Bash disallowed so the model could not fake an echo, against a 3 s server delay.
With CLAUDE_CODE_MCP_STARTUP_WAIT_MS=0, init arrived at 1.3 s with the server pending and no MCP tools listed, and the model answered in one turn that it had no echo tool available. The server would have connected two seconds later; the first turn never saw it. With the default wait, init arrived at 3.7 s with the server connected, the model called ToolSearch at 6.6 s and mcp__slow__echo at 8.0 s, and returned echo: hello. Setting the wait to zero is a promise that the first turn does not need the server. For a one-turn job that promise is the same as not configuring the server.
What we now do in scheduled runs
- Pass servers explicitly and lock the set.
--mcp-confignames the servers and--strict-mcp-configignores every other MCP source, including claude.ai connectors and plugin servers on the host. 2.1.274 also fixed an edge case where--strict-mcp-configwith an empty--mcp-configstill held the first turn for incidental servers. - Lower
MCP_TIMEOUTto what the server actually needs, plus margin. Our stdio servers connect in about a second;MCP_TIMEOUT=10000gives them ten and turns a broken one into afailedstatus at ten seconds instead of thirty. - Gate on
system/init. The exit code will not tell you. The gate above is the whole mechanism. - Use the startup-wait cap only for multi-turn runs where the server is optional in the first turn, and expect
pendingat init when you do. - Consider
--barefor the same reason. It skips auto-discovery of.mcp.json, plugins and hooks entirely, so nothing but--mcp-configcan add a server. It also stops reading OAuth credentials, so it needsANTHROPIC_API_KEY; our scheduled-run guide covers that trade.
The tool-call timers in the table were not exercised here because they only start after a connection exists. Two facts from the docs are worth carrying anyway: MCP_TOOL_TIMEOUT defaults to about 28 hours, so the per-server timeout field in .mcp.json is the practical lever, and 2.1.274 fixed Streamable HTTP tool calls timing out at about five minutes even when a longer per-server timeout was set, so if you saw that ceiling before, retest.
FAQ
Why does my MCP server show pending in system/init?
Because the first turn stopped waiting before the server finished connecting. In -p with --mcp-config that happens when CLAUDE_CODE_MCP_STARTUP_WAIT_MS is set below the server's connect time, or for a remote HTTP or SSE server whose tool list Claude Code cached from a previous session: cached servers skip the wait by design, show pending, and connect on their first tool call. Servers loaded from settings files rather than --mcp-config get a shorter default wait, which is why .mcp.json servers commonly show pending in Agent SDK init messages.
What is the difference between MCP_TIMEOUT and MCP_TOOL_TIMEOUT?
MCP_TIMEOUT bounds the connection: how long Claude Code gives one server to answer initialize before marking it failed, 30 s by default. MCP_TOOL_TIMEOUT bounds a single tool call on an already connected server and defaults to 100,000,000 ms. A timeout field on one server's .mcp.json entry overrides the tool timeout for that server only, and values under 1000 are ignored there.
Does a failed MCP server make claude -p exit non-zero?
Not in our runs. Two runs with the only configured server at failed exited 0 with subtype: "success", because the model completed the prompt without the tool. Read mcp_servers[].status and mcp_server_errors from the system/init event if a missing server should fail the job.
Does Claude Code reconnect a stdio server that timed out?
No. The docs are explicit that stdio servers are local processes and are not reconnected automatically; reconnection with backoff applies to remote HTTP and SSE servers that drop mid-session, and transient first-connection failures on HTTP and SSE are retried up to three times. A stdio server that hits MCP_TIMEOUT stays failed for that session.
Is CLAUDE_CODE_MCP_STARTUP_WAIT_MS documented?
As of 2026-09-17 it is in the 2.1.274 changelog and not yet on the environment variables reference page. The measurements above are the evidence for what it does; check the reference page for the official entry if you are reading this later.