You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make Modern pagination collision-safe and pin the wire
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
Copy file name to clipboardExpand all lines: docs/arch/10-virtual-mcp-architecture.md
+64-3Lines changed: 64 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -136,6 +136,19 @@ When backends expose tools with the same name, vMCP resolves the conflict using
136
136
|**priority**| First backend in priority order wins, others hidden |
137
137
|**manual**| Explicit mapping for each conflict |
138
138
139
+
**Conflict resolution covers tools only.** Resources, resource templates, and
140
+
prompts are concatenated across backends with no de-duplication
141
+
(`default_aggregator.go`, "no conflict resolution for these yet"), so
142
+
`Resource.URI`, `ResourceTemplate.URITemplate` and `Prompt.Name` can each repeat
143
+
in the aggregated view — two backends both exposing `file:///README.md` is
144
+
enough. Only `Tool.Name` is unique, because tool resolution keys a map.
145
+
146
+
Anything that treats one of those fields as an identity must account for that.
147
+
The Modern list paginator does: its cursor names a position within a run of equal
148
+
keys rather than assuming keys are distinct, because a plain "resume after this
149
+
key" scan silently dropped an item whenever a duplicate landed on a page boundary
150
+
(see [Client-facing list pagination](#client-facing-list-pagination)).
151
+
139
152
### Tool Filtering
140
153
141
154
Beyond conflict resolution, vMCP can filter which tools are exposed through allow/deny lists, renaming, and description overrides.
@@ -300,14 +313,53 @@ Beyond tools, vMCP aggregates and serves the full complement of MCP capabilities
300
313
| Resource templates (`resources/templates/list`) | Yes | Templated reads route through the same `ReadResource` path; the router matches an expanded URI against the aggregated templates, and an exact template-string key routes to its backend; covered by the same `notifications/resources/list_changed` propagation as resources (MCP 2025-11-25 has no separate wire method for template changes) |
301
314
| Prompts (`prompts/list`, `prompts/get`) | Yes | Served per-session; a backend's asynchronous `notifications/prompts/list_changed` propagates ADDITIONS (not removals) to already-registered sessions — see below |
302
315
| Completions (`completion/complete`) | Yes | A `ref/prompt` routes via the prompts table; a `ref/resource` carries the URI-template string per the spec and routes via the resource-templates table (exact template-string key, with exact-resource and template-expansion fallbacks). Unroutable refs return an empty result (lenient completion); admission denial returns an error |
303
-
| Resource subscriptions (`resources/subscribe`, `resources/unsubscribe`) | Ack-level only | vMCP accepts and records the subscription (after session-binding and resource-admission checks) but does **not** yet forward backend `notifications/resources/updated` — see the limitation below |
316
+
| Resource subscriptions (`resources/subscribe`, `resources/unsubscribe`) | Ack-level only | Legacy edge. vMCP accepts and records the subscription (after session-binding and resource-admission checks) but does **not** yet forward backend `notifications/resources/updated` — see the limitation below |
317
+
| Subscriptions (`subscriptions/listen`) | Ack-level only | Modern (2026-07-28) edge, the revision's only server→client push channel. Answered with an SSE stream carrying the mandatory `notifications/subscriptions/acknowledged` and then a terminating result, both tagged `io.modelcontextprotocol/subscriptionId` and keyed by the listen request's JSON-RPC id (Modern has no sessions). The honored set is intersected against the advertised capabilities, all of whose push flags are false, so it is always **empty** and the stream closes immediately rather than idling. Serving it is what lets a go-sdk v1.7 client complete `Connect` at all — see below |
318
+
319
+
The four Modern list verbs (`tools/list`, `resources/list`, `resources/templates/list`, `prompts/list`) **paginate**, emitting `nextCursor` while items remain and capping a page at 1000 items to match the page size the SDK applies on the Legacy path. See [Client-facing list pagination](#client-facing-list-pagination).
304
320
305
321
The completion handler is a single global handler installed via `WithCompletionHandler`, so it recovers the session from the SDK request context rather than a per-session closure. Setting it makes the shim auto-advertise the `completions` capability at initialize.
306
322
307
323
### Subscription limitation (ack-level)
308
324
309
325
vMCP advertises `resources.subscribe: true` and answers `resources/subscribe` / `resources/unsubscribe` at **ack level**: the request is accepted (enforcing session binding and validating the URI is an advertised, admitted resource), and go-sdk records the subscription. vMCP does **not** currently propagate backend `notifications/resources/updated` to the subscribed client — doing so requires persistent per-session backend connections, which is out of scope. Clients that subscribe will receive a success ack but no update stream yet.
310
326
327
+
### Client-facing list pagination
328
+
329
+
On the Legacy edge the SDK's session-scoped feature store splits list results into
330
+
pages. `dispatchModern` bypasses the SDK, so the Modern edge paginates itself
331
+
(`pkg/vmcp/server/modern_pagination.go`).
332
+
333
+
Modern has no sessions, so a cursor may not denote server-held iteration state.
334
+
The draft pagination page makes cursors **opaque to clients** ("MUST treat cursors
335
+
as opaque tokens"), which is precisely what allows the server to encode position
336
+
*into* the token instead of remembering it. So the cursor is self-describing:
337
+
base64url over a small JSON payload naming the list kind, the last key delivered,
338
+
and how many items sharing that key have already been sent.
339
+
340
+
Three properties worth knowing:
341
+
342
+
-**Ordering.** Keyset paging needs a deterministic total order, and the
343
+
aggregator's fan-out order is not stable between calls, so Modern list results
344
+
are sorted by the item's key (`Tool.Name`, `Resource.URI`,
345
+
`ResourceTemplate.URITemplate`, `Prompt.Name`). Legacy ordering is unchanged.
346
+
-**Duplicate keys are tolerated, not assumed away.** Only tool names are unique
347
+
(see [Conflict Resolution](#conflict-resolution)); resource URIs, template
348
+
strings and prompt names can repeat. The cursor therefore resumes *within* a run
349
+
of equal keys, because a plain "resume after this key" scan skipped every copy
350
+
and permanently dropped items whose key collided at a page boundary.
351
+
-**End of results omits `nextCursor` entirely.** The draft states that "an empty
352
+
string is a valid cursor and thus MUST NOT be treated as the end of results", so
353
+
emitting `""` would make a conformant client re-request and loop on page one.
354
+
355
+
The cursor encodes a position in the **aggregated** ordering and never names a
356
+
backend, so adding or removing a backend cannot invalidate one. This is unrelated
357
+
to the aggregator's *upstream* cursor-following (#5851), which is vMCP acting as a
358
+
client walking a backend's pages.
359
+
360
+
An invalid cursor — malformed, over-length, or minted for a different list verb —
361
+
is rejected with `-32602`, per the draft's error-handling rule.
0 commit comments