You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MCP 2026-07-28 (Modern) conformance and revision-classification follow-ups
Follow-ups identified while reviewing #5993 (go-sdk v1.7 adoption) and its stacked
fix PR. None are regressions from those PRs — they are pre-existing gaps or
deferred work that the v1.7 adoption made visible. Filed as one issue so the
Modern-conformance surface is tracked in one place; each item is independently
actionable.
Context: found while reviewing #5993 (go-sdk v1.7 adoption, part of #5754) and fixing
the actionable subset in the stacked #5997.
Not duplicated here:#5986 already covers stripping reserved io.modelcontextprotocol/* keys from backend response_meta — including the serverInfo leak to Legacy downstream callers, which this review independently
surfaced. Item 1 below is the request-side counterpart and is a different problem
(the key list doing double duty), not the same fix.
Already fixed in #5997 (listed so this issue isn't read as still-open)
Revision cache never self-healed Legacy→Modern — fixed in-band by reading the
SDK-negotiated version off InitializeResult in legacyInit. Closes that half of vMCP backend revision cache has no success/TTL re-probe (stale on stateless redeploy) #5992. Still open there: an integration test for the stateful↔stateless redeploy
paths, and whether a TTL re-probe is still wanted for anything else.
Mcp-Name never sentinel-encoded — EncodeSentinelName added and wired in.
1. Split ReservedModernMetaKeys, then add logLevel
Confirmed, not speculative:io.modelcontextprotocol/logLevel is a genuine fourth
reserved per-request _meta key — it appears in the draft's per-request protocol-field
table and go-sdk declares it (mcp/protocol.go:2370, flagged Deprecated per SEP-2577). pkg/mcp/revision.go lists only three.
The fix is not "append it to the slice", because that slice does double duty:
the egress strip-list (StripReservedModernMeta, mergeModernMeta), and
the ingress "this request claims Modern" signal (hasModernSignal, revision.go:484).
Appending enrols it in (2) as a side effect, which would make ToolHive stricter than
go-sdk — validateRequestMeta (mcp/shared.go:543) gates purely on protocolVersion
and merely best-effort-decodes logLevel. A request carrying only logLevel and no protocolVersion is forwarded today; it would start returning 400. That's a real
regression surface (see pkg/transport/proxy/transparent/revision_classification_test.go
and revision_guard_regression_test.go) in exchange for a deprecated field.
So: split the two lists first, then add logLevel to the strip-list only.
Impact if left alone is low — a leaked logLevel hits go-sdk's early return and is
never read, so nothing is broken today. This is about the latent trap, not the key.
2. No x-mcp-header / Mcp-Param-{Name} mirroring on the Modern egress
Nothing in the repo matches x-mcp-header. The spec requires clients to mirror
designated parameter values into HTTP headers, and servers to reject omissions with -32020. Any Modern backend that annotates a tool parameter is therefore uncallable
through vMCP, surfacing only as a generic backend failure. Separately, clients MUST reject tool definitions whose x-mcp-header values violate the
constraints; vMCP republishes aggregated schemas without that validation.
3. Architecture documentation for the dual-revision subsystem
The whole vMCP dual-revision design is undocumented: zero hits for server/discover
across docs/, and no revision/Modern/Legacy content in docs/arch/10-virtual-mcp-architecture.md. Both the introducing PR and #5993 landed
without touching docs/arch/, which CLAUDE.md requires for architectural changes.
Add a "Backend MCP Revision Classification" section covering: the Modern-first server/discover probe with supportedVersions as the authoritative signal (and
that a clean discover response alone is not proof); the -32022 negotiate-down
path; the SSE⇒Legacy gate; the per-backend cache and its asymmetric self-healing;
the Legacy _meta strip; and the mcpRevision status field.
Minor: docs/arch/03-transport-architecture.md:680 still calls 2026-07-28 "the
draft spec".
4. Consolidate the negotiate-down rationale (minor)
The same explanation — a v1.7 shim answers server/discover even when negotiating
down, so a clean response is not proof of Modern — is stated at three sites: modern.go (the errModernNegotiatedDown doc), and client.go twice (the modernDiscover and probeRevision docs). Make one canonical and back-reference it.
Low value; noted only because it is the surface that has to be edited whenever the
exact-match tripwire fires.
vMCP always declares empty clientCapabilities on Modern backend calls. This is
honest (the shim cannot drive multi-round retrieval) and spec-consistent, but the
consequence deserves a named limitation rather than being inferable from three
scattered comments: elicitation/sampling forwarding — which vMCP does implement
for Legacy backends (forwardingClientOptions, forwarding.go:116-124) — is
structurally unavailable for Modern backends, and a compliant Modern backend needing
input returns -32021, surfacing as an opaque errModernProtocolError.
6. Retire the hand-rolled Modern shim (~1,500 lines deletable)
pkg/vmcp/client/modern.go (380), pkg/vmcp/server/modern_envelope.go (561), and pkg/mcp/revision.go (553) hand-roll the Modern wire shape. Five comments justified
that on the grounds that go-sdk v1.6.1 could not express it — a premise the v1.7
adoption invalidated, and which #5997 corrected in place.
The shim is still required today, but for a much narrower reason than those comments
implied: mcpcompat's public Client API exposes no no-initialize primitive — only
the private, Legacy-shaped resumeCall. go-sdk itself implements the revision fine; TestListCapabilities_MisCachedLegacy_SelfHealsViaSDKNegotiation shows its client
negotiating Modern unaided, and #5997's in-band self-heal now reads the negotiated
version straight off InitializeResult.
So this is a two-step with a dependency, not a judgement call:
Add a no-initialize primitive to toolhive-core's mcpcompat public client API.
Retire the vMCP shim in favour of it, deleting the hand-rolled envelope, the raw modernCall path, and most of the revision-classification machinery that exists
only to decide which of the two shims to drive.
Worth confirming step 2's true scope before committing — some of revision.go serves
the ingress classifier (hasModernSignal, ValidateHeaderConsistency) and would
survive regardless. But the client-side egress duplication looks squarely deletable.
7. Upstream (mcpcompat / go-sdk): two conformance concerns found while reviewing
Both were surfaced by tracing why pkg/mcp/parser_integration_test.go changed
behavior under v1.7. Neither is fixable in this repo; both are worth reporting to toolhive-core (and possibly modelcontextprotocol/go-sdk).
(a) server/discover is sent over the deprecated HTTP+SSE message channel.
MCP 2026-07-28 defines exactly two transport bindings — stdio and Streamable HTTP.
mcpcompat's SSE server uses go-sdk's NewSSEHandler, whose transport implements no ProtocolVersionSupporter gate (only streamable.go:871 does), so filterSupportedVersions advertises 2026-07-28 and the Modern-first client
negotiates Modern over a 2024-11-05 transport. The Modern revision constrains the
Streamable HTTP binding, not what an SDK pushes through a legacy channel — so this is
non-conformant client/server behavior, not spec.
Note it produces an apparent contradiction inside this repo that is worth being aware
of: parser_integration_test.go observes Modern negotiation over an sse-labelled
endpoint, while probeRevision now asserts that can never happen. Both are correct,
because they are different layers — mcpcompat's SSE client reaches /messages,
whereas vMCP's modernCall only ever POSTs to BaseURL, which is the /sse path.
(b) mcpcompat/client.Initialize silently ignores the caller's requested ProtocolVersion. It calls Connect(ctx, tr, nil) with nil ClientSessionOptions,
so the negotiated version is always latestProtocolVersion (mcp/shared.go:50);
only ClientInfo and Capabilities are carried over from the caller's InitializeRequest. A caller explicitly asking for 2024-11-05 gets 2026-07-28
requested on the wire with no error and no warning. parser_integration_test.go:106
does exactly this, which is why that test's behavior changed on the v1.7 bump.
8. Dependency: reach go-sdk v1.7.0 final
go.mod pins go-sdk v1.7.0-pre.3 (indirect, via toolhive-core v0.0.34). renovate.json extends config:recommended, which sets ignoreUnstable: true, so
Renovate will never propose moving off the pre-release — the only exit is a toolhive-core bump. Unlike swaggo/swag's rc5 (codegen tooling), go-sdk is on
the runtime wire path. Track reaching the final release.
MCP 2026-07-28 (Modern) conformance and revision-classification follow-ups
Follow-ups identified while reviewing #5993 (go-sdk v1.7 adoption) and its stacked
fix PR. None are regressions from those PRs — they are pre-existing gaps or
deferred work that the v1.7 adoption made visible. Filed as one issue so the
Modern-conformance surface is tracked in one place; each item is independently
actionable.
Context: found while reviewing #5993 (go-sdk v1.7 adoption, part of #5754) and fixing
the actionable subset in the stacked #5997.
Not duplicated here: #5986 already covers stripping reserved
io.modelcontextprotocol/*keys from backend response_meta— including theserverInfoleak to Legacy downstream callers, which this review independentlysurfaced. Item 1 below is the request-side counterpart and is a different problem
(the key list doing double duty), not the same fix.
Already fixed in #5997 (listed so this issue isn't read as still-open)
SDK-negotiated version off
InitializeResultinlegacyInit. Closes that half ofvMCP backend revision cache has no success/TTL re-probe (stale on stateless redeploy) #5992. Still open there: an integration test for the stateful↔stateless redeploy
paths, and whether a TTL re-probe is still wanted for anything else.
mergeModernMetaduplicatingStripReservedModernMeta— de-duplicated.Mcp-Namenever sentinel-encoded —EncodeSentinelNameadded and wired in.1. Split
ReservedModernMetaKeys, then addlogLevelConfirmed, not speculative:
io.modelcontextprotocol/logLevelis a genuine fourthreserved per-request
_metakey — it appears in the draft's per-request protocol-fieldtable and go-sdk declares it (
mcp/protocol.go:2370, flaggedDeprecatedper SEP-2577).pkg/mcp/revision.golists only three.The fix is not "append it to the slice", because that slice does double duty:
StripReservedModernMeta,mergeModernMeta), andhasModernSignal, revision.go:484).Appending enrols it in (2) as a side effect, which would make ToolHive stricter than
go-sdk —
validateRequestMeta(mcp/shared.go:543) gates purely onprotocolVersionand merely best-effort-decodes
logLevel. A request carrying onlylogLeveland noprotocolVersionis forwarded today; it would start returning 400. That's a realregression surface (see
pkg/transport/proxy/transparent/revision_classification_test.goand
revision_guard_regression_test.go) in exchange for a deprecated field.So: split the two lists first, then add
logLevelto the strip-list only.Impact if left alone is low — a leaked
logLevelhits go-sdk's early return and isnever read, so nothing is broken today. This is about the latent trap, not the key.
2. No
x-mcp-header/Mcp-Param-{Name}mirroring on the Modern egressNothing in the repo matches
x-mcp-header. The spec requires clients to mirrordesignated parameter values into HTTP headers, and servers to reject omissions with
-32020. Any Modern backend that annotates a tool parameter is therefore uncallablethrough vMCP, surfacing only as a generic backend failure. Separately, clients
MUST reject tool definitions whose
x-mcp-headervalues violate theconstraints; vMCP republishes aggregated schemas without that validation.
3. Architecture documentation for the dual-revision subsystem
The whole vMCP dual-revision design is undocumented: zero hits for
server/discoveracross
docs/, and no revision/Modern/Legacy content indocs/arch/10-virtual-mcp-architecture.md. Both the introducing PR and #5993 landedwithout touching
docs/arch/, which CLAUDE.md requires for architectural changes.Add a "Backend MCP Revision Classification" section covering: the Modern-first
server/discoverprobe withsupportedVersionsas the authoritative signal (andthat a clean discover response alone is not proof); the
-32022negotiate-downpath; the SSE⇒Legacy gate; the per-backend cache and its asymmetric self-healing;
the Legacy
_metastrip; and themcpRevisionstatus field.Minor:
docs/arch/03-transport-architecture.md:680still calls 2026-07-28 "thedraft spec".
4. Consolidate the negotiate-down rationale (minor)
The same explanation — a v1.7 shim answers
server/discovereven when negotiatingdown, so a clean response is not proof of Modern — is stated at three sites:
modern.go(theerrModernNegotiatedDowndoc), andclient.gotwice (themodernDiscoverandprobeRevisiondocs). Make one canonical and back-reference it.Low value; noted only because it is the surface that has to be edited whenever the
exact-match tripwire fires.
5. Modern-egress limitation: elicitation/sampling unavailable
vMCP always declares empty
clientCapabilitieson Modern backend calls. This ishonest (the shim cannot drive multi-round retrieval) and spec-consistent, but the
consequence deserves a named limitation rather than being inferable from three
scattered comments: elicitation/sampling forwarding — which vMCP does implement
for Legacy backends (
forwardingClientOptions,forwarding.go:116-124) — isstructurally unavailable for Modern backends, and a compliant Modern backend needing
input returns
-32021, surfacing as an opaqueerrModernProtocolError.6. Retire the hand-rolled Modern shim (~1,500 lines deletable)
pkg/vmcp/client/modern.go(380),pkg/vmcp/server/modern_envelope.go(561), andpkg/mcp/revision.go(553) hand-roll the Modern wire shape. Five comments justifiedthat on the grounds that go-sdk v1.6.1 could not express it — a premise the v1.7
adoption invalidated, and which #5997 corrected in place.
The shim is still required today, but for a much narrower reason than those comments
implied: mcpcompat's public Client API exposes no no-initialize primitive — only
the private, Legacy-shaped
resumeCall. go-sdk itself implements the revision fine;TestListCapabilities_MisCachedLegacy_SelfHealsViaSDKNegotiationshows its clientnegotiating Modern unaided, and #5997's in-band self-heal now reads the negotiated
version straight off
InitializeResult.So this is a two-step with a dependency, not a judgement call:
toolhive-core'smcpcompatpublic client API.modernCallpath, and most of the revision-classification machinery that existsonly to decide which of the two shims to drive.
Worth confirming step 2's true scope before committing — some of
revision.goservesthe ingress classifier (
hasModernSignal,ValidateHeaderConsistency) and wouldsurvive regardless. But the client-side egress duplication looks squarely deletable.
7. Upstream (mcpcompat / go-sdk): two conformance concerns found while reviewing
Both were surfaced by tracing why
pkg/mcp/parser_integration_test.gochangedbehavior under v1.7. Neither is fixable in this repo; both are worth reporting to
toolhive-core(and possiblymodelcontextprotocol/go-sdk).(a)
server/discoveris sent over the deprecated HTTP+SSE message channel.MCP 2026-07-28 defines exactly two transport bindings — stdio and Streamable HTTP.
mcpcompat's SSE server uses go-sdk's
NewSSEHandler, whose transport implements noProtocolVersionSupportergate (onlystreamable.go:871does), sofilterSupportedVersionsadvertises2026-07-28and the Modern-first clientnegotiates Modern over a 2024-11-05 transport. The Modern revision constrains the
Streamable HTTP binding, not what an SDK pushes through a legacy channel — so this is
non-conformant client/server behavior, not spec.
Note it produces an apparent contradiction inside this repo that is worth being aware
of:
parser_integration_test.goobserves Modern negotiation over ansse-labelledendpoint, while
probeRevisionnow asserts that can never happen. Both are correct,because they are different layers — mcpcompat's SSE client reaches
/messages,whereas vMCP's
modernCallonly ever POSTs toBaseURL, which is the/ssepath.(b)
mcpcompat/client.Initializesilently ignores the caller's requestedProtocolVersion. It callsConnect(ctx, tr, nil)with nilClientSessionOptions,so the negotiated version is always
latestProtocolVersion(mcp/shared.go:50);only
ClientInfoandCapabilitiesare carried over from the caller'sInitializeRequest. A caller explicitly asking for2024-11-05gets2026-07-28requested on the wire with no error and no warning.
parser_integration_test.go:106does exactly this, which is why that test's behavior changed on the v1.7 bump.
8. Dependency: reach go-sdk v1.7.0 final
go.modpinsgo-sdk v1.7.0-pre.3(indirect, viatoolhive-core v0.0.34).renovate.jsonextendsconfig:recommended, which setsignoreUnstable: true, soRenovate will never propose moving off the pre-release — the only exit is a
toolhive-corebump. Unlikeswaggo/swag'src5(codegen tooling), go-sdk is onthe runtime wire path. Track reaching the final release.