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
Harden the dual-era stream loop's era-lock and rejection semantics
Post-merge review follow-up to #3038, fixing the cases where a failed
request could still lock the connection's era:
- The modern era now locks only when a request SUCCEEDS, mirroring the
legacy side's lock-on-success rule. Previously the lock committed at
classification time, so a first request with a well-formed envelope
but malformed content - or subscriptions/listen, or an unknown
method - locked the connection modern after failing; the client's
initialize fallback then got -32022, the one code auto-negotiating
clients do not fall back from, stranding the connection for its
lifetime (a whole subprocess on stdio).
- initialize is legacy-distinctive by definition (the method does not
exist at modern versions), so it always takes the handshake path now,
even when a confused client stamps the envelope triple on it.
Previously it routed modern, failed METHOD_NOT_FOUND, and locked the
era modern - bricking the handshake the same way.
- A bare server/discover on a legacy-locked connection falls through to
the loop runner's per-version surface validation again, restoring the
byte-identical METHOD_NOT_FOUND a handshake-only server produced;
only envelope-bearing frames get the INVALID_REQUEST rejection (a
conforming legacy client can never send the reserved triple).
- classify_inbound_request rejects a present-but-non-string protocol
version as a descriptive INVALID_PARAMS instead of raising
ValidationError out of its own -32022 payload construction
(`requested` is a str field); the dispatcher's exception ladder
happened to mask that as a generic -32602.
- _NoServerRequestsDispatchContext masks transport.can_send_request so
the per-message transport metadata agrees with the wrapper's denial,
matching the modern HTTP entry's context.
- NotifyOnlyOutbound inherits _NoChannelOutbound's refusal instead of
duplicating it, and the has_standalone_channel / Raises contracts now
document that a modern connection refuses requests on a real channel.
- Docs: the migration guide and protocol-versions page note the
discover probe is transport-independent; the subscriptions page
documents the streamable-HTTP-only limitation of subscriptions/listen;
serve_loop's stale shared-recipe claim is corrected.
Also replaces two pyright suppressions in the stdio test doubles with
typeshed-compatible Buffer signatures.
Copy file name to clipboardExpand all lines: docs/advanced/subscriptions.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -46,6 +46,12 @@ Two more things the stream is *not*:
46
46
***It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about.
47
47
***It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only.
48
48
49
+
!!! warning "Streamable HTTP only, for now"
50
+
`subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other
51
+
stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the
52
+
open-stream semantics haven't been built for that transport yet, even though
53
+
`server/discover` still advertises the subscription capabilities there.
54
+
49
55
## One process is the default. More takes a bus
50
56
51
57
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
Copy file name to clipboardExpand all lines: docs/migration.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -411,6 +411,8 @@ On the high-level `Client`, `client.server_capabilities`, `client.server_info`,
411
411
412
412
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
413
413
414
+
The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport.
415
+
414
416
For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.
415
417
416
418
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
0 commit comments