Skip to content

Refuse an unserved MCP revision conformantly - #6009

Merged
JAORMX merged 1 commit into
vmcp-dual-era-bridgefrom
fix-unsupported-version-conformance
Jul 27, 2026
Merged

Refuse an unserved MCP revision conformantly#6009
JAORMX merged 1 commit into
vmcp-dual-era-bridgefrom
fix-unsupported-version-conformance

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6006 — base is vmcp-dual-era-bridge, so review only the top commit
(Refuse an unserved MCP revision conformantly). GitHub will retarget this to main
automatically when #6006 merges.

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:

Bad Request: protocol version "2026-07-28" is only supported on stateless HTTP servers
(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 precisely a disabled kill switch — to reply 400 with an
UnsupportedProtocolVersionError listing its supported versions.

  • Answer it in the classifier, which is the only layer that knows the switch state and therefore
    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/discover stays 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.

Related: #6002 (Modern conformance follow-ups) — this is the emitting counterpart to #5997's
consuming side, which classifies an incoming -32022 as Legacy. Also relevant to #5959, which
removes this kill switch entirely before GA; sequence that after this.

Type of change

  • Bug fix

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e) — LABEL_FILTER="vmcp && dual-era", 6/6 green
  • Linting (task lint-fix)
  • Manual testing (described below)

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 -32022 error envelope satisfies
both (an error, not a result), so the spec still passes — and it passes for the right reason
rather than by coincidence, which is why it was re-run rather than assumed.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label 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 before
and 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_IsAudited never set ModernDispatchEnabled, so despite
its name it never reached dispatchModern's re-homed authz call gate — with the switch off its
Modern 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) calls writeJSONRPCError correctly for the adjacent
header-mismatch case two lines below the offending http.Error, so the SDK has the right tool and
does 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 own WithStateless
knowledge, 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

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>
@github-actions github-actions Bot added the size/XS Extra small PR: < 100 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.06%. Comparing base (94e48b6) to head (47718da).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX
JAORMX merged commit ec0e2b5 into vmcp-dual-era-bridge Jul 27, 2026
42 checks passed
@JAORMX
JAORMX deleted the fix-unsupported-version-conformance branch July 27, 2026 12:14
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>
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>
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants