Skip to content

Commit 8ca1fc6

Browse files
committed
Serve subscriptions/listen over the dual-era stream loop
Remove the transport-level rejection of subscriptions/listen on the stream-pair modern path: the method now dispatches through serve_one to the registered ListenHandler like every other modern request. Over stdio the listen request simply stays pending - the ack and every event are request-scoped notifications on the duplex pipe, and the response arrives only when the stream ends gracefully. server/discover's subscription capability advertisement is now truthful on this transport. Two era-scoped generalizations ride the dispatcher's new per-request seams, fixing the class rather than the listen instance: - Era lock at first client-visible success frame. A classified-modern request commits the modern lock immediately before its first non-error frame is written (notification, progress, or response), not when its dispatch returns. Plain requests lock at their response exactly as before; a listen locks at its ack, so an initialize pipelined behind a live stream is rejected with -32022 instead of flipping the connection legacy mid-stream. Classification failures and pre-frame cancellations still never lock, and progress written for a request that later fails locks correctly (the client that stamped a valid envelope already committed to modern). - Quiet cancellation for every classified-modern request. After an inbound notifications/cancelled the server sends nothing further for that request - no result, no error - matching the transport spec's MUST NOT. Legacy-era requests keep the byte-identical code-0 "Request cancelled" answer released 2025 clients unblock on. The single-exchange HTTP and direct in-process paths need no seam: cancellation is structurally silent there (stream close / caller's own scope).
1 parent ff34da9 commit 8ca1fc6

5 files changed

Lines changed: 384 additions & 62 deletions

File tree

docs/handlers/subscriptions.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,10 @@ Two things the stream is *not*:
5252
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
5353
for; the acknowledgment is how the client learns what it actually got.
5454

55-
!!! warning "Streamable HTTP only, for now"
56-
`subscriptions/listen` needs a transport that can stream a request's response, which today
57-
means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
58-
METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
59-
there. Serving it over stdio is planned; the open-stream semantics for that transport are
60-
not built yet.
55+
`subscriptions/listen` is served on every transport that can stream a request's response: over
56+
streamable HTTP the response is the SSE stream, and over stdio the listen request simply stays
57+
pending on the duplex pipe (the acknowledgment and every event are notifications; the request's
58+
result arrives only when the stream ends gracefully).
6159

6260
## The client end
6361

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
191191

192192
### Change notifications become one stream
193193

194-
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet.
194+
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. This works on both transports: over streamable HTTP the stream is the request's SSE response, and over stdio the events ride the duplex pipe while the listen request stays pending.
195195

196196
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
197197

src/mcp/server/runner.py

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@
5959
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
6060
from mcp.shared.exceptions import MCPError, NoBackChannelError
6161
from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request
62-
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
62+
from mcp.shared.jsonrpc_dispatcher import (
63+
JSONRPCDispatcher,
64+
handler_exception_to_error_data,
65+
observe_success_frames,
66+
suppress_cancel_answer,
67+
)
6368
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
6469
from mcp.shared.transport_context import TransportContext
6570

