Complete Modern client-facing dispatch: listen + pagination - #6050
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6050 +/- ##
========================================
Coverage 72.24% 72.25%
========================================
Files 722 724 +2
Lines 75125 75317 +192
========================================
+ Hits 54277 54423 +146
- Misses 16985 17018 +33
- Partials 3863 3876 +13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
8a8108d to
7046faa
Compare
jhrozek
left a comment
There was a problem hiding this comment.
Went through this in detail (protocol-compliance, Go quality, architecture/docs, and test coverage) plus checked it against the vmcp anti-pattern list separately. Overall this is solid -- no correctness or spec issues that need to hold up merging. Left a handful of inline notes below, plus two things that don't fit as inline comments because they're not touched by this diff:
docs/arch/10-virtual-mcp-architecture.md(~line 340-342) still says vMCP "does not implement"subscriptions/listenyet -- worth a follow-up correcting that to describe the ack-only, zero-capability handler this PR adds (thelist_changed-is-Legacy-only conclusion there is still accurate, just the premise needs a tweak).docs/arch/03-transport-architecture.md's "Forward-compatibility note" (~line 680-689) still frames a Modernsubscriptions/listenhandler as future work and floatsserverStreamRegistryas the fan-out seam for it. Might be worth a short update now that the handler exists and deliberately doesn't reuse that registry (empty honored-set today, no fan-out consumer yet).
Neither blocks this PR, just flagging so they don't get lost.
|
@jhrozek all six of your inline notes are applied, plus both doc items from your review body. Consolidating here since the threads are marked outdated against the current head (
Two things your questions led to that are worth recording, because both closed gaps we could not otherwise verify: Your A blocking defect was found after your review and is fixed here, so it's worth flagging since you reviewed the earlier tree: the paginator assumed item keys were unique, citing the aggregator's conflict resolver. That holds for tools only — Happy to take anything else. Note the merge is currently blocked on these conversations being resolved. |
|
/retest |
|
@JAORMX I have a feeling the e2e tests failure is real as it's failed on 2 retries |
MCP 2026-07-28 removed the standalone HTTP GET stream, so subscriptions/ listen is the revision's only server->client push channel. vMCP's Modern dispatcher had no case for it, which made vMCP unreachable for any go-sdk v1.7 client: Connect is Modern-first, and once server/discover succeeds it opens a listen stream whenever a list-changed handler is registered -- which mcpcompat's Initialize does unconditionally. The resulting -32601 failed Connect outright and tore the session down. The honored set is computed by intersecting the client's requested notification types against the capability advertisement server/discover publishes, now extracted as newModernCapabilities so the two answers cannot drift. Every push-related flag there is deliberately false, so the honored set is empty today and the acknowledgement says so explicitly. That is the spec's own mechanism for declaring unsupported notification types, not a silent no-op: nothing is ever reported as subscribed and then dropped. No push delivery exists yet (#5743); vMCP must start advertising a push capability before this stream can carry anything. Measured against the kill-switch removal in #6033: 20 regressions before, 6 after. The remaining 6 are unrelated to this channel -- five need server-initiated elicitation/sampling that 2026-07-28 removes outright, and one needs Modern list pagination. Refs #5959, #6033, #5743
dispatchModern's four list verbs returned the full aggregated set and never emitted nextCursor, so a client had no way to page and vMCP silently ignored any cursor it was sent. On the Legacy path the SDK's session-scoped feature store does this split; the Modern dispatcher bypasses the SDK, so it has to do it itself. Modern has no sessions, so a cursor may not denote server-held iteration state. The spec makes cursors opaque to clients -- they MUST NOT parse, modify, or persist them -- which is exactly what lets the server encode position INTO the token instead of remembering it. So the cursor carries the unique key of the last item delivered and the next page is the items sorted strictly above it: keyset pagination, stateless, and the same scheme go-sdk's own server uses. Aggregation across backends needs no per-backend positions. core.List* already returns one flat, conflict-resolved set whose keys are unique across backends, so the cursor encodes a position in the aggregated ordering and adding or removing a backend cannot invalidate it. Page size matches the SDK default (1000) so pages line up across revisions, and an undecodable or wrong-verb cursor is rejected as -32602 rather than being reinterpreted into a plausible but wrong page. The upstream cursor-following in pkg/vmcp/client and the session backend (#5851) is the opposite direction -- vMCP as client -- and is untouched. Refs #5959, #6033
Review found the paginator asserting a precondition that holds for one of its four callers. Only Tool.Name is unique -- ResolveToolConflicts keys a map -- while resources, resource templates and prompts are plain-appended across backends under "no conflict resolution for these yet". With a bare "resume strictly above lastKey" scan, a key shared at a page boundary made the scan skip every copy, so an item appeared on no page at all. Two honest backends both exposing file:///README.md was enough. A 1100-item corpus delivered 1097. The cursor now carries how many items sharing the last key were already sent, so a page can resume inside a run of equal keys. Items sharing a key are interchangeable for that purpose, which keeps the walk correct without a secondary sort key the generic signature cannot see. The comment claiming uniqueness is corrected rather than merged: it now states which callers can collide and that the paginator does not rely on uniqueness. The test corpus was the reason this shipped. Every case drew from a helper minting strictly unique keys, so the one precondition the code called load-bearing was the only thing untested. Collisions must also be placed by SORTED position -- a distinctively named duplicate key sorts away from the boundary and tests nothing, which a first attempt at the fixture did. Cap an inbound cursor before decoding it. A well-formed 6MB cursor decoded successfully at ~295ms of CPU on a verb that is not rate-limited, and a legitimate cursor is tens of bytes. Skip the capability fan-out in subscriptions/listen when nothing could be honored. core.Discover is un-rate-limited and, while no push capability is advertised, its result cannot change the answer -- so N no-op listens cost N fan-outs, now worse since list verbs page. Probing the ceiling first makes that O(1). This renders the fan-out error path unreachable, so its test is removed rather than left asserting a branch the code cannot enter. Assert the SSE framing. Replacing the blank-line event terminator with a single newline previously left the whole suite green -- including against a real client -- while a conformant parser would dispatch neither frame, so the ack-then-result contract was unverifiable. The helper now parses strictly, and the method name, acknowledgement name, subscriptionId key and jsonrpc field are asserted as literals; mutating any of them was silent. Add the negative capability test. The design rests on the advertisement honoring nothing, which was enforced by no test at all -- only by a runtime WARN that fires in production after the offending line ships. Now asserted across all sixteen presence combinations. Use http.ResponseController rather than a Flusher assertion: every ResponseWriter wrapper in the chain defines Flush unconditionally, so the assertion only established that a wrapper was present. Correct the comment that claimed otherwise, and the one claiming headers were committed on all four failure paths when three precede WriteHeader. Log the honored set by count, not by client-supplied URIs. Update docs/arch: subscriptions/listen is no longer unimplemented, serverStreamRegistry is the seam for delivery rather than for the handler, and both the pagination behaviour and the tools-only uniqueness property are now recorded. Refs #5959, #6033, #5743
Three comment corrections from review follow-up, no behaviour change. Name what flipping a push capability flag re-enables: the core.Discover call the ceiling pre-check currently skips, that call's error path whose test was removed rather than left asserting an unenterable branch, and the handler's non-empty-honored branch. All three are uncovered today because they cannot be entered, so whoever flips a flag needs the list. State the cost of moving to http.ResponseController: there is no longer a pre-write capability check, because Go exposes no flushability predicate and calling Flush commits the header. An unflushable writer now surfaces mid-stream instead of as -32603. Deliberate -- the old type assertion answered early but wrongly, since every ResponseWriter wrapper in the chain defines Flush unconditionally. Stop the page-size comment presuming how the Legacy twin will be pinned. Whether that is a pinning harness or a loud failure at negotiation is undecided, and naming one would let the comment go stale.
5f4619f to
1588e6a
Compare
The principal-binding claim in cell 1 was false for two of six egress strategies: header_injection (static env-mounted secret) and unauthenticated (no-op) present one vMCP identity for every downstream user, so the backend's requestState principal binding cannot protect one downstream user from another. Scope the claim to the user-derived strategies and state the consequence — under shared-credential egress vMCP must bind the round itself, because no other component can — which turns wrap-vs-verbatim into a configuration-dependent obligation. Record the third arm the framing missed: a server-side D5 handle over a verbatim backend round, which closes that gap with no keys at the cost of a durable store on the pass-through path. Also from review: the D5 owner check is vacuous under incomingAuth: anonymous (every session stores the unauthenticated sentinel), so slice 5 documents that as a single-tenant caveat and the stronger-than-AEAD claim is scoped to authenticated incoming auth; the drift list gains the fifth SEP-vs-page divergence (unrecognized resultType SHOULD vs MUST) and stops calling requirements 6 and 7 page-only (one is in schema.ts, the other a core-page MUST); SEP-2577's union freeze does not name InputRequest, so that citation is scoped to what the SEP actually says; a cell-1 sequence diagram per the arch-docs convention; and the in-flight list reflects #6050/#6051 having merged. Doc 10's client-edge section still called the -32021 contract 'the planned follow-up' — #6061 landed it, and this PR is the reconciliation pass, so the refresh rides here. Part of #6059. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
* Design MRTR for the vMCP Modern path The 2026-07-28 revision removes server-initiated requests outright and replaces them with client-polled Multi Round-Trip Requests (SEP-2322). vMCP has no MRTR implementation: elicitation and sampling forward only over Legacy sessions, and the Modern egress shim declares empty clientCapabilities, so a compliant Modern backend that needs input answers -32021 and the call surfaces as an opaque protocol error. With the Modern dispatch kill-switch being removed (#5959/#6033), that gap becomes the served behavior. Add the design the closed-as-not-planned #5759 asked for, as its own document so it composes with the in-flight edits to doc 10 (#6050, #6051). Decisions, grounded in the spec text read one day before the revision's finalization and in RFC-0083: - Modern-client/Modern-backend rounds are a stateless pass-through: per-request capability mirroring, verbatim inputRequests/requestState relay, deterministic re-routing of the retry. vMCP adds no state, so no durable store is triggered. - Legacy-client/Modern-backend rounds bridge in-request: vMCP fulfills the backend's inputRequests through the existing ElicitationRequester/SamplingRequester seams and retries the backend call itself, bounded, on one pod — the same bridge go-sdk's own serverMultiRoundTripMiddleware performs for its handlers. - Modern-client/Legacy-backend stays deliberately unbridged, per the costed rejection recorded in doc 10; the Tasks extension is the sanctioned stateful path. - Only composite-workflow suspend/resume needs durable state, and it takes RFC-0083 D5 verbatim: an opaque >=128-bit handle as the client-visible requestState, WorkflowStatus.Owner bound to plaintext (iss, sub) via session/binding.Format, owner-checked resume, TTL, audit redaction, Redis store behind a core.Config seam. - Sampling gets no feature work: SEP-2577 deprecates it, and the relay is type-agnostic, so it transits for existing counterparts only. Roots additionally has no existing vMCP counterpart and is refused on the bridge cell. The gap list names what the pinned go-sdk/mcpcompat cannot express (mcpcompat drops inputRequests/requestState in both directions; the SDK's MRTR internals are unexported), phrased for toolhive-core. Refs #5743, #5759, #6018 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Surface Modern input_required rounds as typed values An MCP 2026-07-28 backend that needs more input answers with a resultType:"input_required" envelope (SEP-2322). The Modern egress shim collapsed that into the opaque errModernInputRequired sentinel, discarding the inputRequests and requestState payload — so no upper layer could ever relay or fulfill a round, and the MRTR work designed in docs/arch/16-vmcp-mrtr.md had no seam to build on. Decode the envelope into a domain vmcp.InputRequiredResult carried by a typed error. The values stay opaque json.RawMessage (the pass-through relay must forward them verbatim, never reinterpret them) with a Methods() probe for the capability gate; the error unwraps to errModernInputRequired with a byte-identical message, so revision classification (probeRevision) and every client-visible behavior are unchanged — capabilities are still declared empty, so a compliant backend cannot yet send a round at all. InputRequiredFromError is the single branch point the ingress relay and the Legacy-client bridge (slices 2-4 of the design) will consume. Confined to pkg/vmcp and pkg/vmcp/client, which none of the in-flight Modern-path PRs (#6050, #6033, #6051) touch. Refs #5743, #5759 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Flag the MRTR design's draft-only spec dependencies The 2026-07-28 revision finalizes one day after this design was written against schema/draft. The schema types and SEPs it cites are already Final, but four load-bearing points rest on the draft spec page's text, which refines the Final SEP: the three-method table (the SEP also allowed GetTaskPayloadRequest), the capability-gating MUST NOT, the at-least-one-field requirement, and the requestState security language. Name them explicitly with the design's exposure to each, so the re-verification against the final cut is a checklist rather than a re-read. Refs #5759 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Reconcile the MRTR design with merged #6061 PR #6061 landed the two-path mid-call capability-refusal contract this design's sequencing anticipated: -32021 with data.requiredCapabilities at HTTP 200 (documented deviation, go-sdk#1117) for an undeclared capability, an explicit -32603 naming SEP-2322 for a declared one. Reconcile rather than duplicate: the contract is permanent for the Modern-client/Legacy-backend cell (the only cell its refusal recorder can fire on — the SDK adapters are the Legacy-session forwarding seams), superseded by capability mirroring only where the backend is Modern. Name writeModernCallFailure as slice 2's rendering hook — the input_required branch and the refusal branch cannot co-occur — and note the declared-case message needs rescoping when slice 3 lands. Also record the finalization re-check: on 2026-07-28 the revision is still not cut (no schema/2026-07-28 directory, /specification/2026-07-28 pages 404, schema/draft byte-identical to the copy designed against), so the draft-only-dependency checklist stays pending. Refs #5759, #6061 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Regenerate CRD reference docs task crdref-gen output shifts by two trailing blank lines once this branch's new exported pkg/vmcp types are in the generator's scanned set. Verified the drift is this branch's consequence, not pre-existing staleness: regenerating in a clean origin/main worktree produces no diff. Generated file only; no generator or config changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Track the MRTR design under issue #6059 The human filed #6059 for exactly the pass-through this document designs, superseding the closed-not-planned #5759 the doc referenced. Point the design at the live issue, defer to doc 10's landed rationale for both current limitations instead of restating it, and delineate the adjacent Modern-path issues (#6058 streaming, #6064 ping/resultType, #6065 subscriptions) this design deliberately does not cover. One genuine divergence is flagged rather than papered over: #6059 sketches wrapping the backend's requestState with vMCP routing context, while this design relays it verbatim and re-derives routing from the capability name — because a vMCP-authored wrapper makes vMCP a state-minting server under MRTR server requirements 4-5, a key-management surface the verbatim relay avoids. Recorded as an open decision to resolve at slice 2/3. Part of #6059. Refs #5743. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Harden the MRTR egress seam per #6074 review Review (jhrozek) found the seam promising more than it enforced. Extraction is now fail-closed on three gates the design already stated but the code did not check: the method allow-list (only tools/call, resources/read, prompts/get may carry a round), strict payload decode (a wrong-typed inputRequests or requestState is a schema violation, not an empty round), and server requirement 6 (an envelope with nothing to fulfill and nothing to echo must not become a retry-forever round). RequestState becomes *string because client requirement 2 makes absent-vs-present-and-empty an observable distinction the retry must preserve. The carrier and extractor move to pkg/vmcp beside InputRequiredResult with exported fields, so slice 2's server-side rendering never has to import the concrete HTTP client and mock-based consumers can construct the error. Classification and message text are unchanged: the typed error still unwraps to the client's sentinel and renders byte- identically — except that a resultType beyond 64 bytes is now truncated to a prefix plus length, the #6079/#6066 rule, since the text reaches the downstream client. Also per review: modernResultTypeInputRequired constant beside the existing complete constant, the unrecognized-resultType comment cites the page's MUST rather than the SEP's SHOULD, the sentinel's comment explains why its message is frozen instead of claiming MRTR is deferred, the message-pinning test hardcodes the expected string so test and production cannot drift together, and the verbatim-relay test asserts raw bytes (assert.Equal) as its name promises. Part of #6059. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Correct MRTR design claims found false in review The principal-binding claim in cell 1 was false for two of six egress strategies: header_injection (static env-mounted secret) and unauthenticated (no-op) present one vMCP identity for every downstream user, so the backend's requestState principal binding cannot protect one downstream user from another. Scope the claim to the user-derived strategies and state the consequence — under shared-credential egress vMCP must bind the round itself, because no other component can — which turns wrap-vs-verbatim into a configuration-dependent obligation. Record the third arm the framing missed: a server-side D5 handle over a verbatim backend round, which closes that gap with no keys at the cost of a durable store on the pass-through path. Also from review: the D5 owner check is vacuous under incomingAuth: anonymous (every session stores the unauthenticated sentinel), so slice 5 documents that as a single-tenant caveat and the stronger-than-AEAD claim is scoped to authenticated incoming auth; the drift list gains the fifth SEP-vs-page divergence (unrecognized resultType SHOULD vs MUST) and stops calling requirements 6 and 7 page-only (one is in schema.ts, the other a core-page MUST); SEP-2577's union freeze does not name InputRequest, so that citation is scoped to what the SEP actually says; a cell-1 sequence diagram per the arch-docs convention; and the in-flight list reflects #6050/#6051 having merged. Doc 10's client-edge section still called the -32021 contract 'the planned follow-up' — #6061 landed it, and this PR is the reconciliation pass, so the refresh rides here. Part of #6059. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Regenerate CRD reference docs after main merge Same cause as the earlier regen on this branch, not pre-existing staleness: a clean origin/main (92c70db) worktree regenerates with no diff, while this branch's additions to pkg/vmcp — whose shared types the VirtualMCPServer CRD reference renders — make the generator emit two extra blank lines. The merge of main resolved docs/operator/crd-api.md to main's copy, dropping the lines the earlier regen commit had added, so the check failed again on the merge result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Narrow anonymous-auth handle-theft rationale The D5 caveat claimed a stolen resume handle concedes no privilege the anonymous trust model doesn't already grant. That holds for invoking the tools, but not for the data: resuming loads accumulated step outputs produced from the original caller's arguments, which a thief running the same workflow could not necessarily reproduce. Scope the claim to tool-invocation privilege and name the residual data-read exposure, which rests on handle secrecy and the existing redaction requirement. The decision — accept as a single-tenant caveat rather than refuse to suspend — is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n * Name missing sentinel in InputRequiredError InputRequiredError.Sentinel is documented as required, but the fields are exported so callers can build the error without the real client's envelope decode, and nothing enforces it: a literal omitting Sentinel rendered "%!s(<nil>)" into text that reaches the downstream client. Guard both Error() branches with a named fallback so the omission is diagnosable instead of cryptic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Summary
Why. vMCP's Modern (2026-07-28) dispatcher had two client-facing gaps that together made it unusable for a real SDK client. Both are exposed the moment vMCP serves Modern unconditionally (#6033); neither was introduced by it.
Delivered as one larger PR per the CLAUDE.md large-PR exception — these are the two halves of "Modern client-facing dispatch works".
Refs #5959, #6033, #5743
Part 1 —
subscriptions/listenModern removed the standalone HTTP GET stream, making
subscriptions/listenthe revision's only server→client push channel.dispatchModernhad no case for it, so it answered-32601. That made vMCP unreachable for any go-sdk v1.7 client:Connectis Modern-first, and onceserver/discoversucceeds it opens a listen stream whenever a list-changed handler is registered — whichmcpcompat'sInitializedoes unconditionally. The-32601failedConnectoutright and tore the session down.server/discoverpublishes — extracted asnewModernCapabilitiesso the two cannot drift.notifications/subscriptions/acknowledgedfirst, then a terminating result, both taggedio.modelcontextprotocol/subscriptionIdand keyed by the listen request's own JSON-RPC id (Modern has no sessions).server/discover.No push delivery exists, and this PR does not add any. Every push flag in
newModernCapabilitiesis deliberately false —dispatchModerncreates no session, so the per-sessionlist_changedmachinery never runs for a Modern client, and backendresources/updatedis not forwarded even on Legacy. The honored set is therefore always empty, and the acknowledgement says so explicitly. That is the spec's own mechanism for declaring unsupported types, not a silent no-op: nothing is reported as subscribed and then quietly dropped. Real delivery is #5743.Part 2 —
nextCursorpagination for the list verbsThe four Modern list verbs returned the full aggregated set and never emitted
nextCursor, so a client could not page and any cursor it sent was ignored. On Legacy the SDK's session-scoped feature store does this split; the Modern dispatcher bypasses the SDK.Modern is stateless, so a cursor may not denote server-held iteration state. The draft pagination page (not the 2025-11-25 one — they differ) makes cursors opaque to clients, which is precisely what lets the server encode position into the token instead of remembering it.
Tool.Nameis unique (ResolveToolConflictskeys a map); resource URIs, template strings and prompt names are plain-appended across backends with no de-duplication, so collisions are ordinary.nextCursorentirely. The draft states an empty string is a valid cursor and MUST NOT be treated as the end of results, so emitting""would make a conformant client loop on page one.-32602.Ordering note: keyset paging needs a deterministic total order and the aggregator's fan-out order is not stable, so Modern list results are now sorted by key. Legacy ordering is unchanged.
Type of change
Test plan
task test) — only the three known environmental failures (TestNewServer_ReadTimeoutConfigured,TestSecurityHeaders,TestCompositeProvider_RealProviders), all pre-existing onmainand all passing in CI, which has a container runtime.task lint) — 0 issues.go vet ./...andgo build ./...run separately: clean.fd3b6a9b.Measured effect on #6033
test/integration/vmcppkg/vmcp/serverforwardingsubscriptions/listentest/integration/vmcpis fully green,TestRegression_Over1000Tools_CompleteSetReceivedincluded.The remaining 5 are the forwarding tests and are not addressable here: they exercise server-initiated elicitation/sampling, which 2026-07-28 removes outright — go-sdk's
assertServerInitiatedRequestAllowedrefuses them on protocol version alone, never consulting client capabilities. Progress and logging are not subscribable types under SEP-2575. Those need the MRTR work.Duplicate-key walk counts (H1)
API Compatibility
v1beta1API.No CRD or
api/v1*type is touched. Legacy behaviour is unchanged; every change is confined to the Modern dispatch path, except thenewModernCapabilitiesextraction, a pure refactor of whatserver/discoveralready emitted.Does this introduce a user-facing change?
Yes, reachable once vMCP serves Modern (still behind the kill-switch on
main, removed by #6033):Connectinstead of having its session torn down, and receives an explicit acknowledgement that no notification subscriptions are honored.Special notes for reviewers
Things that are correct and easy to mistake for bugs — please don't re-flag these; each was checked against the draft spec:
closeevent and noid:field. The draft explicitly drops resumability: "Resumable SSE streams viaLast-Event-IDare not supported", and instructs servers to ignore the header. Emittingid:would imply a resumability we don't offer.event: messagename is not mandated. The draft specifies the content type and stream lifecycle but never an SSE event name. It's kept for symmetry with go-sdk's framing.resultType: "complete"on the listen result is required byResultinschema/draft/schema.ts("Servers implementing this protocol version MUST include this field").{}for an empty honored set is the spec's way to say "I honor none of these", not an omission.notifications/cancelledwould fall todefault:→-32601→ 404 → dead session.pingreturns a bare{}.pingdoes not exist in 2026-07-28 (absent from the draft schema; go-sdk lists it among removed methods), so answering it at all is deliberate leniency, not conformance — and the bare{}also omitsresultType. Left as-is because it is a pre-existing path and inert (a go-sdk Modern client never pings); the comment now says-32601is the conformant answer for whoever tightens it.Where to push back:
callSubscriptionsAckHandlerreturns(nil, nil)), so against that client the declaration is not observable in behaviour. It is still correct on the wire, and the alternative — holding a stream open forever delivering nothing — is strictly worse.WARNfails loud where blocking fails silent. The branch is unreachable today, and theWARNexists so a future capability flip without delivery is loud in logs rather than an idle subscription.serverStreamRegistry.dispatcher_streams.gosuggests it as the fan-out seam. With an empty honored set there is no fan-out, so adapting an unexported registry from another package would add an abstraction with zero consumers (vMCP anti-pattern Start simple hardcoded registry #8). It stays the right seam for delivery; the doc now says so.modernPageSizeis a hand-copy of go-sdk'sDefaultPageSize. The Modern side is pinned behaviourally (1001 → 1000 + 1); the matching Legacy assertion is not, because pinning a client to Legacy needs a negotiation-pinning harness this package lacks (mcpcompat cannot request a protocol version, Handle per-backend MCP revision in vMCP (client-side dual protocol) #5911). Stated in the constant's doc comment as an invariant maintained by review, not CI. Follow-ups: re-export the constant from mcpcompat, and add the Legacy twin alongside the MRTR work.Known property introduced by pagination: backend-chosen names now influence page-1 membership. 1000 tools named
aaa_*will fill the first page and push others to page 2, where previously everything arrived at once. Only affects clients that ignorenextCursor; noting it because it did not exist before this change.Follow-up filed upstream: go-sdk's
ClientSession.SetLoggingLevelomitsinjectRequestMetawhile still sending theMCP-Protocol-Version: 2026-07-28header, so a conformant server must reject it-32020and a Modern go-sdk client cannot calllogging/setLevelat all — modelcontextprotocol/go-sdk#1116.Generated with Claude Code