Files
kandev/docs/plans/mcp-timeout-budget-split/plan.md
T
2026-09-05 16:16:10 +00:00

135 lines
6.5 KiB
Markdown

---
spec: docs/specs/agents/requirements/mcp-timeout-budgets.md
design: docs/specs/agents/system-design/mcp-timeout-budgets.md
decision: docs/decisions/2026-09-02-separate-mcp-startup-and-tool-budgets.md
created: 2026-09-02
status: done
---
# Implementation Plan: Split the Claude MCP Timeout Budgets
## Overview
Replace the single `MCP_TIMEOUT=7200000` managed agent default on the Claude
ACP runtime with two independent budgets, add the regression test the current
value never had, and audit the other agent runtimes for the same coupling.
Requirements: `REQ-AGENTS-MCP-TIMEOUT-BUDGETS-001`
(`AC-...-001.1` through `AC-...-001.5`).
Design: `docs/specs/agents/system-design/mcp-timeout-budgets.md`.
## Confirmed root cause
`apps/backend/internal/agent/agents/claude_acp.go:125-127` declares one managed
agent default, `MCP_TIMEOUT: "7200000"`, chosen so Kandev's blocking MCP tools
(`apps/backend/internal/mcp/handlers/handlers.go:3782` waits for a user answer)
have a two-hour budget.
In the Claude Code CLI that key governs two unrelated things: the MCP connect
deadline, and a first-turn wait the CLI performs before dispatching the first
prompt.
The handshake is not the problem. It succeeds. After it succeeds, the CLI opens
a `subscriptions/listen` request and waits for that request to RETURN before
dispatching the first turn. `subscriptions/listen` is a server-to-client
notification channel, so a specification-conformant server holds it open and
sends nothing. The CLI waits out its full budget, cancels with
`notifications/cancelled`, opens a fresh listen, and proceeds.
Frame trace from a logging proxy between the CLI and one MCP server:
```text
5.629 -> server/discover hdr=2026-07-28
5.800 <- server/discover 200 1784b (succeeds in 171 ms)
5.806 -> subscriptions/listen id=listen:0
20.812 -> notifications/cancelled requestId=listen:0
```
The block is exactly `MCP_TIMEOUT - 5000` ms, confirmed at two values:
`MCP_TIMEOUT=12000` blocks 7.002 s, `MCP_TIMEOUT=20000` blocks 15.006 s.
End-to-end `claude -p` measured 6.0 s with no MCP server and 22.6 s with one
HTTP MCP server.
This is an upstream client defect, filed as `anthropics/claude-code#91414`. It
is not a slow server, not a broken server, and not specific to any one server:
it affects every MCP server that implements `subscriptions/listen` as
specified.
Reading the shipped binary at `~/.local/share/claude/versions/2.1.258` shows the
same arithmetic:
- The `MCP_TIMEOUT` reader is `function Cc(){ let n = a.MCP_TIMEOUT; return
n && n > 0 ? Math.min(n, 2147483647) : 30000 }`. The CLI default is 30000 ms.
- The connect that carries the subscription stream uses
`Wr() = Math.max(Cc() - 5000, Math.floor(Cc() / 3))`, which is
`MCP_TIMEOUT - 5000` for any value at or above 7500 ms, matching both
measurements exactly.
- The cancel-and-reopen behavior is present as `tengu_mcp_listen_reopen` and a
`subscriptions/listen re-open attempt` retry path.
- The first-turn wait is instrumented as `tengu_headless_mcp_prewait` with log
markers `before_mcp_prewait` / `mcp_prewait_ms` / `after_mcp_prewait` /
`before_ask`, and its deadline becomes the `MCP_TIMEOUT` reader when the CLI
receives an explicit MCP configuration.
With `MCP_TIMEOUT=7200000` the wait is 7,195,000 ms, which is 1h 59m 55s. That
is the observed freeze: the process is alive, emits no tokens, and Kandev's card
stays at `foreground_activity=generating`.
`MCP_TOOL_TIMEOUT` is the correct key for the two-hour budget. The CLI clamps
it to `[60000, 2147483647]` and computes the per-request fetch deadline as
`max(clamp(MCP_TOOL_TIMEOUT), Cc())`, so moving 7200000 onto it preserves the
long request budget that `MCP_TIMEOUT=7200000` was producing as a side effect.
## Why this is invisible today
Nothing asserts either value. `apps/backend/internal/agent/agents/claude_acp_test.go`
covers the command, install script, and permission flags, and no test in
`apps/backend/` references `MCP_TIMEOUT`. A wrong value here changes only
latency, so it survived until it cost a user two hours.
## Decision on `MCP_PROTOCOL_NEGOTIATION`
Considered and rejected as a Kandev-set default. The argument is recorded in
full in the ADR. In short: forcing the legacy handshake avoids the code path
that opens `subscriptions/listen`, so it removes the wait outright, but it pins
every MCP server in every Claude session to an older handshake to route around
a filed client bug, overrides a vendor rollout the CLI gates behind its own
flag, is invisible at runtime, and would silently outlive the defect. Once the
wait is bounded at 25 s the failure it prevents is no longer severe. Users who
want it can set it on an agent profile, which outranks managed agent defaults.
## Work orders
| Order | Work order | Outcome |
| --- | --- | --- |
| 1 | [task-01-split-claude-mcp-budgets.md](task-01-split-claude-mcp-budgets.md) | Claude ACP declares two independent budgets, asserted by test |
| 2 | [task-02-audit-agent-runtime-mcp-budgets.md](task-02-audit-agent-runtime-mcp-budgets.md) | No other agent runtime couples the two budgets, asserted by test |
Sequential. Task 02 generalises the invariant task 01 establishes for one
runtime; running it first would have nothing to generalise.
## Risks
- **The tool-call idle watchdog may bind before `MCP_TOOL_TIMEOUT`.** The CLI
also computes an idle timeout from `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`,
defaulting to 1800000 ms for stdio and 300000 ms for other transports, capped
by the tool budget. Kandev serves its own MCP over HTTP/SSE. Task 01 must
verify empirically whether a blocking `ask_user_question_kandev` call still
survives past those bounds after the split. This risk exists today and is not
introduced by the split, but the split is the right moment to establish it.
If the idle watchdog binds first, record the finding and raise it as separate
work rather than widening this fix.
- **Residual 25 s first-turn wait whenever any MCP server is configured.**
This is `MCP_TIMEOUT - 5000` and is the upstream defect, not a Kandev choice.
It cannot be reduced further by lowering `MCP_TIMEOUT` without also
shortening the connect deadline the same key governs. Accepted and documented
in the ADR; it disappears when `anthropics/claude-code#91414` is fixed, with
no change on Kandev's side.
## Verification strategy
Unit tests at the agent-runtime declaration boundary, which is where the
contract lives. No browser test: the requirement has no UI surface. A manual
launch with at least one MCP server configured is the acceptance evidence for
the latency outcome, recorded in task 01's results.