Serve repeat initialize from a cached result - #6153
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>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6153 +/- ##
==========================================
+ Coverage 72.59% 72.60% +0.01%
==========================================
Files 736 737 +1
Lines 76359 76395 +36
==========================================
+ Hits 55435 55470 +35
+ Misses 16981 16973 -8
- Partials 3943 3952 +9 ☔ 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>
Holding the cache mutex across the upstream round trip serialized concurrent first handshakes: each waiter ran its own forward once the previous released the lock, so against an unresponsive backend the Nth client waited N*requestTimeout -- 60s each -- before it could even start. Per-call boundedness is not queue boundedness. singleflight collapses them into one upstream call whose outcome every waiter shares, so a slow backend costs one timeout rather than one per client. The mutex now only guards the cached result and the initialized-notification flag, and is never held across a network call. Two consequences the shared flight introduces, both handled: Run the upstream call on a context detached from the caller's request. singleflight invokes the shared function with whichever context the first caller supplied, so its disconnect would otherwise cancel the handshake for every waiter -- and worse, the cancelled call still reaches the backend, so the next attempt hits an already-initialized session and fails with `duplicate "initialize" received`, reintroducing the bug this cache exists to fix. forwardUpstream still applies requestTimeout to the detached context. Rebuild every response with the calling request's own JSON-RPC id. A shared result carries the id of whichever caller performed the forward, and returning it verbatim would hand later waiters an id they never sent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jerm-dro
left a comment
There was a problem hiding this comment.
Apologies — I should have elaborated more on my initial review rather than leaving this to a second pass.
One structural suggestion inline on initialize_cache.go: the synchronization can be simpler than singleflight-plus-mutex, which also collapses the flight's return type from three to one and removes the id-rewriting hazard by construction. Behaviour should be unchanged.
Separately, the description's "the cache lock is held across the first upstream round trip, so concurrent clients wait and then read the cache" bullet describes the pre-singleflight revision and now contradicts the comment at initialize_cache.go:49. TestConcurrentInitializeReachesBackendOnce's doc comment has the same drift.
| flight singleflight.Group | ||
|
|
||
| // mu guards the two fields below. It is never held across an upstream call. | ||
| mu sync.Mutex |
There was a problem hiding this comment.
Suggestion: drop the mutex and let the callback be the only place that touches the cache.
result needs a lock only because it's also read on the fast path above, outside the flight. singleflight runs at most one callback per key at a time and hands that callback's return value to every waiter, so a field touched only inside the callback never has overlapping accesses — the re-check you already have becomes the only cache read:
v, err, _ := c.flight.Do(initializeFlightKey, func() (any, error) {
// A handshake already succeeded: replay it, no backend round trip.
if c.result != nil {
return handshake{result: c.result}, nil
}
// Detached from the caller's context: this result belongs to every waiter,
// so whichever caller performs the forward must not cancel the handshake
// for the others by disconnecting. forwardUpstream still bounds it with
// p.requestTimeout.
msg, ferr := p.forwardUpstream(context.WithoutCancel(ctx), sessID, req)
if ferr != nil {
return nil, ferr // nothing cached; the next client retries for real
}
resp, ok := msg.(*jsonrpc2.Response)
if !ok {
return nil, errUnexpectedInitializeReply
}
if resp.Error != nil {
// Shared with this flight's waiters but deliberately not cached, so a
// transient failure cannot pin every future client to one response.
return handshake{rpcErr: resp.Error}, nil
}
if len(resp.Result) == 0 {
return nil, errUnexpectedInitializeReply // no result and no error
}
c.result = resp.Result
return handshake{result: resp.Result}, nil
})
if err != nil {
return upstreamErrorResponse(req.ID, err)
}
h := v.(handshake) // the callback returns a handshake or a non-nil error
return &jsonrpc2.Response{ID: req.ID, Result: h.result, Error: h.rpcErr}handshake is just struct{ result json.RawMessage; rpcErr error } — the id-free half of the outcome. Sharing that instead of a whole *jsonrpc2.Response means each caller builds its own reply, so switch out := v.(type) and its default: branch both go: a waiter can no longer receive the forwarding caller's JSON-RPC id, by construction rather than by a branch catching it.
That leaves mu guarding nothing, so it plus cachedResult() and storeResult() can go. initializedForwarded wants atomic.Bool with CompareAndSwap(false, true) rather than the flight — singleflight de-duplicates rather than serializes, so two concurrent claims would share one callback's answer and both forward. Worth a comment on result stating that only the callback may touch it: that rule is what replaces the lock, and a leftover accessor is what a future caller would reach for from outside.
Behaviour should be unchanged and the existing tests should stay green as written — worth a -race run to confirm. One intentional exception: a reply with no error and an empty result currently forwards verbatim and would now become an internal error. That's malformed for initialize, so I think erroring is better, but it is a change.
The mutex existed only because the cached result was also read on a fast
path outside the flight. singleflight runs at most one callback per key
at a time, and a later callback starts only after the previous one has
completed through the group's own mutex, so a field touched solely
inside the callback can never have overlapping accesses. Dropping the
fast path makes the re-check already in the callback the only cache
read, and the lock guards nothing.
This matches the shape of the repo's other singleflight user,
pkg/cache.ValidatingCache: all cache access lives inside the flight and
the callback shares a small result struct rather than a whole value.
Share an id-free handshake{result, rpcErr} instead of a
*jsonrpc2.Response, so each caller builds its own reply. A waiter can no
longer receive the forwarding caller's JSON-RPC id by construction,
which retires the type switch and its default branch.
initializedForwarded becomes an atomic.Bool. It deliberately does not
use the flight: singleflight de-duplicates rather than serializes, so
two concurrent claims would share one callback's answer and both
forward. CompareAndSwap gives exactly one winner.
One intentional behaviour change: a success carrying neither error nor
result was forwarded verbatim and is now an internal error. That
envelope is malformed for initialize, which requires protocolVersion,
capabilities and serverInfo. Nothing is cached, so the next client still
gets a real handshake attempt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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