Skip to content

MCP 2026-07-28 conformance and revision follow-ups from the go-sdk v1.7 review #6002

Description

@jhrozek

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.
  • mergeModernMeta duplicating StripReservedModernMeta — de-duplicated.
  • 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:

  1. the egress strip-list (StripReservedModernMeta, mergeModernMeta), and
  2. 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.

5. Modern-egress limitation: elicitation/sampling unavailable

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:

  1. Add a no-initialize primitive to toolhive-core's mcpcompat public client API.
  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions