Skip to content

Commit 927b48e

Browse files
committed
Tighten the dispatcher seam edges
Four small hardenings around the per-request seams: - on_success_frame is invoked through fire_success_frame, which contains a raising observer (log and continue, the same boundary as the subscription bus's listeners) so an observer bug cannot convert the success it is observing into an error response. The only observer today cannot raise; this makes the contract structural. - suppress_cancel_answer / observe_success_frames now reach through NoServerRequestsDispatchContext instead of silently no-oping on it: the wrapper delegates wire I/O to the loop context it wraps, so per-request facts attached through it must land there. The wrapper moves from server/runner.py to shared/dispatcher.py - it was never server-specific, and the shared setters can only name it without a layering violation from there. The no-op remains for genuinely structurally-silent contexts (single-exchange HTTP, direct dispatch). - The legacy code-0 cancel answer template is never written to the wire: the one site that writes a cancel answer sends a fresh model_copy. ErrorData is mutable and frames cross in-memory transports by reference, so sharing the instance would let a consumer's mutation corrupt every later cancel answer process-wide. - ListenHandler's class docstring states the actual transport requirement (any dispatch context that forwards request-scoped notifications: HTTP SSE and the stream-pair dual-era loop) instead of naming SSE alone.
1 parent 4932a44 commit 927b48e

6 files changed

Lines changed: 284 additions & 91 deletions

File tree

src/mcp/server/runner.py

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import logging
1717
from collections.abc import Awaitable, Mapping
18-
from dataclasses import KW_ONLY, dataclass, replace
18+
from dataclasses import KW_ONLY, dataclass
1919
from functools import cached_property, partial
2020
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
2121

@@ -35,7 +35,6 @@
3535
Implementation,
3636
InitializeRequestParams,
3737
InitializeResult,
38-
RequestId,
3938
RequestParams,
4039
RequestParamsMeta,
4140
UnsupportedProtocolVersionErrorData,
@@ -56,16 +55,22 @@
5655
from mcp.server.models import InitializationOptions
5756
from mcp.server.session import ServerSession
5857
from mcp.shared._stream_protocols import ReadStream, WriteStream
59-
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
60-
from mcp.shared.exceptions import MCPError, NoBackChannelError
58+
from mcp.shared.dispatcher import (
59+
DispatchContext,
60+
Dispatcher,
61+
NoServerRequestsDispatchContext,
62+
OnNotify,
63+
OnRequest,
64+
)
65+
from mcp.shared.exceptions import MCPError
6166
from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request
6267
from mcp.shared.jsonrpc_dispatcher import (
6368
JSONRPCDispatcher,
6469
handler_exception_to_error_data,
6570
observe_success_frames,
6671
suppress_cancel_answer,
6772
)
68-
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
73+
from mcp.shared.message import ServerMessageMetadata, SessionMessage
6974
from mcp.shared.transport_context import TransportContext
7075

7176
if TYPE_CHECKING:
@@ -490,57 +495,6 @@ def modern_error_data(exc: Exception) -> ErrorData:
490495
return ErrorData(code=INTERNAL_ERROR, message="Internal server error")
491496

492497

493-
@dataclass
494-
class _NoServerRequestsDispatchContext:
495-
"""Delegating `DispatchContext` that refuses server-initiated requests.
496-
497-
Wraps the loop dispatcher's per-message context for modern-era dispatch:
498-
the modern protocol forbids server-initiated JSON-RPC requests, so
499-
`send_raw_request` refuses while notifications and progress still ride
500-
the duplex pipe.
501-
"""
502-
503-
_inner: DispatchContext[TransportContext]
504-
505-
@property
506-
def transport(self) -> TransportContext:
507-
# Mask the per-message flag so the transport metadata agrees with this
508-
# wrapper's denial: the modern HTTP entry builds its context with
509-
# can_send_request=False, while the loop's default builder says True.
510-
transport = self._inner.transport
511-
return replace(transport, can_send_request=False) if transport.can_send_request else transport
512-
513-
@property
514-
def can_send_request(self) -> bool:
515-
return False
516-
517-
@property
518-
def request_id(self) -> RequestId | None:
519-
return self._inner.request_id
520-
521-
@property
522-
def message_metadata(self) -> MessageMetadata:
523-
return self._inner.message_metadata
524-
525-
@property
526-
def cancel_requested(self) -> anyio.Event:
527-
return self._inner.cancel_requested
528-
529-
async def send_raw_request(
530-
self,
531-
method: str,
532-
params: Mapping[str, Any] | None,
533-
opts: CallOptions | None = None,
534-
) -> dict[str, Any]:
535-
raise NoBackChannelError(method)
536-
537-
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
538-
await self._inner.notify(method, params, opts)
539-
540-
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
541-
await self._inner.progress(progress, total, message)
542-
543-
544498
async def serve_dual_era_loop(
545499
server: Server[LifespanT],
546500
read_stream: ReadStream[SessionMessage | Exception],
@@ -704,7 +658,7 @@ async def serve_modern(
704658
try:
705659
return await serve_one(
706660
server,
707-
_NoServerRequestsDispatchContext(dctx),
661+
NoServerRequestsDispatchContext(dctx),
708662
method,
709663
params,
710664
connection=connection,
@@ -772,7 +726,7 @@ async def on_notify(dctx: DispatchContext[TransportContext], method: str, params
772726
connection = Connection.from_envelope(modern_version, None, None, outbound=standalone_outbound)
773727
notify_runner = ServerRunner(server, connection, lifespan_state)
774728
try:
775-
await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params)
729+
await notify_runner.on_notify(NoServerRequestsDispatchContext(dctx), method, params)
776730
finally:
777731
await aclose_shielded(connection)
778732

@@ -832,7 +786,7 @@ async def handle(
832786
)
833787
return await serve_one(
834788
server,
835-
_NoServerRequestsDispatchContext(dctx),
789+
NoServerRequestsDispatchContext(dctx),
836790
method,
837791
params,
838792
connection=connection,

src/mcp/server/subscriptions.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,10 @@ 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, or a duplex stream pair (stdio) where the listen request
169-
simply stays pending while notifications ride the pipe.
167+
Serves any transport whose dispatch context forwards request-scoped
168+
notifications: streamable HTTP's SSE mode (the response IS the stream)
169+
and the stream-pair dual-era loop (stdio), where the listen request
170+
simply stays pending while the ack and events ride the duplex pipe.
170171
171172
`max_subscriptions` bounds concurrent streams (further listen requests are
172173
rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events`

src/mcp/shared/dispatcher.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818

1919
import logging
2020
from collections.abc import Awaitable, Callable, Mapping
21+
from dataclasses import dataclass, replace
2122
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
2223

2324
import anyio
2425
import anyio.abc
2526
from mcp_types import RequestId
2627

28+
from mcp.shared.exceptions import NoBackChannelError
2729
from mcp.shared.message import MessageMetadata
2830
from mcp.shared.transport_context import TransportContext
2931

@@ -33,6 +35,7 @@
3335
"CallOptions",
3436
"DispatchContext",
3537
"Dispatcher",
38+
"NoServerRequestsDispatchContext",
3639
"OnNotify",
3740
"OnNotifyIntercept",
3841
"OnRequest",
@@ -218,6 +221,60 @@ async def progress(self, progress: float, total: float | None = None, message: s
218221
...
219222

220223

224+
@dataclass
225+
class NoServerRequestsDispatchContext:
226+
"""Delegating `DispatchContext` that refuses server-initiated requests.
227+
228+
Wraps another dispatch context for entries whose protocol forbids
229+
server-initiated JSON-RPC requests (the modern 2026-07-28 era):
230+
`send_raw_request` refuses while notifications and progress still ride
231+
the wrapped context's channel. Because it delegates wire I/O, the
232+
per-request seam setters in `mcp.shared.jsonrpc_dispatcher`
233+
(`suppress_cancel_answer`, `observe_success_frames`) reach through it to
234+
the wrapped context.
235+
"""
236+
237+
inner: DispatchContext[TransportContext]
238+
239+
@property
240+
def transport(self) -> TransportContext:
241+
# Mask the per-message flag so the transport metadata agrees with this
242+
# wrapper's denial: the modern HTTP entry builds its context with
243+
# can_send_request=False, while the loop's default builder says True.
244+
transport = self.inner.transport
245+
return replace(transport, can_send_request=False) if transport.can_send_request else transport
246+
247+
@property
248+
def can_send_request(self) -> bool:
249+
return False
250+
251+
@property
252+
def request_id(self) -> RequestId | None:
253+
return self.inner.request_id
254+
255+
@property
256+
def message_metadata(self) -> MessageMetadata:
257+
return self.inner.message_metadata
258+
259+
@property
260+
def cancel_requested(self) -> anyio.Event:
261+
return self.inner.cancel_requested
262+
263+
async def send_raw_request(
264+
self,
265+
method: str,
266+
params: Mapping[str, Any] | None,
267+
opts: CallOptions | None = None,
268+
) -> dict[str, Any]:
269+
raise NoBackChannelError(method)
270+
271+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
272+
await self.inner.notify(method, params, opts)
273+
274+
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
275+
await self.inner.progress(progress, total, message)
276+
277+
221278
OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[dict[str, Any]]]
222279
"""Handler for inbound requests: `(ctx, method, params) -> result`. Raise `MCPError` to send an error response."""
223280

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CallOptions,
4444
DispatchContext,
4545
Dispatcher,
46+
NoServerRequestsDispatchContext,
4647
OnNotify,
4748
OnNotifyIntercept,
4849
OnRequest,
@@ -134,11 +135,15 @@ class _InFlight(Generic[TransportT]):
134135

135136

136137
_LEGACY_CANCEL_ANSWER: Final[ErrorData] = ErrorData(code=0, message="Request cancelled")
137-
"""The answer a peer-cancelled request gets by default.
138+
"""Template for the answer a peer-cancelled request gets by default.
138139
139140
TODO(L38): the spec says receivers SHOULD NOT respond after a cancel; the
140-
existing server always has, so this pins released-client compat. Never
141-
mutated - one shared instance is safe.
141+
existing server always has, so this pins released-client compat. A template,
142+
never written to the wire itself: the one site that writes a cancel answer
143+
sends a fresh `model_copy` (`ErrorData` is not frozen, and over in-memory
144+
transports the answer object crosses to the peer by reference, so sharing
145+
one instance would let a consumer's mutation corrupt every later cancel
146+
answer process-wide).
142147
"""
143148

144149

@@ -158,8 +163,9 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
158163
"""The error written when a peer cancel interrupts this request's handler.
159164
160165
`None` means silence: no frame at all follows the cancel. The default is
161-
the legacy compat answer; loop layers that know a request is governed by
162-
2026-era wire rules (which forbid any frame for a request after
166+
the shared legacy compat template (the write site sends a copy, never the
167+
template itself); loop layers that know a request is governed by 2026-era
168+
wire rules (which forbid any frame for a request after
163169
`notifications/cancelled`) clear it via `suppress_cancel_answer`.
164170
"""
165171
on_success_frame: Callable[[], None] | None = None
@@ -168,7 +174,8 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
168174
those) or the success result. Never invoked for error responses, nor for
169175
notifications dropped because the context closed. Set via
170176
`observe_success_frames`; loop layers use it to commit connection state no
171-
later than the request's first client-visible output.
177+
later than the request's first client-visible output. Always invoked
178+
through `fire_success_frame`, which contains a raising observer.
172179
"""
173180

174181
@property
@@ -193,12 +200,25 @@ def must_stay_silent(self) -> bool:
193200
"""
194201
return self.cancel_answer is None and self.cancel_requested.is_set()
195202

203+
def fire_success_frame(self) -> None:
204+
"""Invoke the success-frame observer ahead of a non-error frame write.
205+
206+
Contained like the subscription bus's listener boundary: a raising
207+
observer is logged and skipped, never propagated, so an observer bug
208+
cannot convert the success it is observing into an error response.
209+
"""
210+
if self.on_success_frame is None:
211+
return
212+
try:
213+
self.on_success_frame()
214+
except Exception: # observer boundary: the frame must still be written
215+
logger.exception("on_success_frame observer raised; continuing")
216+
196217
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
197218
if self._closed:
198219
logger.debug("dropped %s: dispatch context closed", method)
199220
return
200-
if self.on_success_frame is not None:
201-
self.on_success_frame()
221+
self.fire_success_frame()
202222
await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id)
203223

204224
async def send_raw_request(
@@ -225,6 +245,23 @@ def close(self) -> None:
225245
self._closed = True
226246

227247

248+
def _wire_context(dctx: DispatchContext[Any]) -> _JSONRPCDispatchContext[Any] | None:
249+
"""Resolve `dctx` to the JSON-RPC loop context that owns its wire request, if any.
250+
251+
`NoServerRequestsDispatchContext` delegates wire I/O to the context it
252+
wraps, so per-request facts attached through it must land on the wrapped
253+
context - the seam setters peel it here rather than silently no-oping on
254+
a context that IS a loop request underneath. Every other context type
255+
resolves to None: those are structurally silent after a peer cancel
256+
(single-exchange HTTP unwinds the response stream, direct dispatch
257+
unwinds into the caller's own cancelled scope), so there is no late
258+
answer to configure and the setters are documented no-ops for them.
259+
"""
260+
while isinstance(dctx, NoServerRequestsDispatchContext):
261+
dctx = dctx.inner
262+
return dctx if isinstance(dctx, _JSONRPCDispatchContext) else None
263+
264+
228265
def suppress_cancel_answer(dctx: DispatchContext[Any]) -> None:
229266
"""Commit `dctx`'s request to silence on peer cancellation.
230267
@@ -234,13 +271,15 @@ def suppress_cancel_answer(dctx: DispatchContext[Any]) -> None:
234271
those semantics; do so before the request's first checkpoint, so a racing
235272
cancel can never observe the legacy default answer.
236273
237-
No-op for dispatch contexts that are already structurally silent after a
238-
cancel (single-exchange HTTP closes the response stream; direct dispatch
239-
unwinds into the caller's own cancelled scope) - only the JSON-RPC loop
240-
context writes a late answer to suppress.
274+
Reaches through `NoServerRequestsDispatchContext` to the loop context it
275+
delegates to. No-op for dispatch contexts that are already structurally
276+
silent after a cancel (single-exchange HTTP closes the response stream;
277+
direct dispatch unwinds into the caller's own cancelled scope) - only the
278+
JSON-RPC loop context writes a late answer to suppress.
241279
"""
242-
if isinstance(dctx, _JSONRPCDispatchContext):
243-
dctx.cancel_answer = None
280+
wire = _wire_context(dctx)
281+
if wire is not None:
282+
wire.cancel_answer = None
244283

245284

246285
def observe_success_frames(dctx: DispatchContext[Any], observer: Callable[[], None]) -> None:
@@ -250,14 +289,19 @@ def observe_success_frames(dctx: DispatchContext[Any], observer: Callable[[], No
250289
success result, in the writing task with no checkpoint before the write -
251290
so state the observer commits is settled before the peer can read the
252291
frame. It does not fire for error responses or for notifications dropped
253-
because the request already finished. `observer` must not raise.
254-
255-
No-op for dispatch contexts other than the JSON-RPC loop's: the only
256-
caller is the dual-era loop's era lock, which has no analogue on the
292+
because the request already finished. A raising observer is contained -
293+
logged and skipped, the frame still written - matching the subscription
294+
bus's listener boundary, so an observer bug cannot convert a success into
295+
an error response.
296+
297+
Reaches through `NoServerRequestsDispatchContext` like
298+
`suppress_cancel_answer`. No-op for other non-loop dispatch contexts: the
299+
only caller is the dual-era loop's era lock, which has no analogue on the
257300
single-exchange or direct paths.
258301
"""
259-
if isinstance(dctx, _JSONRPCDispatchContext):
260-
dctx.on_success_frame = observer
302+
wire = _wire_context(dctx)
303+
if wire is not None:
304+
wire.on_success_frame = observer
261305

262306

263307
def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
@@ -795,18 +839,19 @@ async def _handle_request(
795839
# peers drop late responses, while a second answer for one id
796840
# would break JSON-RPC.
797841
answer_write_started = True
798-
if dctx.on_success_frame is not None:
799-
dctx.on_success_frame()
842+
dctx.fire_success_frame()
800843
await self._write_result(req.id, result)
801844
if scope.cancelled_caught and dctx.cancel_answer is not None:
802845
# anyio absorbs the scope's own cancel at __exit__, and
803846
# `cancelled_caught` (unlike `cancel_called`) guarantees the
804847
# result write above did not happen - no double response.
805848
# `cancel_answer` is the legacy compat answer unless the loop
806849
# layer committed this request to 2026 cancel semantics
807-
# (silence) via `suppress_cancel_answer`.
850+
# (silence) via `suppress_cancel_answer`. Written as a fresh
851+
# copy: frames cross in-memory transports by reference, and
852+
# the default answer is a shared template.
808853
answer_write_started = True
809-
await self._write_error(req.id, dctx.cancel_answer)
854+
await self._write_error(req.id, dctx.cancel_answer.model_copy())
810855
except anyio.get_cancelled_exc_class():
811856
# Shutdown: answer the request so the peer isn't left waiting - unless
812857
# an answer write already started (it may have reached the transport;

0 commit comments

Comments
 (0)