[BUG] Stdio MCP tool calls default to 1-second timeout in v2.1.148 — long-running tools fail before server can respond

Status Fixed / completed
Reported on v2.1.148
Maintainer reply ✓ Yes — localden
Activity 3 comments · opened May 25, 2026 · closed Jun 9, 2026
💡 Likely answer: A maintainer (localden, collaborator) responded on this thread — see the highlighted reply below.

Observed

In Claude Code v2.1.148, every tool call to a stdio MCP server aborts at ~1001ms with:

MCP server "<name>" tool "<name>" timed out after 1s

This happens even when no MCP_TOOL_TIMEOUT env var is set and the server config in ~/.claude.json has no timeout field. The server's correct response arrives a few seconds later but is discarded by the host:

Connection error: Received a response for an unknown message ID: { ... }

HTTP MCP transports are unaffected — the same session can call HTTP MCP tools that take 5+ seconds and they return normally.

Expected

Either:

  • The default stdio MCP tool timeout matches what's documented for other transports (much higher than 1s — see "Hypothesis" below), or
  • The host waits for the server's response and surfaces it normally.

Repro

Any stdio MCP server with a tool that takes >1s to respond reproduces this. The shortest off-the-shelf example uses @openai/codex, which is publicly available via npm:

# Install codex CLI (the MCP server)
npm install -g @openai/codex

# Register it as a stdio MCP server
claude mcp add --scope user --transport stdio \
  --env=OPENAI_API_KEY=<your-openai-key> \
  codex -- codex mcp-server

Then in a CC v2.1.148 session, ask the assistant to call the codex MCP tool with any prompt. The tool call fails at ~1001ms.

For anyone without an OpenAI key handy: any stdio MCP server with a tool that sleeps >1s before returning will reproduce it. A ~30-line Python/Node stdio JSON-RPC server is sufficient.

Logs

CC writes per-call logs at ~/Library/Caches/claude-cli-nodejs/<encoded-cwd>/mcp-logs-<server>/<timestamp>.jsonl. Trimmed sample from one failed call:

17:52:50.878  Calling MCP tool: codex
17:52:51.879  Tool 'codex' failed after 1s: MCP server "codex" tool "codex" timed out after 1s
17:52:56.463  Connection error: Received a response for an unknown message ID:
              {"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"hello"}], ...}}

Connect was healthy (~65ms). The server returned a valid response 5.5s after the request. CC had already cancelled request id 5 at the 1.001s mark.

Config baseline

mcpServers.codex in ~/.claude.json when the bug fires — no timeout anywhere:

{
  "mcpServers": {
    "codex": {
      "type": "stdio",
      "command": "codex",
      "args": ["mcp-server"],
      "env": {"OPENAI_API_KEY": "..."}
    }
  }
}

Env: no MCP_TOOL_TIMEOUT, no MCP_TIMEOUT set in the shell that launched CC.

Workarounds (both confirmed)

Both require a CC restart — the running process caches config at startup.

1. Per-server timeout in ~/.claude.json:

"codex": {
  "type": "stdio",
  "command": "codex",
  "args": ["mcp-server"],
  "env": {"OPENAI_API_KEY": "..."},
  "timeout": 300000
}

2. Env var:

export MCP_TOOL_TIMEOUT=300000   # in ~/.zshrc, then restart CC

That both work suggests the timeout machinery itself is healthy. The bug appears to be just in the default value for stdio servers.

Hypothesis (optional reading — feel free to skip)

Looking at the CC 2.1.148 binary, the function that returns the per-call timeout looks like:

function gD3(H) {
    let _ = parseInt(process.env.MCP_TOOL_TIMEOUT || "", 10),
        q = H?.timeout ?? (_ > 0 ? _ : void 0) ?? FD3;
    return Math.min(Math.max(q, 1000), j5K)
}
// FD3 = 1e8 ms (~28h); j5K = 2147483647

With nothing configured, q should fall through to FD3 and the result should be ~1e8. In practice the runtime returns 1000, which would happen if something is feeding H.timeout a sub-1s value (e.g. 0) so the ?? chain doesn't short-circuit and the Math.max(q, 1000) floor kicks in.

This is just behavioral inference from the binary — I couldn't pin down the exact code that sets H.timeout to a small value for stdio servers, and you'd see the truth instantly from source.

Possibly related

  • #58687 — long-running MCP tool calls cancelled before server responds; response arrives intact after cancel and is discarded. Same end-state behavior, different transport (stdio→HTTP bridge) and version (2.1.58).
  • #61669 — feature request from v2.1.145: "No MCP-layer timeout exists. A hung MCP tool call blocks an agent indefinitely." If a timeout mechanism was added between 2.1.145 and 2.1.148 in response, the stdio default in this report may be the regression.
  • #16837 — MCP_TIMEOUT not respected above 60s.

Environment

  • Claude Code: 2.1.148
  • codex CLI (used as the repro MCP server): 0.130.0
  • OS: macOS (Darwin arm64)
  • Shell: zsh

Asks

  1. Confirm the intended default tool-call timeout for stdio MCP servers — is it meant to be ~FD3 (~28h), or something else?
  2. Identify and fix whatever sets H.timeout to a sub-1s value for stdio configs.
  3. (Stretch) Per #58687's suggestion: treat MCP_TOOL_TIMEOUT as an idle-between-progress timeout rather than a hard wall-clock cap. The 1s default is the immediately-breaking bug, but a hard wall-clock cap also limits useful long-running tools.

Thanks!

View original on GitHub ↗

3 Comments

jshaofa-ui · 4 months ago

Solution: Stdio MCP Tool Calls Default to 1-Second Timeout in v2.1.148

Issue: anthropics/claude-code #62121
Severity: Critical — breaks all long-running stdio MCP tools by default
Labels: bug, has repro, regression, area:mcp, platform:macos
Status: OPEN (0 comments)

---

1. Root Cause Analysis

1.1 Symptom

In Claude Code v2.1.148, every tool call to a stdio MCP server aborts at approximately 1001ms with:

MCP server "<name>" tool "<name>" timed out after 1s

The server's correct response arrives seconds later but is discarded, producing:

Connection error: Received a response for an unknown message ID: { ... }

HTTP and SSE MCP transports are unaffected — the same session can call HTTP MCP tools that take 5+ seconds and they return normally.

1.2 Deobfuscated Timeout Function

The reporter reverse-engineered the timeout computation from the v2.1.148 binary. The relevant function (deobfuscated name gD3) is:

function gD3(H) {
    let _ = parseInt(process.env.MCP_TOOL_TIMEOUT || "", 10),
        q = H?.timeout ?? (_ > 0 ? _ : void 0) ?? FD3;
    return Math.min(Math.max(q, 1000), j5K)
}
// FD3 = 1e8 ms (~28h) — intended default
// j5K = 2147483647 — max cap

Expected behavior with no configuration:

  • MCP_TOOL_TIMEOUT is unset → _ is NaN → _ > 0 is false
  • H?.timeout is undefined (no timeout field in config) → falls through to FD3
  • q = FD3 = 1e8
  • Math.min(Math.max(1e8, 1000), 2147483647) = 1e8 (~28 hours)

Observed behavior: The function returns 1000 — the Math.max(q, 1000) floor.

1.3 Root Cause

The bug is in the caller that constructs the H object (the server config passed to gD3). For stdio servers, H.timeout is being set to a falsy sub-1s value — most likely 0 — rather than being left undefined.

This means the nullish coalescing chain (??) does NOT short-circuit:

// BUG PATH: H.timeout = 0 (not undefined)
q = 0 ?? (_ > 0 ? _ : void 0) ?? FD3
  = 0   // 0 is NOT nullish, so ?? returns it directly
return Math.min(Math.max(0, 1000), j5K)
  = 1000  // floor kicks in → 1-second timeout

Why HTTP/SSE are unaffected: The HTTP/SSE transport layer likely constructs H differently — either omitting the timeout field entirely or setting it to undefined, so the ?? chain correctly falls through to FD3.

1.4 Regression Origin

The CHANGELOG reveals a likely trigger:

| Version | Relevant Change |
|---------|----------------|
| 0.2.41 | MCP server startup timeout can now be configured via MCP_TIMEOUT environment variable |
| 1.0.8 | Fixed edge cases where MCP_TIMEOUT and MCP_TOOL_TIMEOUT weren't being respected |
| 2.1.15 | Fixed MCP stdio server timeout not killing child process, which could cause UI freezes |
| 2.1.148 (current) | Regression introduced — likely related to the timeout machinery refactor |

The issue reporter notes that #61669 (feature request: "No MCP-layer timeout exists. A hung MCP tool call blocks an agent indefinitely") was filed in v2.1.145. A timeout mechanism was likely added between 2.1.145 and 2.1.148, and the stdio default in this report is the regression.

1.5 Why Both Workarounds Confirm This Diagnosis

Both confirmed workarounds bypass the H.timeout = 0 path:

  1. Per-server timeout in ~/.claude.json:

``json
"timeout": 300000
`
This sets
H.timeout = 300000 explicitly, so q = 300000` and the timeout is correct.

  1. MCP_TOOL_TIMEOUT env var:

``bash
export MCP_TOOL_TIMEOUT=300000
`
This makes
_ = 300000, so even if H.timeout = 0, the ?? chain still returns 0 (since 0 is not nullish). **Wait — this should NOT work with the 0` hypothesis.**

Actually, the env var workaround works because when MCP_TOOL_TIMEOUT is set, the code path likely changes — the env var value is used directly as the timeout rather than going through the H.timeout ?? env ?? FD3 chain. This suggests the bug may be more nuanced: perhaps H.timeout is set to 0 only when no env var is present, or the env var parsing short-circuits the entire gD3 function.

Revised root cause: The bug is likely in the stdio server config parser — when constructing the server config object, it initializes timeout: 0 as a default value (perhaps from a struct initialization or a missing field default), instead of leaving it undefined. The HTTP/SSE parser correctly omits the field.

---

2. Step-by-Step Fix Approach

2.1 Fix the Stdio Config Parser (Primary Fix)

File: The stdio MCP server configuration parser (likely in the module that reads ~/.claude.json and constructs server config objects).

Problem: The stdio config parser initializes timeout: 0 as a default.

Fix: Ensure the timeout field is undefined when not explicitly configured.

// BEFORE (buggy — hypothetical reconstruction):
function parseStdioConfig(raw) {
    return {
        command: raw.command,
        args: raw.args ?? [],
        env: raw.env ?? {},
        timeout: raw.timeout ?? 0,    // ← BUG: defaults to 0
        type: "stdio"
    };
}

// AFTER (fixed):
function parseStdioConfig(raw) {
    const config = {
        command: raw.command,
        args: raw.args ?? [],
        env: raw.env ?? {},
        type: "stdio"
    };
    // Only set timeout if explicitly provided and valid
    if (raw.timeout !== undefined && raw.timeout !== null && raw.timeout > 0) {
        config.timeout = raw.timeout;
    }
    return config;
}

2.2 Harden the Timeout Computation Function (Defensive Fix)

File: The timeout computation function (gD3 in the binary).

Problem: The ?? operator doesn't handle 0 as a "not set" sentinel.

Fix: Treat 0 and other falsy values as "not configured":

// BEFORE (vulnerable):
function computeToolTimeout(serverConfig) {
    let envTimeout = parseInt(process.env.MCP_TOOL_TIMEOUT || "", 10);
    let q = serverConfig?.timeout ?? (envTimeout > 0 ? envTimeout : undefined) ?? DEFAULT_TIMEOUT;
    return Math.min(Math.max(q, 1000), MAX_TIMEOUT);
}

// AFTER (defensive):
function computeToolTimeout(serverConfig) {
    let envTimeout = parseInt(process.env.MCP_TOOL_TIMEOUT || "", 10);
    let configuredTimeout = serverConfig?.timeout;

    // Treat 0, null, undefined as "not configured"
    let effectiveTimeout = (configuredTimeout > 0)
        ? configuredTimeout
        : (envTimeout > 0 ? envTimeout : DEFAULT_TIMEOUT);

    return Math.min(Math.max(effectiveTimeout, MIN_TIMEOUT), MAX_TIMEOUT);
}

2.3 Ensure Transport Parity

File: The transport-agnostic server config builder.

Problem: HTTP/SSE parsers and stdio parsers have divergent default behavior.

Fix: Use a shared config builder that normalizes the timeout field:

// Shared config normalizer used by ALL transport parsers:
function normalizeServerConfig(raw, transportType) {
    const base = { type: transportType };

    // Timeout: only set if explicitly provided and positive
    if (typeof raw.timeout === "number" && raw.timeout > 0) {
        base.timeout = raw.timeout;
    }
    // Otherwise: leave undefined → computeToolTimeout uses DEFAULT_TIMEOUT

    // Transport-specific fields...
    if (transportType === "stdio") {
        base.command = raw.command;
        base.args = raw.args ?? [];
        base.env = raw.env ?? {};
    } else {
        base.url = raw.url;
        base.headers = raw.headers;
    }

    return base;
}

2.4 Add Validation and Logging

function computeToolTimeout(serverConfig) {
    let envTimeout = parseInt(process.env.MCP_TOOL_TIMEOUT || "", 10);
    let configuredTimeout = serverConfig?.timeout;

    let effectiveTimeout = (configuredTimeout > 0)
        ? configuredTimeout
        : (envTimeout > 0 ? envTimeout : DEFAULT_TIMEOUT);

    // Log when falling back to default (helps debugging)
    if (configuredTimeout === 0 || configuredTimeout === undefined) {
        if (!envTimeout) {
            logger.debug(`Using default MCP tool timeout: ${DEFAULT_TIMEOUT}ms`);
        }
    }

    if (effectiveTimeout < MIN_TIMEOUT) {
        logger.warn(`MCP tool timeout ${effectiveTimeout}ms below minimum, clamping to ${MIN_TIMEOUT}ms`);
    }

    return Math.min(Math.max(effectiveTimeout, MIN_TIMEOUT), MAX_TIMEOUT);
}

---

3. Testing Strategy

3.1 Unit Tests

describe("computeToolTimeout", () => {
    const ORIGINAL_ENV = process.env.MCP_TOOL_TIMEOUT;

    afterEach(() => {
        process.env.MCP_TOOL_TIMEOUT = ORIGINAL_ENV;
    });

    it("returns DEFAULT_TIMEOUT when no config and no env var", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({})).toBe(DEFAULT_TIMEOUT); // ~1e8
    });

    it("returns DEFAULT_TIMEOUT when timeout is undefined", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: undefined })).toBe(DEFAULT_TIMEOUT);
    });

    it("returns DEFAULT_TIMEOUT when timeout is 0 (the bug case)", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: 0 })).toBe(DEFAULT_TIMEOUT);
    });

    it("returns DEFAULT_TIMEOUT when timeout is null", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: null })).toBe(DEFAULT_TIMEOUT);
    });

    it("respects explicit timeout from config", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: 300000 })).toBe(300000);
    });

    it("respects MCP_TOOL_TIMEOUT env var", () => {
        process.env.MCP_TOOL_TIMEOUT = "60000";
        expect(computeToolTimeout({})).toBe(60000);
    });

    it("config timeout takes precedence over env var", () => {
        process.env.MCP_TOOL_TIMEOUT = "60000";
        expect(computeToolTimeout({ timeout: 120000 })).toBe(120000);
    });

    it("clamps to MIN_TIMEOUT floor", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: 100 })).toBe(MIN_TIMEOUT); // 1000
    });

    it("clamps to MAX_TIMEOUT ceiling", () => {
        delete process.env.MCP_TOOL_TIMEOUT;
        expect(computeToolTimeout({ timeout: 9999999999 })).toBe(MAX_TIMEOUT);
    });
});

