fix(cli): stop the MCP stdio bridge serialising requests #92

Merged
michal merged 1 commits from fix/mcp-bridge-head-of-line into main 2026-08-07 14:20:27 +00:00
Owner

The bug

The stdio bridge's stdin loop awaited every request before reading the next line, so it handled exactly one at a time. A single slow call stalled every later request — and because those requests were never even sent, nothing could time them out. The client saw silence, not an error.

Evidence

Observed 2026-08-05: two gitea calls through this bridge sat completely mute until Claude Code aborted them at its own 1800s idle limit (sent no response or progress). The upstream was healthy throughout:

  • a fresh MCP session answered the same tools in 0.3s
  • the gitea MCP server's own log contained zero tools/call entries — the calls never reached it
  • mcplocal logged nothing for gitea either

They died queued in the bridge. Ruled out along the way: Cloudflare (allows every plausible UA; only Python-urllib is blocked, which was my probe script), the favourite-index namespace change (legacy flat server/tool names still resolve fine), SSE streams staying open (all end in 3–192ms), and mcplocal's own client (already has a 30s AbortSignal.timeout).

The fix

JSON-RPC ids exist precisely so responses may return out of order, so nothing here needed a queue. Requests now dispatch concurrently and are tracked in a set; stdin close awaits them before the session DELETE, otherwise a concurrent call races teardown and dies with a 404.

We still serialise until the session id exists — it arrives on the first response, and firing later requests without it would open a second upstream session. A client sends initialize first and waits anyway, so this costs one round trip, not throughput.

Also makes the per-request timeout configurable via MCPCTL_MCP_TIMEOUT_MS (default unchanged at 30s) and names it in the error, so a project with genuinely long tool calls can raise it rather than hitting a hardcoded wall.

Tests

Two regression tests, and I verified the first one fails on the old serial code by reverting the change — a regression test that doesn't catch the bug is worthless:

  • a fast request must overtake a slow one
  • a failed request must still produce a JSON-RPC error carrying the original id

Full CLI suite green: 515 tests / 42 files. Lint error count unchanged from HEAD (7 pre-existing).

🤖 Generated with Claude Code

## The bug The stdio bridge's stdin loop awaited every request before reading the next line, so it handled **exactly one at a time**. A single slow call stalled every later request — and because those requests were never even *sent*, nothing could time them out. The client saw silence, not an error. ## Evidence Observed 2026-08-05: two gitea calls through this bridge sat completely mute until Claude Code aborted them at its own 1800s idle limit (`sent no response or progress`). The upstream was healthy throughout: - a fresh MCP session answered the same tools in **0.3s** - the gitea MCP server's own log contained **zero** `tools/call` entries — the calls never reached it - mcplocal logged nothing for gitea either They died queued in the bridge. Ruled out along the way: Cloudflare (allows every plausible UA; only `Python-urllib` is blocked, which was my probe script), the favourite-index namespace change (legacy flat `server/tool` names still resolve fine), SSE streams staying open (all end in 3–192ms), and mcplocal's own client (already has a 30s `AbortSignal.timeout`). ## The fix JSON-RPC ids exist precisely so responses may return out of order, so nothing here needed a queue. Requests now dispatch concurrently and are tracked in a set; stdin close awaits them before the session DELETE, otherwise a concurrent call races teardown and dies with a 404. We still serialise until the session id exists — it arrives on the first response, and firing later requests without it would open a second upstream session. A client sends `initialize` first and waits anyway, so this costs one round trip, not throughput. Also makes the per-request timeout configurable via `MCPCTL_MCP_TIMEOUT_MS` (default unchanged at 30s) and names it in the error, so a project with genuinely long tool calls can raise it rather than hitting a hardcoded wall. ## Tests Two regression tests, and I verified the first one **fails on the old serial code** by reverting the change — a regression test that doesn't catch the bug is worthless: - a fast request must overtake a slow one - a failed request must still produce a JSON-RPC error carrying the original id Full CLI suite green: 515 tests / 42 files. Lint error count unchanged from HEAD (7 pre-existing). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
michal added 1 commit 2026-08-07 14:20:11 +00:00
fix(cli): stop the MCP stdio bridge serialising requests
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m23s
CI/CD / publish (pull_request) Has been skipped
e85250fedf
The bridge's stdin loop awaited every request before reading the next line, so
it handled exactly one at a time. A single slow call therefore stalled every
later request — and because those requests were never even sent, nothing could
time them out. The client saw silence, not an error.

Observed 2026-08-05: two gitea calls through this bridge sat completely mute
until Claude Code aborted them at its own 1800s idle limit, reporting "sent no
response or progress". The upstream was healthy the whole time — a fresh
session answered the same tools in 0.3s, and the gitea MCP server's own log
showed the calls never reached it. They died queued in the bridge.

JSON-RPC ids exist precisely so responses may return out of order, so nothing
here needed a queue. Requests now dispatch concurrently and are tracked in a
set; stdin close awaits them before the session DELETE, otherwise a concurrent
call races the teardown and dies with a 404.

We still serialise until the session id exists: it arrives on the first
response, and firing later requests without it would open a second upstream
session. A client sends `initialize` first and waits for the reply anyway, so
this costs one round trip rather than throughput.

Also makes the per-request timeout configurable via MCPCTL_MCP_TIMEOUT_MS
(default unchanged at 30s) and names it in the timeout error, so a project with
genuinely long tool calls can raise it instead of hitting a hardcoded wall.

Tests pin both halves: a fast request must overtake a slow one (this fails on
the old serial code — verified by reverting), and a failed request must still
produce a JSON-RPC error carrying the original id rather than nothing.
michal merged commit 2c8419eddb into main 2026-08-07 14:20:27 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#92