Repository navigation
Conversation
Every MCP tool call ran FillProjectDisplayNameIfNeeded before its handler, and until the name resolved that meant GET /cli-auth/validate with no deadline: context.Background() on a transport with no response timeout. A slow API response therefore held any tool call open for as long as the API took, including calls that never otherwise reach the API, such as a write refused in read-only mode. The client saw initialize answered and then nothing. The lookup is cosmetic, so it now takes the tool call's context and gives up after 5s, silently, as it already did on API errors. The next call tries again. ValidateAPIKeyContext and ListProjectsContext carry the context; the existing methods delegate to them with context.Background(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RunMCPSubprocess wrote every message up front, then killed the server after a fixed runDuration and only then looked for the response, so every MCP call took the full window (usually 20s) even when it passed. It now sends initialize on its own and the rest once that is answered, reads stdout as it arrives, and as soon as every request in stdin has a response closes stdin and lets the server exit. runDuration is an upper bound: the process is killed if responses are still missing. Stdin is only closed once everything is answered, because closing it earlier makes the server shut down and drop in-flight responses. Helper signatures are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
RunMCPSubprocessnow drives stdio the way an MCP client does. It waits for theinitializeresponse before sendingnotifications/initializedand the request, reads stdout as it arrives, and returns once every request has a response.runDurationis now an upper bound, not a fixed sleep. Helper signatures are unchanged, so no gateway or outpost MCP test changes.Why
Three acceptance runs failed on PRs that don't touch MCP code, each with
no JSON-RPC response with id 2 in stdout: "{...initialize result...}":TestMCPEventsIgnoredScopedToARequestTestGatewayMCPStdio_ReadOnlyRefusesWriteActionTestMCPEventsStatusIsCanonicalisedAgainstTheLiveAPI/FailedSeparately, every MCP subprocess call used its full 20s window even when it passed, which is why every passing test logged
subprocess wait: signal: killed.Root cause
Verified (mechanism):
wrapWithTelemetrycallsFillProjectDisplayNameIfNeeded, which resolves the project name shown in tool output. The name is never set at startup, so the first tool call in every session sendsGET /cli-auth/validatefirst. That includes tools that never otherwise touch the API, such as a write refused in read-only mode, which is whyTestGatewayMCPStdio_ReadOnlyRefusesWriteActioncould fail.context.Background(), and the CLI's HTTP client sets no response timeout on its TCP transport. A slow response therefore held the tool call open for as long as the API took./cli-auth/validatewaits 25s, pipelined stdin and the read-onlydeleterefusal: 8/8 runs gave the exact CI signature (initialize answered, no id 2, empty stderr). With a 10-minute stall the call was still blocked at 75s. Waiting for the initialize response first made no difference (4/4 still missing id 2).Ruled out (verified): a pipelining race. Pipelined stdin against an instant mock API answered id 2 in 4,300/4,300 runs, at up to 64 concurrent processes (max 562ms). A
-racebuild reported no data races in 300 runs. go-sdk v1.8.0 finishes handlinginitializebefore it reads the next message. A request that arrives before initialization gets an error response, not silence.Inferred (not reproduced): that CI actually hit a slow
/cli-auth/validateresponse. I made 750 live calls from a dev machine (p50 about 205ms, max 446ms) and saw no stall. CI made roughly 500 to 750 MCP calls over the period with 3 stalls, about 1 in 200, so 750 clean samples neither confirm nor rule it out. In the two tests that call the API for their own work, the stall could instead have been in that request. This PR does not bound that request (see below).Change
pkg/mcpcore/project_display.go:FillProjectDisplayNameIfNeededtakes the tool call's context and caps the whole lookup atprojectDisplayLookupTimeout(5s). On timeout it gives up silently, as it already did on API errors, and the next call tries again. Once the budget is spent it skips the project listing.pkg/mcpcore/server.go: passes the tool call's context in.pkg/hookdeck: addsValidateAPIKeyContextandListProjectsContext.ValidateAPIKeyandListProjectscall them withcontext.Background(), so no other caller changes.test/acceptance/helpers.go:RunMCPSubprocesssends theinitializerequest on its own and the rest only after it is answered. It watches stdout as it arrives and, once every request in stdin has a response, closes stdin and gives the server up to 5s to exit. The process is killed atrunDurationif responses are still missing. Stdin with no requests, or empty stdin, behaves as before (run until exit orrunDuration). Stdin is closed only after every response is in, because closing it earlier makes the server shut down and drop in-flight responses.Not in this PR (follow-ups)
runDurationinstead of hanging, and stderr will show any lookup timeout. A transport-level timeout would affect every CLI command, so it needs its own decision.tools/callrequests in one session race on the shared API client. go-sdk runs eachtools/callon its own goroutine, so the "Stdio MCP invokes tools sequentially" comments inpkg/mcpcoredon't hold. A-racebuild sent four concurrent calls and reported data races in 20/20 runs:wrapWithTelemetrywritesTelemetry, andFillProjectDisplayNameIfNeededwritesProjectName/ProjectOrg. Each acceptance process sends exactly one call, so this isn't the flake.Testing
Reproduction. A throwaway driver (not committed) runs the built binary as
gateway mcp --api-base <mock>, using the read-onlydeleterefusal, which needs no API call of its own.main/cli-auth/validatestalls 25s, pipelined stdin, 20s window, 8 runsHarness against a mock API (local throwaway test, not committed):
CallMCPToolreturned in 2.4s, includinggo build; before, it always took 20s plus the build.runDuration. The caller got the initialize response and acontext deadline exceededline on stderr.Unit tests (the CI
unit-testjob steps), judged by exit code:go test -short ./pkg/...→ exit 0 (20 packages ok, 0 FAIL)go vet ./...→ exit 0go test -race -short ./pkg/mcpcore/ ./pkg/hookdeck/→ exit 0TestToolCallIsNotHeldByAStalledDisplayNameLookupfails ("the tool call is still waiting on the display-name lookup").Acceptance, only the tags that use these helpers, with no CI acceptance run in progress:
mainACCEPTANCE_SLICE=0 go test -tags=mcp ./test/acceptance/... -count=1ACCEPTANCE_SLICE=3 go test -tags=outpost ./test/acceptance/... -count=1🤖 Generated with Claude Code