Bridge era-mismatched client×backend cells in vMCP - #6006
Conversation
abbed92 to
885f5ad
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6006 +/- ##
==========================================
- Coverage 72.21% 72.19% -0.02%
==========================================
Files 721 721
Lines 74987 75021 +34
==========================================
+ Hits 54152 54162 +10
- Misses 16956 16982 +26
+ Partials 3879 3877 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
Panel review — dual-era bridge (Closes #5912, #5979)
I ran a multi-axis review (MCP-protocol conformance, security/confidentiality, Go correctness & architecture, test/e2e quality), reading the change against the four-cell matrix defined in #5912, its parent #5756, and the 2026-07-28 draft spec. Short version: this is a genuinely strong PR — the production change is small, correct, and spec-justified, and the confidentiality invariants are faithfully pinned. No blocking correctness or security defect found on any axis. The findings below are one CI fix, a handful of comment/message precision nits, and honest coverage-boundary notes. Inline comments mark the specific lines.
🔴 Must-fix before merge
- CI is red on Spellcheck / Codespell →
bridge_regression_test.go:609(re-declared→redeclared). One character. (Unit + 3 k8s E2E-lifecycle jobs were still running at review time; everything else green.)
🟡 Suggestions (non-blocking)
- Type-assertion silent-degradation foot-gun + four duplicate
revisionReporterdecls (serve.go:371). Correctly kept out of the option, but if the concrete client or a future wrapper dropsCachedRevision, the Modern-skip silently disappears with no compile error. Suggest one exportedRevisionReporter+ a co-locatedvar _ RevisionReporter = (*httpBackendClient)(nil)compile-time assertion. Runtime-pinned atbridge_regression_test.go:748, so not a blocker. "All backends failed to initialise; session will have no capabilities"(factory.go:478) now overstates the mixed skip+fail case, and the "no capabilities" half is misleading given caps come from the core.initOneBackend"session not found" wording (factory.go:232) implies the Modern spec requires a session; it's a go-sdk v1.7 pre-release artifact. Reword so it doesn't misinform./status mcp_revisionjustification (status.go:44): the "already public via CRD" / "transport is a finer-grained fingerprint" reasoning is imprecise (different trust boundary; orthogonal dimension). Exposure itself is negligible — just don't carry the "no new exposure class" framing forward.
Axis notes
MCP-protocol — clean. Every wire-level detail checks out against the draft: server/discover as the Modern entry point, initialize/session removal, subscriptions/listen as the sole Modern server-push channel (so "list_changed is Legacy-only" is factually accurate, not a hand-wave), the exact ReservedModernMetaKeys set (with progressToken/trace-context correctly surviving the strip), the revision strings, and the -32020/-32021/-32022 error shapes. Two wording items only (#3 above; and the deferred kill-switch-off -32022 path — spec-MUST confirmed, deferral reasonable, but the follow-up should call out that a Modern-only client with no initialize fallback is stranded by the plain-400).
Security — sound, no HIGH/CRITICAL. The #5912 confidentiality requirement (per-(principal, backend) session, calling principal's own credential, no bleed) holds by construction in legacyCallTool (fresh client per request, defer Close(), per-request identity off the live context; no shared credential state), and the regression test genuinely pins it — the symmetric-swap credential↔session correlation is a rigorous defense the cardinality check alone would miss. The out-of-scope token-exchange cache is independently keyed by {backend}:{hash(subject)}:{audience}, so the exclusion is legitimate. Skipping the Modern connect bypasses no data-plane security control (auth Validate, same-host-redirect/SSRF guard, TLS, header-restriction all re-run per call). One note: the Cell A test pins per-request creation, which is stricter than per-principal — deliberate as a tripwire for RFC-0083 D6's shared pool, but that test must be consciously re-baselined when D6 lands.
Go / architecture — no correctness bug. Concurrency is race-free (per-index writes, wg.Wait before reads); the slices.Contains(modernSkipped, false) suppression is logically correct across all cases; the skipped Modern backend's tools are still advertised/routed via the core aggregator (verified through server.New → AdvertiseFromCore → core.CallTool); the double isKnownModern is a legitimate cache-warmed re-check, not the SDK-lifecycle anti-pattern; correctly used a typed-func option instead of widening vmcp.BackendClient (avoids anti-patterns #8/#9). The initOneBackend doc block is exemplary.
Tests — rigorous unit/integration tier; honest coverage boundaries. The Cell B subscriptions/listen fingerprint with its self-validating positive-control sibling is the strongest-designed test here, and the Cell A _meta-survives assertion is a genuine (non-vacuous) positive control. The CLI-tier waitForBackendRevision BeforeEach guard really is load-bearing — it's the only thing stopping the 2×2 matrix silently collapsing to 1×2, and it works. Test-rule compliance (parallel t.Cleanup, timed barriers, no assert-in-goroutine, DEBUG-level log recorder) is clean. Boundaries worth stating plainly:
- Concurrency covers only the two same-era cells; the bridge cells aren't exercised concurrently — Cell B (Legacy→Modern) has no concurrency coverage at any tier (
vmcp_dual_era_test.go:155, inline). - Operator tier has never run against a cluster — compiles/vets clean, but stateless-Modern-inside-discovered-mode-aggregation is a first; watch the readiness/
BackendsDiscoveredcoupling vs the deliberate cold-cache connect skip. - CLI tier proves the bridge only under a non-conformant
Acceptheader (no SSE parser); the conformant path's intended compensation is the operator tier — which is the unrun one. So no executed test proves conformant-Acceptbridging. - No Redis / cross-pod
RestoreSessionmixed-era coverage, and the #5992 stale-cache window is documented-not-fixed. Reasonable to defer; largest untested surface the issues imply.
Nice work overall — the documentation-of-intent throughout (especially the SDK failure-chain and the falsifiability guards) is a model for this kind of change.
Reviewed with a multi-agent panel (protocol/security/Go/test axes); findings verified against the code before posting.
885f5ad to
94e48b6
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Approving. Multi-axis review (MCP-protocol, security/confidentiality, Go correctness/architecture, tests) found no blocking correctness or security defect — the confidentiality invariants of the bridge cells hold by construction and are faithfully pinned, the Modern-connect skip is spec-justified and bypasses no data-plane security control, and CI is green on the checks that exercise this change (E2E Core (vmcp) + all three Operator CI tiers, which retires the "operator tier never run against a cluster" concern).
Everything in my earlier review is non-blocking — please treat the following as follow-up rather than merge-blockers:
- The
revisionReportertype-assertion silent-degradation foot-gun + four duplicate declarations → the one hardening item I'd genuinely like folded in (an exported interface + a co-located compile-time assertion), but fine to defer. - Cell B (Legacy→Modern) has no concurrency coverage at any tier — worth a follow-up given #5912's cross-delivery framing.
- Minor comment/message precision nits (the
factory.gowarn text, the "session not found" wording, the/statusexposure justification).
Nice work — the documentation-of-intent throughout (the SDK failure-chain, the falsifiability guards, the disclosed coverage boundaries) is a model for this kind of change.
Part of #5992. The 'stale Legacy cache is harmless' property was pinned only for ListCapabilities (forwarding=false). The forwarding tools/call path is the risky arm: binding forwarders makes newStreamableHTTPClient enable WithContinuousListening, whose standalone server->client GET stream is what made the pooled session-factory connect fail against a session-less backend in #6006. The per-call path survives because go-sdk's streamableClientConn opens that stream only when the negotiated protocol is < 2026-07-28, and the SDK's Modern-first client negotiates Modern on the mis-cached-Legacy hop — suppressing the stream entirely. legacyInit then flips the cache to Modern in-band. Pin it against a real stateless go-sdk backend so a regression in that version gate surfaces here, not in production.
…6010) Part of #5992. The 'stale Legacy cache is harmless' property was pinned only for ListCapabilities (forwarding=false). The forwarding tools/call path is the risky arm: binding forwarders makes newStreamableHTTPClient enable WithContinuousListening, whose standalone server->client GET stream is what made the pooled session-factory connect fail against a session-less backend in #6006. The per-call path survives because go-sdk's streamableClientConn opens that stream only when the negotiated protocol is < 2026-07-28, and the SDK's Modern-first client negotiates Modern on the mis-cached-Legacy hop — suppressing the stream entirely. legacyInit then flips the cache to Modern in-band. Pin it against a real stateless go-sdk backend so a regression in that version gate surfaces here, not in production.
cca1729 to
8d5dd24
Compare
vMCP opens a persistent per-(session x backend) connection for three purposes:
list_changed propagation, backend-session-id resume hints, and
identity-binding metadata. All three are inapplicable to a stateless
2026-07-28 ("Modern") backend, and against one the connect does not merely
go unused -- it fails.
Measured, not assumed. go-sdk v1.7's Connect is Modern-first, so
server/discover succeeds and Modern is negotiated; no raw Legacy initialize
is ever sent. Connect then opens a subscriptions/listen stream, because
mcpcompat's Initialize unconditionally installs the three list-changed
notification handlers and go-sdk opens that stream whenever any is set. A
stateless backend cannot serve it -- no Mcp-Session-Id, so "session not
found" -- which fails Connect, tears the connection down, and fails the
follow-up tools/list. The connector returns (nil, nil, err).
So every session paid a discover, a failed subscribe, a failed tools/list
and a teardown per Modern backend, ending in a WARN indistinguishable from a
genuinely dead backend. With every backend Modern it also tripped "All
backends failed to initialise", which reads as an outage. Note the first of
the three purposes is precisely what breaks it: vMCP asks for a notification
stream the backend cannot give it.
Nothing was functionally broken -- capability advertisement and every tool,
resource and prompt call flow through core.* to the revision-aware per-call
backend client, never through these session connections, and the backend was
already excluded from the session either way.
Skip the connect for a backend whose cached revision is known Modern, and
suppress the all-failed warning when every backend was skipped that way. The
revision is read through a func-typed factory option rather than a new
interface method, keeping CachedRevision off vmcp.BackendClient; the
composition root does the one type assertion, mirroring pkg/vmcp/health's
existing read-model pattern. The lookup consumes only a resolved Revision
plus a "known?" bool, never probeRevision's classification internals, so the
concurrent rework of backend-era detection cannot break it.
An unprobed backend still gets today's unconditional connect. The cache is
warmed by the first backend call through dispatch (or, when configured, the
health monitor -- which is opt-in), neither ordered before the first client
session. So every session created before the first backend call still pays
the failing sequence and its WARN is not suppressed. Closing that window
needs a TTL re-probe (#5992) and is deliberately not attempted here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vMCP resolves whether each backend speaks the Legacy (2025-11-25) or the stateless Modern (2026-07-28) MCP revision and caches the result. The operator already publishes it as VirtualMCPServer.status.discoveredBackends[].mcpRevision, but the vMCP /status endpoint did not expose it, so an operator debugging a dual-era deployment outside Kubernetes had no way to see which era a backend negotiated -- and no e2e tier could assert it. Add MCPRevision to BackendStatus, populated from the live health state the backend loop already fetches. omitempty because the revision has no static registry counterpart: it is observable only through the health monitor, which is opt-in, so it is legitimately absent rather than empty for a monitoring-disabled deployment. Existing consumers see byte-identical JSON. The endpoint is unauthenticated by design. This adds no new exposure class: the same value is already public via the CRD status, and the neighbouring transport field is a strictly finer-grained fingerprint of the same backend. The doc comment enumerating what /status exposes is updated accordingly, since that is what an operator reads when deciding whether the endpoint needs a NetworkPolicy. Tests cover all three states: populated via a revision-reporting client, empty when the health monitor is disabled (asserted on the decoded JSON so "key absent" is distinguished from "present and empty"), and empty when monitoring runs but the client cannot report a revision -- the shape any BackendClient other than httpBackendClient takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vMCP's per-backend revision machinery -- the Modern-first server/discover probe, the revision cache, Modern enumeration and dispatch, and mcpRevision status surfacing -- was proven only in-process against httptest servers. The existing operator aggregation test uses gofetch and osv, both Legacy, so they take the unchanged initialize path and validate none of it. Add one discovered-mode VirtualMCPServer over a deliberately mixed backend set: yardstick with STATELESS=true (Modern) alongside gofetch (Legacy). It asserts the aggregated tool set spans both backends, that a tool call routed to the Modern backend succeeds, and that mcpRevision reports 2026-07-28 and 2025-11-25 respectively on both the CRD status and the /status endpoint. Asserting the revision on both backends is what makes the test falsifiable: a regressed probe that classified the stateless backend Legacy would otherwise leave every assertion passing while testing the Legacy edge twice. Health monitoring must be enabled explicitly for any of this to work. mcpRevision is observable only through live health state, and vMCP only starts the monitor when operational.failureHandling.healthCheckInterval > 0; with the section absent the status payload carries no backends at all and both revision assertions would time out. unhealthyThreshold is mandatory once the interval is set, or the vmcp pod fails at startup. Extend the existing VMCPBackendStatus test mirror with the new field rather than declaring a parallel type, so it does not drift from the struct it exists to mirror. Closes #5979. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vMCP classifies the client edge per request and each backend edge independently, so a client on either MCP revision can reach backends on either revision. RFC THV-0081 marked the two era-mismatched combinations "Fails" for a pass-through proxy and deferred them to vMCP, which can bridge them because it terminates the client connection and re-originates. Until now that claim was only tested in-process, against httptest peers that share the module's own encoder and so cannot catch a wire-shape divergence. Drive a real thv vmcp serve over two containerized yardstick backends -- one with STATELESS=true (Modern), one without (Legacy), differing in that single variable -- and exercise all four cells with a raw stdlib HTTP client for Modern traffic and the SDK client for Legacy. Both axes of the matrix are asserted, not assumed. BeforeEach gates every spec on /status reporting 2026-07-28 for the stateless backend and 2025-11-25 for the other; without that, a regressed server/discover probe that classified the stateless backend Legacy would leave all six specs passing while silently testing the Legacy backend edge twice. That is also why the vMCP config enables health monitoring: the revision is observable only through live health state. Session-id expectations follow the specs rather than convenience: 2025-11-25 lets a server assign the id on the InitializeResult and never requires it to be echoed on later responses, and the draft requires a Modern server not to mint or echo one at all -- so presence is asserted on the Legacy handshake and absence on every Modern response. The Legacy client also sends notifications/initialized and the MCP-Protocol-Version header that the spec requires on post-handshake requests, so the suite proves vMCP tolerates what a real Legacy client sends. The kill-switch-off spec asserts the exact 400 the draft mandates, and that no resultType is emitted, so it stays valid once the response body is made conformant. Known limitation, recorded in the file: the raw client cannot parse SSE, so the bridge is proven only under a non-conformant Accept header. The Kubernetes tier opts into the full header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The security-critical bridge cell is a Modern (stateless) client reaching a Legacy (session-based) backend: vMCP must synthesize a backend session per request, carrying the calling principal's own credential, never shared across principals. That invariant holds today only by construction -- legacyCallTool opens a client, initializes, calls and closes per request, so nothing is pooled and nothing can bleed. RFC THV-0083 D6 plans a shared backend connection pool, which is precisely the change that could break it silently. Nothing defended it. Pin it. Two principals issue tools/calls over two sequential rounds, with the correlating nonce carried in the tool arguments -- an independent channel from the credential, which travels via the outgoing-auth strategy reading identity off the live request context. So a symmetric swap of two principals' credentials diverges the two channels and fails, where session-id cardinality alone would not. The second round is what gives the pin teeth. With one call per principal, per-request client creation is never exercised -- only per-principal creation -- so a pool keyed by (principal, backend) passes unnoticed. That is the design most likely to be built, because per-principal keying looks safe; it is not, since fallbackIdentity is captured at client construction, so a client outliving one request serves later ones from a frozen identity snapshot (#5323). Verified by simulating exactly that pool and watching the pin fail. Also pinned: the Legacy backend must never receive reserved io.modelcontextprotocol/* _meta, AND a non-reserved key must survive the same hop -- asserting only the absence would pass on a blanket _meta drop, which would silently kill progressToken and trace context. A backend classified Modern must get no session connect, fingerprinted on the subscriptions/listen that go-sdk emits only from a full client handshake, with a companion test that omits the revision lookup and asserts the fingerprint IS observed, so the signal self-validates against the pinned SDK rather than passing vacuously forever. Every assertion was mutation-verified: each was made to fail against deliberately broken code before being trusted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the Modern-dispatch kill switch off (the default), vMCP does not serve
the 2026-07-28 revision. It answered that by letting the request fall through
to the SDK-backed stateful server, which rejects it with a plain-text HTTP 400
whose body is Go-API advice addressed to the server author ("set
StreamableHTTPOptions.Stateless = true"). A client cannot parse that as a
protocol error, cannot act on it, and learns nothing about what the server
does support.
The draft's Streamable HTTP "Protocol Version Header" section requires a
server that does not implement a requested version -- explicitly including "a
known version the server has chosen not to support", which is exactly a
disabled kill switch -- to reply 400 with an UnsupportedProtocolVersionError
listing its supported versions. Answer it in the classifier, which is the
layer that owns the decision and the only one that knows the switch state.
The error type and its supported-versions data already existed.
server/discover is exempt and still falls through: it is how a client learns
which revisions a server supports, so refusing it on version grounds would
leave the client unable to negotiate down. go-sdk exempts it for the same
reason and its stateful path answers discover correctly.
This surfaced a latent defect in
TestIntegration_CedarAuthzDenial_ModernPath_IsAudited, which never set
ModernDispatchEnabled and so never reached dispatchModern's re-homed call
gate -- it passed on a 403 from a different gate. Its helper now enables the
switch, so the test exercises what its name claims. The other tests sharing
that helper send Legacy traffic, which the classifier passes through
regardless of the flag.
The underlying go-sdk behaviour is a separate upstream bug: it has
writeJSONRPCError and uses it two lines later for the adjacent header-mismatch
case. Reported separately; this change does not depend on it, and every other
SDK consumer serving a stateful server is still affected.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
8d5dd24 to
5dc1f2b
Compare
|
Thanks — this was a genuinely useful review, and two of its findings changed the code rather than just the comments. All items addressed; details below, including two where I disagreed or where the finding turned out to be already-resolved. 🔴 Must-fixCodespell 🟡 Suggestions1. Type-assertion foot-gun + four duplicate I had kept the duplicates on the reasoning that an exported interface would create a cycle. That was only true for exporting from The property you cared about is preserved: it stays off 2. 3. 4. Axis notesCell B concurrency — fixed here rather than deferred. You were precisely right, and it was worse than "no coverage at any tier": the existing concurrency spec races the two same-era cells (Legacy client→Legacy backend, Modern client→Modern backend), so the off-diagonal — the entire point of #5912 — was never concurrent. Added a spec that crosses the tool names, so a Legacy session calls the Modern backend while a stateless Modern client calls the Legacy backend, concurrently, with distinct principals. That is the configuration where cross-delivery matters most, because the two bridge cells re-originate through different egress paths (stateless Modern-only client stranded by the plain-400 — already resolved, no follow-up needed. This was written while the Operator tier never run against a cluster — now retired, but it did find a real bug first. CI caught the spec crash-looping the vmcp pod: supplying the Conformant- Redis / cross-pod mixed-era — filed as #6008, parented under #5756, including the two specs #5981 removed pending this bridge and the two traps a implementer will otherwise hit (health monitoring must be enabled explicitly; assert each backend's era rather than trusting the env, since #6004 unified on one image where Cell A's per-request strictness — agreed it is stricter than per-principal and deliberately so. Left as-is; the pin's own comment records that it must be consciously re-baselined when D6's pool lands, rather than filing against an issue that does not exist yet. Rebase noteRebased onto One coherence fix from that: #6016 added a "Backend MCP Revision Classification" section to the same Also worth flagging for whoever reviews next: #6010 ("Pin forwarding tools/call against a Unrelated flakes seen on this PRBoth earlier CI failures turned green on re-run with no code change: |
Three packages each declared their own copy of the same one-method interface to type-assert the backend client for its cached MCP revision. The assertion is the failure mode: a concrete client or wrapper that stops exposing CachedRevision compiles cleanly and silently disables the telemetry revision label, the /status field, and the session layer's Modern-connect skip -- each of which degrades quietly by design, so nothing would surface the mistake. Export one vmcp.RevisionReporter and pin the implementation with `var _ vmcp.RevisionReporter = (*httpBackendClient)(nil)` next to the method, so dropping or renaming it is a build error. pkg/vmcp is the right home: all three consumers already import it and it imports none of them. Exporting from pkg/vmcp/client -- the first home considered -- could not work, since client imports backendtelemetry. It stays off vmcp.BackendClient deliberately. It is a read-model, not a protocol operation, and widening the interface would force every implementation and every generated mock to satisfy it. Also correct two log messages this change made it easy to misread: - "All backends failed to initialise; session will have no capabilities" claimed something untrue. On the Serve path capabilities come from the core, not from these connections, so a session holding zero connections still lists and calls tools normally; what is lost is list_changed propagation. It also overstated the mixed case, where some backends were skipped as Modern rather than failing. - The subscriptions/listen failure comment could be read as "the Modern spec requires a session". It does not -- the revision has no sessions at all. The rejection is a go-sdk v1.7.0-pre.3 artifact, and the comment now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5dc1f2b to
f547955
Compare
Main was red: #6024 renamed mcpparser.ReservedModernMetaKeys / StripReservedModernMeta to ReservedMetaPrefix / StripReservedMeta (with passthroughMetaKeys), and #6006's bridge_regression_test.go still referenced the removed identifier, breaking typecheck on the merge commit (Linting / Lint Go Code and Tests / Test Go Code both failed). Assert on the reserved namespace (ReservedMetaPrefix) instead of the removed fixed list — the same pattern revision_realbackend_test.go already uses. This is also strictly stronger: it catches any reserved key the spec adds later, and no passthroughMetaKeys entry rides a tools/call, so the blanket prefix check is exact.
PR #5981 removed two specs from the single-server dual-era k8s test because they cannot work on one MCPServer: a single go-sdk backend cannot be both stateless (for Modern per-request clients) and session-issuing (for Legacy clients) at once. PR #6006 then landed the vMCP cross-generation bridge, but neither of its tiers configures Redis, so the session-store dimension of the bridge stayed unproven: nothing exercised a Legacy client's session metadata living in Redis while a Modern backend shared the group, and nothing exercised what a Redis outage does to a mixed-era deployment. Add an operator-tier spec over a mixed Legacy+Modern yardstick backend set behind one vMCP with Redis session storage. It asserts the asymmetry a single-server test could not express: a Redis outage 503s the Legacy session path while the Modern stateless path keeps serving, because vMCP classifies client era in middleware ahead of the SDK session layer and dispatchModern never reads the session store. Coverage: concurrent Legacy-session and Modern-stateless traffic with no cross-delivery; Redis holding a backend-session key for the Legacy backend only; and outage to 503 to recovery, with the wiped pre-outage session correctly reported terminated. Two constraints are documented in the file because both cost a debug cycle. yardstick's echo tool schema-validates input against ^[a-zA-Z0-9]+$ and reports a violation as a successful result with isError set, so resp.Error being nil is not proof a call worked. And a go-sdk/mcpcompat client cannot open a Legacy session against a Modern-dispatch-enabled vMCP at all: Connect is Modern-first, so server/discover routes to dispatchModern, the client negotiates Modern, gets no session, and then 404s on subscriptions/listen. This spec therefore drives all traffic through the raw client, which pins the era per request. Runs at one replica rather than the multiple replicas the issue suggests. WithSessionIdManager is wired unconditionally, so one replica exercises the Redis store just as fully; two would add only the rehydration path, whose transient-error branch returns 404 instead of 503 unless the pod holds a cached rehydration, making the outage assertion nondeterministic. Cross-pod Redis session sharing is already covered by virtualmcp_redis_session_test.go. Closes #6008 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #5981 removed two specs from the single-server dual-era k8s test because they cannot work on one MCPServer: a single go-sdk backend cannot be both stateless (for Modern per-request clients) and session-issuing (for Legacy clients) at once. PR #6006 then landed the vMCP cross-generation bridge, but neither of its tiers configures Redis, so the session-store dimension of the bridge stayed unproven: nothing exercised a Legacy client's session metadata living in Redis while a Modern backend shared the group, and nothing exercised what a Redis outage does to a mixed-era deployment. Add an operator-tier spec over a mixed Legacy+Modern yardstick backend set behind one vMCP with Redis session storage. It asserts the asymmetry a single-server test could not express: a Redis outage 503s the Legacy session path while the Modern stateless path keeps serving, because vMCP classifies client era in middleware ahead of the SDK session layer and dispatchModern never reads the session store. Coverage: concurrent Legacy-session and Modern-stateless traffic with no cross-delivery; Redis holding a backend-session key for the Legacy backend only; and outage to 503 to recovery, with the wiped pre-outage session correctly reported terminated. Two constraints are documented in the file because both cost a debug cycle. yardstick's echo tool schema-validates input against ^[a-zA-Z0-9]+$ and reports a violation as a successful result with isError set, so resp.Error being nil is not proof a call worked. And a go-sdk/mcpcompat client cannot open a Legacy session against a Modern-dispatch-enabled vMCP at all: Connect is Modern-first, so server/discover routes to dispatchModern, the client negotiates Modern, gets no session, and then 404s on subscriptions/listen. This spec therefore drives all traffic through the raw client, which pins the era per request. Runs at one replica rather than the multiple replicas the issue suggests. WithSessionIdManager is wired unconditionally, so one replica exercises the Redis store just as fully; two would add only the rehydration path, whose transient-error branch returns 404 instead of 503 unless the pod holds a cached rehydration, making the outage assertion nondeterministic. Cross-pod Redis session sharing is already covered by virtualmcp_redis_session_test.go. Closes #6008 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
vMCP classifies the client edge per request and each backend edge independently, so a client on
either MCP revision can reach backends on either revision. RFC-0081 marked the two era-mismatched
combinations "Fails" for a pass-through proxy and deferred them here, because vMCP is the component
that terminates the client connection and re-originates backend calls.
Investigating those two cells first changed what this PR needed to be. Both were already
largely functional after #5910/#5911, and their difficulty is inverted from how #5912 describes it:
session held per request and partitioned by
(principal, backend)) already holds byconstruction.
legacyCallToolopens a client, initializes, calls and closes per request, sonothing is pooled and no credential can bleed. There was nothing to build — only an undefended
invariant to pin.
attempted a connect that cannot succeed.
So the work is one small behaviour fix, one additive status field, and the coverage that makes all
four cells falsifiable.
ConnectisModern-first, so
server/discoversucceeds and no Legacyinitializeis ever sent — butmcpcompat'sInitializeunconditionally installs the list-changed handlers, so go-sdk opens asubscriptions/listenstream, which a session-less backend answerssession not found. Thatfails
Connect, tears the connection down, and fails the follow-uptools/list. Every sessionpaid a discover + failed subscribe + failed list + teardown per Modern backend, ending in a WARN
indistinguishable from a dead backend. Note the causality:
list_changedis one of the threereasons those connections exist, and it is precisely what breaks the connect — vMCP was asking
for a notification stream the backend cannot give it.
/status. The operator already published it viaVirtualMCPServer.status.discoveredBackends[].mcpRevision; the HTTP endpoint did not, so no e2etier could assert which era a backend negotiated.
cannot break them silently.
Closes #5912
Closes #5979
Type of change
/statusfield, and the live dual-era e2e matrixTest plan
task test)task test-e2e) —LABEL_FILTER="vmcp && dual-era", 6/6 greentask lint-fix)Manual verification beyond the suites:
byte-identically. Including two that revealed the assertion itself was wrong:
Mcp-Method") passed with the productionskip removed, because go-sdk v1.7's client is Modern-first and never sends a raw Legacy
initialize. Re-based on the presence ofsubscriptions/listen, which only a full SDK clienthandshake emits.
_metapin asserted only that reserved keys were absent, which a blanket_metadrop would also satisfy — silently killing
progressTokenand trace context. Now also asserts anon-reserved key survives the same hop.
TOOLHIVE_VMCP_MODERN_STATELESS=trueexported, the pre-fix helper leaked it and exactly thekill-switch spec failed; with the fix, 6/6.
(
server/discover→resultType:"complete",supportedVersions:["2026-07-28","2025-11-25"]),because the whole suite rests on that hop working.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.No operator API surface is touched — no CRD, no
api/v1*type. The/statuschange is additive onthe vMCP HTTP endpoint (
mcp_revision,omitempty), so existing consumers see byte-identical JSONwhenever health monitoring is disabled.
Changes
pkg/vmcp/session/factory.gopkg/vmcp/cli/serve.gopkg/vmcp/server/status.goBackendStatus.MCPRevision, populated from the live health statepkg/vmcp/server/bridge_regression_test.gopkg/vmcp/session/factory_revision_test.gopkg/vmcp/server/status_test.gomcp_revisionstatestest/e2e/vmcp_dual_era_test.go,..._helpers_test.gotest/e2e/thv-operator/virtualmcp/virtualmcp_dual_era_backends_test.gotest/e2e/thv-operator/virtualmcp/helpers.goVMCPBackendStatustest mirror, so it stops drifting fromserver.BackendStatusdocs/arch/10-virtual-mcp-architecture.mdDoes this introduce a user-facing change?
Yes. The vMCP
/statusendpoint'sbackends[]entries now carrymcp_revision— the backend'snegotiated MCP protocol revision (
2025-11-25or2026-07-28). It isomitemptyand is populatedonly from live health state, so it is absent when health monitoring is disabled or a backend has not
been probed yet. The endpoint is unauthenticated by design, so this is a real (if small) widening of what an
unauthenticated caller learns: which MCP revision each backend negotiated. It is a protocol version,
not a secret, and the same value is also published on
VirtualMCPServer.status.discoveredBackends[]— but that is behind cluster RBAC, a different trust boundary, so treat the two as separate
disclosures rather than one. Deployments that care should restrict
/statusby network policy, asits own doc comment already advises.
Special notes for reviewers
Two assertions carry most of the value, and both exist because a naive version passed vacuously.
BeforeEachgates every CLI-tier spec on/statusreporting2026-07-28for the statelessbackend and
2025-11-25for the other. Without it the 2×2 matrix can silently collapse to 1×2:a regressed
server/discoverprobe would classify the stateless backend Legacy and all sixspecs would still pass while testing the Legacy backend edge twice. This is not hypothetical —
it caught exactly that during a rebase, when Unify e2e yardstick backend on 1.2.0 #6004 changed which yardstick image is Modern-capable.
backend observed. Counting distinct backend sessions is insufficient: under a symmetric swap of
two principals' credentials the count stays correct. The two channels are independent, so a swap
makes them diverge.
Health monitoring is opt-in, and that is the sharpest edge in this feature.
mcp_revisionisobservable only through live health state, and vMCP only starts the monitor when
operational.failureHandling.healthCheckInterval > 0. Both e2e tiers must enable it explicitly;with the section absent the status payload carries no backends at all and the revision assertions
time out.
unhealthyThresholdis mandatory once the interval is set, or the vmcp pod fails atstartup. This bit the work four separate times and is the strongest argument for the TTL re-probe
tracked in #5992.
Known limitations, deliberate:
test-e2e-lifecycle.ymland verified by compile +go vet+ lint, following the precedent set byAdd live e2e tests for dual-era stateless proxy behavior #5981's own k8s tier. The one assumption no existing test covers is a yardstick backend inside a
discovered-mode vMCP group alongside a Legacy backend; if it fails, the "aggregates tools from
both backends"
Ittimes out first.Acceptheader (recorded in the file). The k8s tier opts into the full header.initOneBackendrather than fixed: abackend redeployed stateless→stateful keeps a stale
Modernlabel until a call re-probes.Follow-ups this unblocks:
_meta) is deliberately untouched — this PRasserts nothing about response
_metaso the two cannot conflict. One trap for whoever takes it:conversion.ToMCPMetais bidirectional, called on both the response path and outbound requests,so the strip belongs at the response call sites rather than inside it.
Redis-outage → 503) are now unblocked. Neither tier here configures Redis, so that coverage is
still outstanding — the operator-tier file is the natural place to grow it.
go-sdk's plain-text 400 rather than the
-32022+ supported-versions body the draft mandates. Afix is ready on a follow-up branch, and the underlying go-sdk defect has been written up for
upstream.
🤖 Generated with Claude Code