Skip to content

Classify -32022 and SSE backends as Legacy, sync stale comments#5997

Open
jhrozek wants to merge 3 commits into
adopt-go-sdk-v1.7from
fix/pr5993-review-followups
Open

Classify -32022 and SSE backends as Legacy, sync stale comments#5997
jhrozek wants to merge 3 commits into
adopt-go-sdk-v1.7from
fix/pr5993-review-followups

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Review follow-ups for #5993, stacked on adopt-go-sdk-v1.7 (base is that branch, not main).

#5993 correctly moved backend revision classification onto server/discover's supportedVersions. Reviewing it turned up two other places where the same "is this peer Modern?" question was answered by the old rule, so a backend could still be stranded on the wrong path:

  • A -32022 was treated as proof of Modern. It means the opposite — the peer does not support the version we asked for. A backend answering it got cached Modern permanently and enumerated zero capabilities, silently: modernListCapabilities tolerates the error as caps == nil, and isRevisionMismatch never reclassified it. Now the error's data.supported list decides, keeping it Modern-positive only when 2026-07-28 is still advertised. This mirrors go-sdk's own reference client (mcp/client.go), whose test for the case is named "unsupported protocol version falls back to initialize". -32020/-32021 remain unconditionally Modern — both require the peer to have parsed Modern _meta.
  • probeRevision was transport-blind. TransportType == "sse" names the deprecated 2024-11-05 two-endpoint transport, whose BaseURL is the GET-only /sse path (pkg/transport/url.go:56-57, httpsse/http_proxy.go:274-278), and modernCall is a single-endpoint POST — so no Modern endpoint is reachable there. It now classifies Legacy without probing, which also avoids POSTing into a stream and hanging to the client timeout (errModernTransient is uncached, so that cost recurred on every call).

The rest is comment accuracy and test coverage. Several comments contradicted the code after the v1.7 bump, which .claude/rules/go-style.md ("Keep Comments Synchronized With Code") calls out as worse than no comment — including five that justified the hand-rolled Modern shim on the grounds that go-sdk v1.6.1 cannot express the Modern wire shape, the premise #5993 invalidates.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test) — targeted runs of ./pkg/vmcp/client/..., ./pkg/vmcp/session/..., ./pkg/mcp/..., all green
  • E2E tests (task test-e2e)
  • Linting (task lint-fix) — 0 issues
  • Manual testing (describe below)

New coverage: TestProbeRevision_SSEGate (handler fails the test if hit, proving no network call), both -32022 branches in TestProbeRevision_TruthTable, two isRevisionMismatch truth-table rows, and TestHTTPSession_CallTool_StripsReservedModernMeta for the previously untested session-path strip site.

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.

Changes

