Refuse an unserved MCP revision conformantly - #6009
Merged
Merged
Conversation
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>
jhrozek
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
jerm-dro and
tgrunnagle
as code owners
July 27, 2026 11:28
JAORMX
approved these changes
Jul 27, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## vmcp-dual-era-bridge #6009 +/- ##
========================================================
- Coverage 72.10% 72.06% -0.04%
========================================================
Files 720 720
Lines 74772 74714 -58
========================================================
- Hits 53911 53846 -65
- Misses 17006 17013 +7
Partials 3855 3855 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
added a commit
that referenced
this pull request
Jul 27, 2026
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>
6 tasks
jhrozek
added a commit
that referenced
this pull request
Jul 27, 2026
* Skip pointless backend connect for Modern backends
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>
* Surface each backend's MCP revision on /status
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>
* Add operator e2e for mixed-era vMCP backends
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>
* Add live e2e coverage for all four vMCP dual-era cells
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>
* Pin the confidentiality invariants of the vMCP bridge cells
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>
* Refuse an unserved MCP revision conformantly (#6009)
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>
* Pin the revision read-model with a compile-time assertion
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>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 27, 2026
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. KNOWN BLOCKER, do not merge as-is: vMCP's Modern dispatcher does not implement subscriptions/listen. Once server/discover is dispatched rather than passed through, it advertises supportedVersions ["2026-07-28","2025-11-25"], so a go-sdk v1.7 client negotiates Modern and then opens the subscriptions/listen stream that mcpcompat's Initialize implies (it installs list-changed handlers unconditionally). dispatchModern answers -32601 and the client tears the connection down, breaking every client that wants server->client traffic. See pkg/vmcp/session/factory.go's initOneBackend, which already records "its push channel is subscriptions/listen, which vMCP does not implement" for the backend edge.
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. KNOWN BLOCKER, do not merge as-is: vMCP's Modern dispatcher does not implement subscriptions/listen. Once server/discover is dispatched rather than passed through, it advertises supportedVersions ["2026-07-28","2025-11-25"], so a go-sdk v1.7 client negotiates Modern and then opens the subscriptions/listen stream that mcpcompat's Initialize implies (it installs list-changed handlers unconditionally). dispatchModern answers -32601 and the client tears the connection down, breaking every client that wants server->client traffic. See pkg/vmcp/session/factory.go's initOneBackend, which already records "its push channel is subscriptions/listen, which vMCP does not implement" for the backend edge.
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. KNOWN BLOCKER, do not merge as-is: vMCP's Modern dispatcher does not implement subscriptions/listen. Once server/discover is dispatched rather than passed through, it advertises supportedVersions ["2026-07-28","2025-11-25"], so a go-sdk v1.7 client negotiates Modern and then opens the subscriptions/listen stream that mcpcompat's Initialize implies (it installs list-changed handlers unconditionally). dispatchModern answers -32601 and the client tears the connection down, breaking every client that wants server->client traffic. See pkg/vmcp/session/factory.go's initOneBackend, which already records "its push channel is subscriptions/listen, which vMCP does not implement" for the backend edge.
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. KNOWN BLOCKER, do not merge as-is: vMCP's Modern dispatcher does not implement subscriptions/listen. Once server/discover is dispatched rather than passed through, it advertises supportedVersions ["2026-07-28","2025-11-25"], so a go-sdk v1.7 client negotiates Modern and then opens the subscriptions/listen stream that mcpcompat's Initialize implies (it installs list-changed handlers unconditionally). dispatchModern answers -32601 and the client tears the connection down, breaking every client that wants server->client traffic. See pkg/vmcp/session/factory.go's initOneBackend, which already records "its push channel is subscriptions/listen, which vMCP does not implement" for the backend edge.
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. KNOWN BLOCKER, do not merge as-is: vMCP's Modern dispatcher does not implement subscriptions/listen. Once server/discover is dispatched rather than passed through, it advertises supportedVersions ["2026-07-28","2025-11-25"], so a go-sdk v1.7 client negotiates Modern and then opens the subscriptions/listen stream that mcpcompat's Initialize implies (it installs list-changed handlers unconditionally). dispatchModern answers -32601 and the client tears the connection down, breaking every client that wants server->client traffic. See pkg/vmcp/session/factory.go's initOneBackend, which already records "its push channel is subscriptions/listen, which vMCP does not implement" for the backend edge.
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Refs #5959. Config.ModernDispatchEnabled (env TOOLHIVE_VMCP_MODERN_STATELESS, default off) was a temporary safety lever gating whether well-formed MCP 2026-07-28 ("Modern") stateless requests were served by classifyingHandler -> dispatchModern or fell through to the SDK path. Both of the issue's gates are now met: the dual-era conformance harness landed in #5837 and go-sdk v1.7 was adopted via #5993. Serve Modern unconditionally and delete the switch, its ServerConfig/Config plumbing, its env var, and the test helpers and specs that existed only to toggle it. This also removes #6009's -32022 UnsupportedVersionError refusal and its server/discover exemption. That refusal answered exactly one condition -- "vMCP supports this revision but has chosen not to serve it" -- which cannot arise once the switch is gone. Every genuine version-grounds rejection still happens, earlier and unchanged, in mcpparser.ClassifyRevision: a _meta protocolVersion naming any other version yields the same conformant -32022, so ClassifyRevision is now the single owner of that decision. server/discover no longer needs an exemption because nothing refuses it on version grounds; dispatchModern answers it via dispatchModernDiscover. authz_integration_test.go's buildCedarAuthzServer had to *set* the switch for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to reach dispatchModern's call gate at all -- a silently-vacuous test class that disappears along with the switch. A blocker documented when this commit was first written -- dispatchModern had no subscriptions/listen case, so once server/discover advertised Modern, a go-sdk v1.7 client negotiated 2026-07-28 and tore its connection down when the listen stream it opens (mcpcompat's Initialize installs list-changed handlers unconditionally) was answered -32601 -- was closed by #6050 (modern_subscriptions.go and Modern list pagination) before this landed: this commit sits on a base that already serves subscriptions/listen.
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
With the Modern-dispatch kill switch off — the default — vMCP does not serve the MCP 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:
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 precisely a disabled kill switch — to reply 400 with an
UnsupportedProtocolVersionErrorlisting its supported versions.owns the decision. The error type and its supported-versions
Data()already existed(
pkg/mcp/revision.go); nothing new was needed but the call.server/discoverstays exempt and still falls through. It is how a client learns whichrevisions 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.
Related: #6002 (Modern conformance follow-ups) — this is the emitting counterpart to #5997's
consuming side, which classifies an incoming
-32022as Legacy. Also relevant to #5959, whichremoves this kill switch entirely before GA; sequence that after this.
Type of change
Test plan
task test)task test-e2e) —LABEL_FILTER="vmcp && dual-era", 6/6 greentask lint-fix)The live dual-era suite matters here specifically: its kill-switch-off spec asserts an exact 400 with
no
resultType, which is precisely the behaviour this changes. A-32022error envelope satisfiesboth (an
error, not aresult), so the spec still passes — and it passes for the right reasonrather than by coincidence, which is why it was re-run rather than assumed.
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.Does this introduce a user-facing change?
Yes, and it changes default behaviour, since the kill switch ships off.
A client sending a well-formed MCP 2026-07-28 request to a vMCP that has not enabled Modern dispatch
now receives HTTP 400 with a JSON-RPC error body — code
-32022(
UNSUPPORTED_PROTOCOL_VERSION),data.supported: ["2025-11-25"],data.requested: "2026-07-28"—instead of a plain-text 400.
Both are 400s and the draft's backward-compatibility guidance tells a dual-era client seeing an
unrecognised 400 body to fall back to
initialize, so a conforming client behaved correctly beforeand behaves correctly now. What changes is that it can now distinguish "this server does not serve
2026-07-28" from "your request was malformed", and it is told which versions to fall back to instead
of having to guess.
Special notes for reviewers
This surfaced a latent test defect worth knowing about.
TestIntegration_CedarAuthzDenial_ModernPath_IsAuditednever setModernDispatchEnabled, so despiteits name it never reached
dispatchModern's re-homed authz call gate — with the switch off itsModern request fell through to the SDK and the 403 it asserted came from a different gate entirely.
It has been passing without exercising the path it exists to cover. Its helper now enables the
switch, so the test does what it 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 defect, and this does not depend on it.
mcp/streamable.go(v1.7.0-pre.3) callswriteJSONRPCErrorcorrectly for the adjacentheader-mismatch case two lines below the offending
http.Error, so the SDK has the right tool anddoes not use it on this path. Reported upstream separately. Fixing it there would not make this
change redundant: vMCP's condition is "ToolHive has chosen not to serve this revision", which only
the classifier knows — the SDK cannot see the kill-switch state. Every other SDK consumer serving a
stateful server is still affected until upstream lands.
Deliberately not done in
mcpcompat. The shim could pre-empt this using its ownWithStatelessknowledge, which would also cover the transport proxies, and that remains a reasonable follow-up if
a second consumer needs it. What was rejected is intercepting and rewriting the SDK's 400, which
would mean string-matching an SDK error message and would break silently the moment the wording
changed.
🤖 Generated with Claude Code