Skip to content

Complete Modern client-facing dispatch: listen + pagination - #6050

Merged
JAORMX merged 4 commits into
mainfrom
feat-modern-subscriptions-listen
Jul 28, 2026
Merged

Complete Modern client-facing dispatch: listen + pagination#6050
JAORMX merged 4 commits into
mainfrom
feat-modern-subscriptions-listen

Conversation

@JAORMX

@JAORMX JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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/listen

Modern removed the standalone HTTP GET stream, making subscriptions/listen the revision's only server→client push channel. dispatchModern had no case for it, so it answered -32601. That 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 -32601 failed Connect outright and tore the session down.

  • The honored set is computed by intersecting the client's requested types against the capability advertisement server/discover publishes — extracted as newModernCapabilities so the two cannot drift.
  • Two-frame SSE response: the mandatory notifications/subscriptions/acknowledged first, then a terminating result, both tagged io.modelcontextprotocol/subscriptionId and keyed by the listen request's own JSON-RPC id (Modern has no sessions).
  • Ungated, same bucket as the list verbs and server/discover.

No push delivery exists, and this PR does not add any. Every push flag in newModernCapabilities is deliberately false — dispatchModern creates no session, so the per-session list_changed machinery never runs for a Modern client, and backend resources/updated is 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 — nextCursor pagination for the list verbs

The 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.

  • Keyset pagination, the same scheme go-sdk's own server uses.
  • Collision-safe. The cursor carries how many items sharing the last key were already delivered, so a page resumes inside a run of equal keys. Only Tool.Name is unique (ResolveToolConflicts keys a map); resource URIs, template strings and prompt names are plain-appended across backends with no de-duplication, so collisions are ordinary.
  • Aggregation needs no per-backend positions — the cursor encodes a position in the aggregated ordering and never names a backend.
  • End-of-results omits nextCursor entirely. 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.
  • Invalid, over-length, and wrong-verb cursors are rejected -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

  • New feature

Test plan

  • Unit tests (task test) — only the three known environmental failures (TestNewServer_ReadTimeoutConfigured, TestSecurityHeaders, TestCompositeProvider_RealProviders), all pre-existing on main and all passing in CI, which has a container runtime.
  • Linting (task lint) — 0 issues. go vet ./... and go build ./... run separately: clean.
  • Manual testing — measured against Replace vMCP Modern kill-switch with a capability gate #6033 by cherry-picking all five commits onto fd3b6a9b.
  • E2E tests — not run locally; require a container runtime unavailable in this environment.

Measured effect on #6033

#6033 state test/integration/vmcp pkg/vmcp/server forwarding total
as-is 15 fail 5 fail 20
+ subscriptions/listen 1 fail 5 fail 6
+ pagination and fixes 0 fail 5 fail 5

test/integration/vmcp is fully green, TestRegression_Over1000Tools_CompleteSetReceived included.

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 assertServerInitiatedRequestAllowed refuses 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)

corpus before after
1100, run straddling the page boundary 1097 1100
1001, same shape 1000 1001
2000, same shape 1997 2000

API Compatibility

  • This PR does not break the v1beta1 API.

No CRD or api/v1* type is touched. Legacy behaviour is unchanged; every change is confined to the Modern dispatch path, except the newModernCapabilities extraction, a pure refactor of what server/discover already emitted.

Does this introduce a user-facing change?

