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
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.