@@ -575,15 +580,29 @@ async def serve_dual_era_loop(
575580
for genuine version negotiation or for `initialize` on an
576581
already-modern connection.
577582
578-
The era lock rides the request's own dispatch. For the inline methods
579-
(`initialize`, `server/discover`) that completes before the next frame is
580-
read, so the canonical probe-then-go flow is race-free; a pinned-modern
581-
client that pipelines frames ahead of its first response should expect
582-
envelope-less notifications sent in that window to be dropped. The lock
583-
settles exactly once: a request from the other era that was already in
584-
flight when the lock committed may still complete and its response
585-
stands, but the era does not move; and a success the peer cancelled away
586-
(it sees "Request cancelled", not the result) does not lock either.
583+
The modern lock commits on the FIRST client-visible success frame the
584+
classified request produces - a request-scoped notification (progress and
585+
the `subscriptions/listen` ack ride those) or its result - immediately
586+
before that frame is written, with no checkpoint in between. One
587+
definition for every method: a plain request locks at its response
588+
exactly as before, and a streaming request (`subscriptions/listen`, whose
589+
response arrives only at close) locks at its ack, so a pipelined
590+
`initialize` behind the ack can never hijack a live stream back to
591+
legacy. For the inline methods (`initialize`, `server/discover`) the
592+
dispatch completes before the next frame is read, so the canonical
593+
probe-then-go flow is race-free; a pinned-modern client that pipelines
594+
frames ahead of its first response should expect envelope-less
595+
notifications sent in that window to be dropped. The lock settles exactly
596+
once: a request from the other era that was already in flight when the
597+
lock committed may still complete and its response stands, but the era
598+
does not move; and a success the peer cancelled away before any frame
599+
went out does not lock either.
600+
601+
Classified-modern requests are also governed by the 2026 cancellation
602+
rule for their whole lifetime: after an inbound `notifications/cancelled`
603+
the server sends nothing further for that request - no result, no error
604+
(the transport spec's MUST NOT). Legacy-era requests keep the
605+
byte-identical "Request cancelled" answer released 2025 clients expect.
587606
"""
588607
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
589608
read_stream,
@@ -601,36 +620,45 @@ async def serve_dual_era_loop(
601620
modern_version = LATEST_MODERN_VERSION
602621

603622
def era_settles(dctx: DispatchContext[TransportContext]) -> bool:
604-
# The one definition of "this request may lock the era": it settled as
623+
# The one definition of "this request may lock the era": it produced
605624
# a client-visible success on a still-unlocked connection. The lock is
606625
# monotone - the first success wins, so a straggling request from the
607626
# other era can never overwrite a committed lock. A pending peer
608-
# cancel means the dispatcher is about to replace this response with
609-
# "Request cancelled": the client never sees the success, no lock.
627+
# cancel means the client already abandoned this request and will
628+
# never read the frame as a success: no lock.
610629
return era == "unlocked" and not dctx.cancel_requested.is_set()
611630

631+
def commit_modern(dctx: DispatchContext[TransportContext], version: str) -> None:
632+
# The success-frame observer for classified-modern requests: the
633+
# dispatcher invokes it immediately before each non-error frame for
634+
# the request (ack/event notifications, progress, or the result),
635+
# with no checkpoint before the write - so by the time the client can
636+
# read any modern output, the era is already locked and a pipelined
637+
# `initialize` is rejected instead of hijacking the connection.
638+
nonlocal era, modern_version
639+
if era_settles(dctx):
640+
era, modern_version = "modern", version
641+
612642
async def serve_modern(
613643
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
614644
) -> dict[str, Any]:
615-
nonlocal era, modern_version
616645
route = classify_inbound_request({"method": method, "params": params})
617646
if isinstance(route, InboundLadderRejection):
618647
raise MCPError(code=route.code, message=route.message, data=route.data)
619-
if method == "subscriptions/listen":
620-
# The registered listen handler assumes the HTTP entry's stream
621-
# semantics; served over a stream pair it would wedge. Reject until
622-
# this transport grows its own listen design.
623-
raise MCPError(
624-
code=METHOD_NOT_FOUND, message="subscriptions/listen is not served over this transport", data=method
625-
)
648+
# Classification succeeded: 2026 wire semantics govern this request's
649+
# lifecycle from here. Both facts attach synchronously - no checkpoint
650+
# since the dispatcher entered the handler scope - so a peer cancel
651+
# can never interleave and observe the legacy defaults.
652+
suppress_cancel_answer(dctx)
653+
observe_success_frames(dctx, partial(commit_modern, dctx, route.protocol_version))
626654
connection = Connection.from_envelope(
627655
route.protocol_version,
628656
route.client_info,
629657
route.client_capabilities,
630658
outbound=standalone_outbound,
631659
)
632660
try:
633-
result = await serve_one(
661+
return await serve_one(
634662
server,
635663
_NoServerRequestsDispatchContext(dctx),
636664
method,
@@ -647,9 +675,6 @@ async def serve_modern(
647675
raise
648676
error = modern_error_data(exc)
649677
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
650-
if era_settles(dctx):
651-
era, modern_version = "modern", route.protocol_version
652-
return result
653678

654679
async def on_request(
655680
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None

src/mcp/server/subscriptions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ class ListenHandler:
164164
cancels the handler; the stream just ends, per the spec's abrupt-close
165165
contract) or `close` ends all streams gracefully.
166166
167-
Requires a transport that can stream a request's response (streamable
168-
HTTP's SSE mode).
167+
Requires a transport that can stream a request's response: streamable
168+
HTTP's SSE mode, or a duplex stream pair (stdio) where the listen request
169+
simply stays pending while notifications ride the pipe.
169170
170171
`max_subscriptions` bounds concurrent streams (further listen requests are
171172
rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events`

0 commit comments

Comments
 (0)