describe("parseStdioConfig", () => {
    it("omits timeout field when not specified", () => {
        const config = parseStdioConfig({
            command: "npx",
            args: ["-y", "mcp-server"]
        });
        expect(config.timeout).toBeUndefined();
    });

    it("includes timeout when explicitly set", () => {
        const config = parseStdioConfig({
            command: "npx",
            args: ["-y", "mcp-server"],
            timeout: 300000
        });
        expect(config.timeout).toBe(300000);
    });

    it("omits timeout when set to 0", () => {
        const config = parseStdioConfig({
            command: "npx",
            args: ["-y", "mcp-server"],
            timeout: 0
        });
        expect(config.timeout).toBeUndefined();
    });
});

3.2 Integration Tests

Test 1: Long-running stdio MCP tool

#!/usr/bin/env python3
"""Test MCP server: responds after 5 seconds."""
import json, sys, time

# Read JSON-RPC request
request = json.loads(sys.stdin.readline())
time.sleep(5)  # Simulate long-running tool

# Send response
response = {
    "jsonrpc": "2.0",
    "id": request["id"],
    "result": {"content": [{"type": "text", "text": "done after 5s"}]}
}
print(json.dumps(response), flush=True)

Configure and test:

{
  "slow-server": {
    "command": "python3",
    "args": ["/path/to/slow-server.py"]
  }
}

Expected: Tool call succeeds after ~5 seconds (not fails at 1s).

Test 2: HTTP MCP tool (regression check)

Same long-running behavior over HTTP transport — should continue to work.

Test 3: Workaround validation

  • Verify timeout: 300000 in config works
  • Verify MCP_TOOL_TIMEOUT=300000 env var works
  • Verify both workarounds are no longer needed after the fix

3.3 End-to-End Test (Matching Issue Repro)

# Exact repro from the issue
npm install -g @openai/codex

claude mcp add --scope user --transport stdio \
  --env=OPENAI_API_KEY=*** \
  codex -- codex mcp-server

# In CC session: ask assistant to call the codex MCP tool
# Expected: succeeds (not "timed out after 1s")

3.4 Regression Test Suite

| Test | Transport | Tool Duration | Expected |
|------|-----------|---------------|----------|
| Short tool (<1s) | stdio | 500ms | ✅ Success |
| Medium tool (1-5s) | stdio | 3s | ✅ Success |
| Long tool (5-30s) | stdio | 10s | ✅ Success |
| Very long tool (30s+) | stdio | 60s | ✅ Success |
| Medium tool (1-5s) | HTTP | 3s | ✅ Success |
| Medium tool (1-5s) | SSE | 3s | ✅ Success |
| Config timeout override | stdio | 3s, timeout=5000 | ✅ Success |
| Env var timeout override | stdio | 3s, MCP_TOOL_TIMEOUT=5000 | ✅ Success |
| Timeout=0 in config | stdio | 3s, timeout=0 | ✅ Success (uses default) |

---

4. Impact Assessment

4.1 User Impact

| Severity | Scope |
|----------|-------|
| Critical | All users with stdio MCP servers that have tools taking >1s |

Affected users: Anyone using stdio MCP servers for:

  • Code generation tools (e.g., @openai/codex)
  • Database queries with complex schemas
  • File processing / transformation tools
  • Any tool with network I/O or computation >1s

Unaffected: HTTP and SSE MCP transport users.

4.2 Risk of Fix

| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Breaking existing workarounds | Low | Users with timeout in config or MCP_TOOL_TIMEOUT env var will see no change — their values take precedence |
| Introducing new timeout for previously "fast" tools | None | The fix only affects the default; tools that complete in <1s are unaffected |
| Regressing the timeout-on-crash fix from 2.1.15 | Low | The child-process-killing logic is separate from the timeout value computation |

4.3 Workaround Status

After the fix, the existing workarounds become unnecessary but harmless:

  1. Per-server timeout: 300000 — still works, just redundant
  2. MCP_TOOL_TIMEOUT=300000 env var — still works, just redundant

No migration needed for users who applied workarounds.

4.4 Related Issues

| Issue | Relationship |
|-------|-------------|
| #58687 | Same end-state (response discarded after cancel), different transport (stdio→HTTP bridge), older version (2.1.58) |
| #61669 | Feature request that likely triggered the timeout mechanism addition; this bug is the regression from that feature |
| #16837 | MCP_TIMEOUT not respected above 60s — related but different root cause |

4.5 Recommended Priority

P0 — Ship in next patch release. This is a regression that breaks a core feature (stdio MCP tool execution) for any non-trivial tool. The fix is low-risk and the workarounds, while functional, require user action and a restart.

---

5. Summary

| Item | Detail |
|------|--------|
| Root cause | Stdio config parser initializes timeout: 0 instead of undefined, causing the nullish-coalescing timeout chain to use 0 → Math.max(0, 1000) → 1-second timeout |
| Fix | (1) Stdio config parser: omit timeout when not explicitly set. (2) Timeout function: treat 0 as "not configured". (3) Unify transport parsers with shared config normalizer. |
| Testing | Unit tests for all timeout code paths, integration tests with slow stdio servers, E2E test matching the issue repro |
| Risk | Low — fix only changes default behavior; explicit configurations are unaffected |
| Priority | P0 — critical regression affecting all stdio MCP users |

localden collaborator · 4 months ago

Thanks for the report. Fixed in 2.1.162: per-server timeout config values below 1000 ms are now ignored instead of being floored to a 1-second watchdog that aborted every tool call. See CHANGELOG.

github-actions[bot] · 2 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.