Yes, reachable once vMCP serves Modern (still behind the kill-switch on main, removed by #6033):

  • A Modern client can complete Connect instead of having its session torn down, and receives an explicit acknowledgement that no notification subscriptions are honored.
  • Modern list verbs paginate: a client with more than 1000 tools/resources/templates/prompts receives the complete set across cursors rather than a silently truncated first page.

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:

  • No SSE prime/close event and no id: field. The draft explicitly drops resumability: "Resumable SSE streams via Last-Event-ID are not supported", and instructs servers to ignore the header. Emitting id: would imply a resumability we don't offer.
  • The event: message name 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.
  • Forcing SSE for this one method is required, not stylistic — two JSON-RPC messages must travel on one response, which a single JSON body cannot express.
  • resultType: "complete" on the listen result is required by Result in schema/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.
  • The 202-for-notifications guard is load-bearing. Without it notifications/cancelled would fall to default:-32601 → 404 → dead session.
  • ping returns a bare {}. ping does 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 omits resultType. Left as-is because it is a pre-existing path and inert (a go-sdk Modern client never pings); the comment now says -32601 is the conformant answer for whoever tightens it.

Where to push back:

  1. Is Part 1's empty honored set acceptable, or a stub? The case that it isn't: it makes a truthful negative declaration matching what the same dispatcher advertises, it is the spec's designated mechanism, and go-sdk's reference server does the same. The honest counter-argument: go-sdk's client ignores the acknowledgement (callSubscriptionsAckHandler returns (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.
  2. Part 1 deliberately diverges from go-sdk on one point. go-sdk blocks on the request context once it has honored a subscription; this does not, because holding a socket open with no delivery mechanism is the same silent no-op with extra steps. Close-plus-WARN fails loud where blocking fails silent. The branch is unreachable today, and the WARN exists so a future capability flip without delivery is loud in logs rather than an idle subscription.
  3. No reuse of serverStreamRegistry. dispatcher_streams.go suggests 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.
  4. modernPageSize is a hand-copy of go-sdk's DefaultPageSize. 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 ignore nextCursor; noting it because it did not exist before this change.

Follow-up filed upstream: go-sdk's ClientSession.SetLoggingLevel omits injectRequestMeta while still sending the MCP-Protocol-Version: 2026-07-28 header, so a conformant server must reject it -32020 and a Modern go-sdk client cannot call logging/setLevel at all — modelcontextprotocol/go-sdk#1116.

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.07317% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.25%. Comparing base (57e681f) to head (1588e6a).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/server/modern_subscriptions.go 62.19% 23 Missing and 8 partials ⚠️
pkg/vmcp/server/modern_dispatch.go 76.74% 7 Missing and 3 partials ⚠️
pkg/vmcp/server/modern_pagination.go 90.76% 3 Missing and 3 partials ⚠️
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.
📢 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 changed the title Implement Modern subscriptions/listen in vMCP Complete Modern client-facing dispatch: listen + pagination Jul 27, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed size/XL Extra large PR: 1000+ lines changed labels Jul 27, 2026
@JAORMX
JAORMX force-pushed the feat-modern-subscriptions-listen branch from 8a8108d to 7046faa Compare July 27, 2026 19:39
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 27, 2026
reyortiz3
reyortiz3 previously approved these changes Jul 27, 2026

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/listen yet -- worth a follow-up correcting that to describe the ack-only, zero-capability handler this PR adds (the list_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 Modern subscriptions/listen handler as future work and floats serverStreamRegistry as 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.

Comment thread pkg/vmcp/server/modern_subscriptions.go Outdated
Comment thread pkg/vmcp/server/modern_subscriptions.go
Comment thread pkg/vmcp/server/modern_pagination.go Outdated
Comment thread pkg/vmcp/server/modern_pagination_test.go Outdated
Comment thread pkg/vmcp/server/modern_pagination.go Outdated
Comment thread pkg/vmcp/server/modern_pagination.go Outdated
@JAORMX
JAORMX requested a review from rdimitrov as a code owner July 27, 2026 20:42
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 27, 2026
ChrisJBurns
ChrisJBurns previously approved these changes Jul 27, 2026
ChrisJBurns
ChrisJBurns previously approved these changes Jul 27, 2026
@JAORMX

JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@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 (e9463fea); I replied in place on the first one because part of it could not be done as asked.

Your note Resolution
modern_subscriptions.go:169 — pre-write errors could return -32603 rather than an empty 200 Applied via your suggested shape: buildModernListenFrames split out, both marshal failures route through writeModernListError, log-and-return reserved for the genuine mid-stream failure. Partial — see the thread reply; the flushability check is gone rather than routed, because http.ResponseController has no predicate and the old type assertion never actually detected unflushability.
modern_subscriptions.go:265 — missing X-Accel-Buffering: no Applied.
modern_pagination.go:166sort.Search instead of the linear resume scan Applied (slices.BinarySearchFunc); a full walk is now O(n log n) rather than O(n²).
modern_pagination_test.go:290 — pin the wrong-verb guarantee at dispatch level, not just against the codec Applied, and widened: TestDispatchModernList_CursorNotReplayableAcrossVerbs mints a real page-1 cursor per verb through dispatchModern and replays it against all three others — 12 combinations, of which your tools/listresources/list case is one. The fixture's resource Names now also collide (URIs distinct), so keying on Name instead of URI is detectable.
modern_pagination.go:209cmpString duplicates the stdlib Applied; removed in favour of strings.Compare (no new import).
modern_pagination.go:200 — malformed params envelope silently falls back to page 1 Applied; now -32602. Your read was right that it wasn't reliably rejected upstream: {"cursor": 42} is well-formed JSON with a field type error, so nothing upstream inspected it. A related detail that settled it — with a duplicate key, {"cursor":42,"cursor":"<valid>"}, Go's unmarshal populates the field with the valid value and returns an error, so the discard was throwing away a cursor it had already decoded.
Review body — 10-virtual-mcp-architecture.md still says subscriptions/listen is not implemented Fixed here rather than deferred, along with a served-capabilities row and a new client-facing pagination section.
Review body — 03-transport-architecture.md still frames the handler as future work Fixed; serverStreamRegistry is reframed as the seam for delivery rather than for the handler, since that's the part still outstanding (#6065).

Two things your questions led to that are worth recording, because both closed gaps we could not otherwise verify:

Your X-Accel-Buffering note turned out to sit exactly where our own spec verification had failed. An earlier pass reported docs/specification/draft/basic/transports.mdx unreachable and downgraded its SSE conclusions to "resting on corroborating sources". The page hadn't gone — the draft split it into a directory, at transports/streamable-http.mdx. Your header is a SHOULD there, verbatim. Finding it also settled two open questions: the SSE event: name is not mandated (so ours is kept for go-sdk symmetry and documented as optional), and omitting id: is correct with a citation — "Resumable SSE streams via Last-Event-ID are not supported".

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 onlydefault_aggregator.go plain-appends resources, templates and prompts under // no conflict resolution for these yet. A duplicate at a page boundary permanently dropped every item sharing that key (1100 in, 1097 out). The cursor now carries (LastKey, Delivered) and resumes inside a run of equal keys; 1100→1100, 1001→1001, 2000→2000. Root cause filed as #6060.

Happy to take anything else. Note the merge is currently blocked on these conversations being resolved.

@ChrisJBurns

Copy link
Copy Markdown
Collaborator

/retest

@ChrisJBurns

Copy link
Copy Markdown
Collaborator

@JAORMX I have a feeling the e2e tests failure is real as it's failed on 2 retries

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 28, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 28, 2026
JAORMX added 4 commits July 28, 2026 06:32
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.
@JAORMX
JAORMX force-pushed the feat-modern-subscriptions-listen branch from 5f4619f to 1588e6a Compare July 28, 2026 06:32
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 28, 2026
@JAORMX
JAORMX merged commit ca4eda4 into main Jul 28, 2026
21 checks passed
@JAORMX
JAORMX deleted the feat-modern-subscriptions-listen branch July 28, 2026 06:38
JAORMX added a commit that referenced this pull request Jul 28, 2026
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
JAORMX added a commit that referenced this pull request Jul 28, 2026
* 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>
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/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants