Serve repeat initialize from a cached result - #6153
Open
amirejaz wants to merge 4 commits into
Open
Conversation
A stdio MCP server is one process and therefore exactly one MCP session, while the streamable proxy fans an arbitrary number of HTTP client sessions onto it. Every client's initialize was forwarded to that single session, so only the first could succeed: go-sdk v1.7+ rejects a second handshake with `duplicate "initialize" received`. Behind vMCP that takes down far more than the reported health check -- every call verb builds a fresh client and handshakes, so tool calls fail too once the aggregator has claimed the one handshake at startup. Cache the InitializeResult from the first handshake and answer later ones locally, restoring what go-sdk v1.6.1 and earlier did when it replayed a repeated initialize, but in the component that actually owns the multiplexing. The cache lock is held across the first upstream round trip so concurrent handshakes single-flight instead of racing a second initialize to the backend; forwardUpstream's timeout bounds it. Errors are never cached, so a transient failure cannot pin every later client to one bad response. Swallow notifications/initialized after the first for the same reason: the backend completed one handshake and expects one such notification. Later ones are still acknowledged with 202. Server capabilities in the replayed result do not vary by client, and client capabilities are inert here because server-initiated requests are already rejected by the shared proxy. The negotiated protocol version is the one genuinely shared value: a client that asked for a different one receives the first client's. Closes #1982 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
amirejaz
requested review from
ChrisJBurns,
JAORMX,
blkt,
jhrozek and
rdimitrov
as code owners
July 30, 2026 21:11
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6153 +/- ##
==========================================
+ Coverage 72.56% 72.58% +0.01%
==========================================
Files 736 737 +1
Lines 76331 76362 +31
==========================================
+ Hits 55392 55425 +33
+ Misses 17017 17011 -6
- Partials 3922 3926 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The initialize cache tests used a permissive stub that accepted every handshake, so they could only count how many messages reached the backend -- they could never produce the error the cache exists to prevent. Give the fake backend go-sdk v1.7+ session semantics: answer the first initialize and reject every later one with the same `duplicate "initialize" received` error ServerSession.initialize returns. The tests now assert the outcome that matters, that a later client's handshake succeeds, and fail with the literal error from #5890 when the cache is removed rather than only with a mismatched count. Co-Authored-By: Claude Opus 5 (1M context) <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.
Summary
A stdio MCP server is one process and therefore exactly one MCP session, while the streamable proxy fans an arbitrary number of HTTP client sessions onto it. Every client's
initializewas forwarded to that single session, so only the first could ever succeed — go-sdk v1.7+ rejects a second handshake withduplicate "initialize" received.Behind vMCP this is worse than the reported symptom. #5890 reports the health check failing every 30s, but every vMCP call verb builds a fresh client and handshakes (
legacyCallToolatpkg/vmcp/client/client.go:1681-1692, and the resource/prompt paths at:1890,:2008,:2149). Once the aggregator claims the single handshake at startup, tool calls fail too — so the ping-based health check suggested in #5890 would have made the backend report healthy while remaining unusable.InitializeResultfrom the first handshake and answer later ones locally. This restores what go-sdk v1.6.1 and earlier did when replaying a repeatedinitialize, but places it in the component that actually owns the multiplexing rather than asking every MCP server to tolerate duplicate handshakes.initializeto the backend. It is bounded byforwardUpstream'srequestTimeout, the same reasoninguriLocksrelies on forresources/subscribe.notifications/initializedafter the first. This is not defensive guesswork: go-sdk rejects a repeatedinitializedexactly as it rejects a repeatedinitialize(mcp/server.go:1424vs:1978). Later ones are still acknowledged with 202, which is all a notification sender expects.Interception hooks into the existing
interceptSessionScopedRequestswitch, alongside theresources/subscriberef-counting andlogging/setLevelreconciliation that already do this shape of fan-in mediation, and reuseswriteInterceptedResponseso a cached answer is indistinguishable from a forwarded one (session header included).Closes #5890
Closes #1982
Type of change
Test plan
task test)task lint-fix)TestEveryClientHandshakeSucceedsAgainstStrictBackendduplicate "initialize" receivedon clients 2 and 3TestConcurrentInitializeReachesBackendOnceTestDuplicateInitializedNotificationIsNotForwardedTestFailedInitializeIsNotCachedThe fake backend reproduces go-sdk v1.7+ session semantics rather than accepting everything, so these assert the outcome that matters -- a later client's handshake succeeds -- and fail with the literal error from #5890 when the cache is removed, not merely with a mismatched message count. I verified that by reverting the production change and re-running.
TestFailedInitializeIsNotCachedis deliberately a guard rather than a regression test: it exercises a backend that fails the first handshake and succeeds on the second, which passes with or without a cache. It exists to pin the "errors are not cached" invariant against future changes.All existing
pkg/transport/...tests pass with-race, including the streamable proxy's MCP client integration test which performs a real handshake through this path.task lint-fixis clean on the changed files; the one remaining finding (cmd/thv/app/upgrade.gogosec G115) is pre-existing and untouched here.Manual verification against the reported backend
Built
thvfrom this branch and frommain, and ran the exact setup from #5890 --thv run github, which resolves toghcr.io/github/github-mcp-server:v1.6.0withtransport: stdio. Three independent client handshakes, none carrying a session ID:main(v0.41.0-9-g33e06f4f5)duplicate "initialize" receivedduplicate "initialize" receivedv0.41.0-11-ga5303d5d3)The
mainrun reproduces the reported failure verbatim, so the passing result is attributable to this change rather than to v1.6.0 being tolerant of repeat handshakes.Also confirmed the cached handshake yields a genuinely usable session rather than just a well-formed response: client 2 sent
notifications/initialized(HTTP 202) and thentools/list, which returned all 44 tools. Theresultpayload is byte-identical (4238 bytes, valid JSON) across all three of main-forwarded, branch-forwarded and branch-cached.Does this introduce a user-facing change?
Yes. Multiple clients can now connect to a single stdio MCP server through ToolHive without the second and subsequent handshakes failing, verified against
github-mcp-serverv1.6.0.Yes. Multiple clients can now connect to a single stdio MCP server through ToolHive without the second and subsequent handshakes failing, and vMCP can aggregate stdio backends that reject repeat handshakes. Verified end to end against the exact stack from #5890 -- vMCP v0.40.1 with
github-mcp-serverv1.6.0 over stdio -- where the backend previously went unhealthy and degraded the whole gateway.Special notes for reviewers
What is and isn't shared. Worth being explicit, since this is the design question that kept #1982 open:
roots,sampling,elicitation) are frozen to the first client's, but inert: server-initiated requests are already rejected outright by this proxy (dispatcher.go:179), so nothing observable depends on them.Known limitation — a backend process replaced underneath the proxy. The cache is not invalidated on container re-attach. That path (
pkg/transport/stdio.go) only fires when the container is still running, i.e. the same live process whose session is still valid — clearing the cache there would break the common case by sending a secondinitializeto an already-initialized session. Distinguishing a genuinely new process needs a signal the proxy doesn't currently have (container start time or restart count), so a container restarted underneath a running proxy still requires a workload restart. Called out rather than papered over; happy to file a follow-up.Verified against the reported stack. #5890 was reproduced exactly and then confirmed fixed, using vMCP v0.40.1 (the reporter's version) against
github-mcp-serverv1.6.0 over stdio:duplicate "initialize" receivedNote for anyone reproducing on current
main. #5890 does not reproduce with a vMCP built from main: the dual-era revision probing added since v0.40.1 changes the call path so the duplicate handshake never occurs. On main the same backend instead fails roughly half its health checks withfailed to modern discover ... backend returned a Legacy-shaped body, flip-flopping the cached revision between2026-07-28and2025-11-25. That is a separate bug, unaffected by this change, filed separately. Reproducing #5890 requires vMCP v0.40.1.Scope.
Generated with Claude Code