File Change
pkg/vmcp/client/modern.go classifyUnsupportedProtocolVersion decodes data.supported; Data added to modernRPCError; -32022 dropped from isModernProtocolCode
pkg/vmcp/client/client.go SSE⇒Legacy gate in probeRevision; revision-cache and TRIPWIRE docs corrected
pkg/vmcp/client/revision_test.go SSE-gate test; -32022 split into with/without data.supported
pkg/vmcp/server/{modern_envelope,modern_dispatch,serve}.go Stale v1.6.1 shim rationales corrected
pkg/mcp/parser_integration_test.go Wrong SSE explanation replaced with the real mechanism, now asserted
pkg/vmcp/client/reclassify_test.go errModernNegotiatedDown truth-table rows; staleness doc amended
pkg/vmcp/session/internal/backend/*_test.go Session-path strip test; metaFortoolCallMeta
pkg/vmcp/client/{client_test,schema_ingestion_regression_test}.go Fail-loudly on dropped fake response; pin the Legacy path

Does this introduce a user-facing change?

Yes. A backend that rejects our Modern probe with -32022, and any backend on the sse transport, is now correctly driven as Legacy (2025-11-25). Previously a -32022 backend was driven Modern and advertised no tools, resources, or prompts — so affected backends go from silently empty to working.

Special notes for reviewers

  • Retry safety for -32022. errModernNegotiatedDown feeds isRevisionMismatch, so dispatch reclassifies and retries — and unlike the discover source, this one can fire on a side-effecting tools/call. It is safe: go-sdk's ServerSession.handle returns -32022 before the switch req.Method that dispatches to a handler, so the backend provably never executed the request. The sentinel's doc previously justified this as "both call sites are read-only", which is no longer true; it now states the real reason.
  • Exact-match vs range. go-sdk retries Modern on any negotiated version >= 2026-07-28; we exact-match MCPVersionModern, because vMCP's shim speaks exactly one Modern wire shape. Safe today (a dual-era peer also lists 2025-11-25 to fall back to), and this is now the second exact-match site, so the existing TRIPWIRE note names both.
  • HTTP+SSE is deprecated, not removed. An earlier draft of the SSE gate justified it as "2026-07-28 removed HTTP+SSE". That is wrong — the spec's deprecation page states no features have been removed yet; what 2026-07-28 removed is Streamable HTTP's GET stream and sessions. The gate is justified on ToolHive routing instead. Please don't "correct" it back.
  • Apparent contradiction, and why it isn't one. parser_integration_test.go observes Modern negotiation over an sse-labelled endpoint while probeRevision now asserts that can never happen. Different layers: mcpcompat's SSE client reaches /messages, whereas vMCP's modernCall only ever POSTs to BaseURL, which is /sse.
  • Deliberately not fixed here (follow-up issue to be filed once Support MCP 2026-07-28 spec (go-sdk v1.7 via toolhive-core) #5993 lands): the Legacy→Modern revision cache never self-heals under v1.7, and the stale value reaches the mcpRevision CRD status field (vMCP backend revision cache has no success/TTL re-probe (stale on stateless redeploy) #5992). A promising in-band fix exists — initializeClient already receives InitializeResult.ProtocolVersion and discards it. Also pending: possible missing logLevel reserved _meta key, Mcp-Name sentinel encoding, x-mcp-header mirroring, and arch docs for the whole dual-revision subsystem (still undocumented).
  • Commits are split behavioral / comments / tests and are worth reading in order.

Generated with Claude Code

jhrozek and others added 3 commits July 25, 2026 21:51
Two backend-revision signals disagreed with the supportedVersions rule
adopted for go-sdk v1.7, each able to strand a backend on the wrong path.

A -32022 (CodeUnsupportedProtocolVersion) was treated as proof of Modern.
It means the opposite: the peer does not support the version we asked for.
A backend answering it was cached Modern permanently and enumerated zero
capabilities, since isRevisionMismatch never reclassified it. Decode the
error's data.supported list instead and keep it Modern-positive only when
2026-07-28 is still advertised, mirroring go-sdk's own reference client
(mcp/client.go), whose test for this case is named "unsupported protocol
version falls back to initialize". -32020/-32021 stay unconditionally
Modern: both require the peer to have parsed Modern _meta.

probeRevision was also transport-blind. TransportType "sse" names the
deprecated 2024-11-05 two-endpoint transport, whose BaseURL is the GET-only
/sse path, and modernCall is a single-endpoint POST, so no Modern endpoint
is reachable there. Classify it Legacy without a probe, which also avoids
POSTing into a stream and hanging to the client timeout (errModernTransient
is uncached, so that cost recurred on every call).

Retrying under the corrected Legacy classification stays safe for tools/call:
go-sdk rejects an unsupported per-request protocol version before dispatching
to any method handler, so the backend never executed the request.

Also record that revision-cache self-healing is asymmetric under v1.7 and
that the stale value reaches the mcpRevision CRD field (#5992).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five comments justified the hand-rolled Modern shim on the grounds that
go-sdk v1.6.1 cannot express the Modern wire shape. The repo is now on
v1.7.0-pre.3, which implements the revision, so that premise reads as
false to anyone who checks it. The shim is still required, but for a
narrower reason: mcpcompat's public Client API has no no-initialize
primitive, only the private Legacy-shaped resumeCall. Say that instead.

The parser integration test explained its SSE behavior by claiming the
harness cannot capture an initialize carried over the SSE message channel.
That was self-contradictory, since tools/call and resources/read traverse
the same channel and the same middleware and are captured. The real
mechanism: mcpcompat's SSE server uses go-sdk's NewSSEHandler, whose
transport implements no ProtocolVersionSupporter gate, so it advertises
2026-07-28, discover succeeds, and initialize is never sent on that arm.
Assert that directly rather than inferring it, and drop the claim that the
streamable arm sends no discover -- it does, and is negotiated down.

Note also that HTTP+SSE is deprecated but NOT removed by 2026-07-28; what
that revision removed is Streamable HTTP's GET stream and sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The errModernNegotiatedDown arm of isRevisionMismatch had no truth-table
row despite being the only surviving self-healing direction under go-sdk
v1.7, so add both the Modern (mismatch) and Legacy (not a mismatch) cases.

The reserved-_meta strip has two call sites but only the httpBackendClient
one was tested; add the persistent-session counterpart, asserting reserved
keys are stripped, non-reserved caller keys survive, and the caller's map
is not mutated. Its fixture uses scalars because maps.Clone is shallow and
would not have caught a nested mutation.

Two robustness fixes: the SSE fake dropped a queued response after a five
second timeout and returned an empty body, turning a failure into a hang
against a deadline-free context, so fail loudly instead (both send sites,
not just one). And the schema-fidelity test reached its Legacy path only
because the fake's catch-all happens to answer discover with a body that
lacks resultType; pin the revision so adding a field to that arm cannot
silently route the test through the Modern shim and strand the injected
clientFactory it exists to exercise.

metaFor loses its method parameter (every caller passed tools/call, and a
second calling function tripped unparam) and becomes toolCallMeta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 72.05%. Comparing base (ca1198a) to head (acc3e5d).

Files with missing lines Patch % Lines
pkg/vmcp/client/modern.go 90.00% 1 Missing ⚠️
Additional details and impacted files
@@                  Coverage Diff                  @@
##           adopt-go-sdk-v1.7    #5997      +/-   ##
=====================================================
- Coverage              72.06%   72.05%   -0.01%     
=====================================================
  Files                    718      718              
  Lines                  74477    74489      +12     
=====================================================
+ Hits                   53669    53676       +7     
- Misses                 16975    16980       +5     
  Partials                3833     3833              

☔ 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 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff (base is adopt-go-sdk-v1.7, not main). Both behavioral fixes are correct and the reasoning holds up:

  • -32022 classification. Dropping it from isModernProtocolCode and routing it through classifyUnsupportedProtocolVersion is right — a bare -32022 means "I don't support the version you asked for," which a peer negotiating down to Legacy also returns, so treating it as unconditional proof-of-Modern was the bug (silently cached Modern + enumerated zero capabilities). Keying the decision on data.supported containing MCPVersionModern, with best-effort decode → empty → negotiated-down, is the correct fail-safe direction. -32020/-32021 staying unconditionally Modern is right (both require the peer to have parsed Modern _meta).
  • SSE gate in probeRevision. Classifying TransportType == "sse" (the 2024-11-05 two-endpoint transport, GET-only /sse BaseURL) as Legacy without probing is correct — modernCall is a single-endpoint POST that can't reach a Modern endpoint there — and it avoids POSTing into a GET stream and eating the client timeout on every call (since errModernTransient is uncached). TestProbeRevision_SSEGate proves no network call by failing if the handler is hit.

The retry-safety property I paid the most attention to: errModernNegotiatedDown now feeds isRevisionMismatch, so dispatch reclassifies and retries — and via interpretModernResult this can fire on a side-effecting tools/call, unlike the discover-sourced case. The no-double-execution guarantee rests entirely on the peer rejecting the unsupported protocol version before executing the method. That's correct for a spec-compliant peer (per-request version validation precedes method dispatch, as in go-sdk's ServerSession.handle), and you've documented it precisely on the sentinel. Worth being explicit that this is the load-bearing assumption for any non-go-sdk Modern backend — but it's the right call and consistent with the double-exec discipline in the Legacy path.

Comment re-syncs are accurate (the stale v1.6.1-can't-express rationales, the revision-cache asymmetric-self-heal note pointing at #5992, the exact-match TRIPWIRE now naming both sites), and the new session-path strip test + toolCallMeta rename are good. Nice catch on the whole class of "old rule answering the same question" bugs.

Non-blocking: the #5992 staleness (mis-cached Legexpiry→Modern never self-heals and leaks to the mcpRevision CRD field) is real and I agree it's out of scope here — the initializeClient InitializeResult.ProtocolVersion in-band fix you mention sounds like the right follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants