Skip to content

Commit ff34da9

Browse files
committed
Add per-request cancel-answer and success-frame seams to JSONRPCDispatcher
Two per-request facts on the dispatch context, both defaulting to the existing behavior: - cancel_answer: the error written when a peer cancel interrupts the handler. Defaults to the legacy code-0 "Request cancelled" compat answer; None means total silence (the 2026 rule that nothing follows notifications/cancelled). Handler-origin error responses now also honor the silence commitment, so an error computed after the cancel landed (shielded handler, synchronous raise) never reaches the wire. - on_success_frame: an observer invoked immediately before each non-error frame written for the request (request-scoped notifications, progress, the success result), with no checkpoint before the write. Loop layers use it to commit connection state no later than the request's first client-visible output. Configured via suppress_cancel_answer / observe_success_frames, which no-op on dispatch contexts that are already structurally silent after a cancel (single-exchange HTTP, direct dispatch). The dispatcher stays era-ignorant; the era knowledge lives in whoever calls the setters.
1 parent 3a6f299 commit ff34da9

2 files changed

Lines changed: 242 additions & 8 deletions

File tree

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections.abc import Awaitable, Callable, Mapping
1313
from dataclasses import dataclass, field
1414
from functools import partial
15-
from typing import Any, Generic, Literal, cast
15+
from typing import Any, Final, Generic, Literal, cast
1616

1717
import anyio
1818
import anyio.abc
@@ -64,7 +64,9 @@
6464
"JSONRPCDispatcher",
6565
"cancelled_request_id_from_params",
6666
"handler_exception_to_error_data",
67+
"observe_success_frames",
6768
"progress_token_from_params",
69+
"suppress_cancel_answer",
6870
]
6971

7072
logger = logging.getLogger(__name__)
@@ -131,6 +133,15 @@ class _InFlight(Generic[TransportT]):
131133
dctx: _JSONRPCDispatchContext[TransportT]
132134

133135

136+
_LEGACY_CANCEL_ANSWER: Final[ErrorData] = ErrorData(code=0, message="Request cancelled")
137+
"""The answer a peer-cancelled request gets by default.
138+
139+
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.
142+
"""
143+
144+
134145
@dataclass
135146
class _JSONRPCDispatchContext(Generic[TransportT]):
136147
"""Concrete `DispatchContext` produced for each inbound JSON-RPC message."""
@@ -143,6 +154,22 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
143154
_progress_token: ProgressToken | None = None
144155
_closed: bool = False
145156
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
157+
cancel_answer: ErrorData | None = field(default_factory=lambda: _LEGACY_CANCEL_ANSWER)
158+
"""The error written when a peer cancel interrupts this request's handler.
159+
160+
`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
163+
`notifications/cancelled`) clear it via `suppress_cancel_answer`.
164+
"""
165+
on_success_frame: Callable[[], None] | None = None
166+
"""Observer invoked synchronously immediately before each non-error frame
167+
written for this request: a request-scoped notification (progress rides
168+
those) or the success result. Never invoked for error responses, nor for
169+
notifications dropped because the context closed. Set via
170+
`observe_success_frames`; loop layers use it to commit connection state no
171+
later than the request's first client-visible output.
172+
"""
146173

147174
@property
148175
def request_id(self) -> RequestId | None:
@@ -156,6 +183,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call
156183
if self._closed:
157184
logger.debug("dropped %s: dispatch context closed", method)
158185
return
186+
if self.on_success_frame is not None:
187+
self.on_success_frame()
159188
await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id)
160189

161190
async def send_raw_request(
@@ -182,6 +211,41 @@ def close(self) -> None:
182211
self._closed = True
183212

184213

214+
def suppress_cancel_answer(dctx: DispatchContext[Any]) -> None:
215+
"""Commit `dctx`'s request to silence on peer cancellation.
216+
217+
2026-era wire rule: once the peer sends `notifications/cancelled` for a
218+
request, the server MUST NOT send any further frame for it - no result,
219+
no error. Era-aware loop layers call this the moment a request commits to
220+
those semantics; do so before the request's first checkpoint, so a racing
221+
cancel can never observe the legacy default answer.
222+
223+
No-op for dispatch contexts that are already structurally silent after a
224+
cancel (single-exchange HTTP closes the response stream; direct dispatch
225+
unwinds into the caller's own cancelled scope) - only the JSON-RPC loop
226+
context writes a late answer to suppress.
227+
"""
228+
if isinstance(dctx, _JSONRPCDispatchContext):
229+
dctx.cancel_answer = None
230+
231+
232+
def observe_success_frames(dctx: DispatchContext[Any], observer: Callable[[], None]) -> None:
233+
"""Invoke `observer` immediately before each non-error frame written for `dctx`'s request.
234+
235+
Fires for request-scoped notifications (progress rides those) and for the
236+
success result, in the writing task with no checkpoint before the write -
237+
so state the observer commits is settled before the peer can read the
238+
frame. It does not fire for error responses or for notifications dropped
239+
because the request already finished. `observer` must not raise.
240+
241+
No-op for dispatch contexts other than the JSON-RPC loop's: the only
242+
caller is the dual-era loop's era lock, which has no analogue on the
243+
single-exchange or direct paths.
244+
"""
245+
if isinstance(dctx, _JSONRPCDispatchContext):
246+
dctx.on_success_frame = observer
247+
248+
185249
def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
186250
return TransportContext(kind="jsonrpc", can_send_request=True)
187251

@@ -717,15 +781,18 @@ async def _handle_request(
717781
# peers drop late responses, while a second answer for one id
718782
# would break JSON-RPC.
719783
answer_write_started = True
784+
if dctx.on_success_frame is not None:
785+
dctx.on_success_frame()
720786
await self._write_result(req.id, result)
721-
if scope.cancelled_caught:
787+
if scope.cancelled_caught and dctx.cancel_answer is not None:
722788
# anyio absorbs the scope's own cancel at __exit__, and
723789
# `cancelled_caught` (unlike `cancel_called`) guarantees the
724790
# result write above did not happen - no double response.
725-
# TODO(L38): spec says SHOULD NOT respond after cancel;
726-
# the existing server always has, so match that for now.
791+
# `cancel_answer` is the legacy compat answer unless the loop
792+
# layer committed this request to 2026 cancel semantics
793+
# (silence) via `suppress_cancel_answer`.
727794
answer_write_started = True
728-
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
795+
await self._write_error(req.id, dctx.cancel_answer)
729796
except anyio.get_cancelled_exc_class():
730797
# Shutdown: answer the request so the peer isn't left waiting - unless
731798
# an answer write already started (it may have reached the transport;
@@ -742,12 +809,12 @@ async def _handle_request(
742809
except Exception as e:
743810
error = handler_exception_to_error_data(e)
744811
if error is not None:
745-
await self._write_error(req.id, error)
812+
await self._answer_error(dctx, req.id, error)
746813
else:
747814
logger.exception("handler for %r raised", req.method)
748815
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
749816
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
750-
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
817+
await self._answer_error(dctx, req.id, ErrorData(code=0, message=str(e)))
751818
if self._raise_handler_exceptions:
752819
raise
753820
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
@@ -771,6 +838,21 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
771838
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
772839
logger.debug("dropped error for %r: write stream closed", request_id)
773840

841+
async def _answer_error(
842+
self, dctx: _JSONRPCDispatchContext[TransportT], request_id: RequestId, error: ErrorData
843+
) -> None:
844+
"""Write a handler-origin error response, honoring the request's cancel-silence commitment.
845+
846+
A request committed to silence via `suppress_cancel_answer` whose peer
847+
has already cancelled gets NO frame at all - even when the handler's
848+
failure was computed before the cancellation interrupt could land (a
849+
synchronous raise after the cancel arrived, or a shielded handler).
850+
"""
851+
if dctx.cancel_answer is None and dctx.cancel_requested.is_set():
852+
logger.debug("dropped error for %r: peer cancelled a silence-committed request", request_id)
853+
return
854+
await self._write_error(request_id, error)
855+
774856
async def _final_write(
775857
self,
776858
write: Callable[[], Awaitable[None]],

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@
4141
_OutboundPlan,
4242
_Pending,
4343
_plan_outbound,
44+
observe_success_frames,
45+
suppress_cancel_answer,
4446
)
4547
from mcp.shared.message import ClientMessageMetadata, MessageMetadata, ServerMessageMetadata, SessionMessage
4648
from mcp.shared.transport_context import TransportContext
4749

48-
from .conftest import jsonrpc_pair
50+
from .conftest import direct_pair, jsonrpc_pair
4951
from .test_dispatcher import Recorder, echo_handlers, running_pair
5052

5153
DCtx = DispatchContext[TransportContext]
@@ -156,6 +158,156 @@ async def call_then_record() -> None:
156158
assert seen_error == [ErrorData(code=0, message="Request cancelled")]
157159

158160

161+
@pytest.mark.anyio
162+
async def test_suppress_cancel_answer_makes_a_peer_cancelled_request_fully_silent():
163+
"""A request committed to 2026 cancel semantics gets NO late answer: after
164+
the peer cancel nothing at all is written for its id (no result, no
165+
error), and the loop keeps serving other requests."""
166+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
167+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
168+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
169+
handler_started = anyio.Event()
170+
handler_exited = anyio.Event()
171+
172+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
173+
if method == "probe":
174+
return {"alive": True}
175+
suppress_cancel_answer(ctx)
176+
handler_started.set()
177+
try:
178+
await anyio.sleep_forever()
179+
finally:
180+
handler_exited.set()
181+
raise NotImplementedError
182+
183+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
184+
pass
185+
186+
try:
187+
async with anyio.create_task_group() as tg:
188+
await tg.start(server.run, on_request, on_notify)
189+
with anyio.fail_after(5):
190+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="slow")))
191+
await handler_started.wait()
192+
await c2s_send.send(
193+
SessionMessage(
194+
message=JSONRPCNotification(
195+
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 7}
196+
)
197+
)
198+
)
199+
await handler_exited.wait()
200+
# Once every task is parked again the cancelled handler task has
201+
# fully unwound, so the buffered write stream would already
202+
# carry any (buggy) late answer for id 7.
203+
await anyio.wait_all_tasks_blocked()
204+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=8, method="probe")))
205+
resp = await s2c_recv.receive()
206+
assert isinstance(resp, SessionMessage)
207+
assert isinstance(resp.message, JSONRPCResponse)
208+
# The probe's answer is the FIRST frame the server ever wrote:
209+
# total silence for the cancelled id.
210+
assert resp.message.id == 8
211+
tg.cancel_scope.cancel()
212+
finally:
213+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
214+
s.close()
215+
216+
217+
@pytest.mark.anyio
218+
async def test_suppress_cancel_answer_silences_even_an_error_raised_after_the_cancel_landed():
219+
"""A handler that observes the peer cancel and raises before the
220+
cancellation interrupt can land (shielded, then a synchronous raise)
221+
still gets total silence: the error computed after the cancel must not
222+
reach the wire either."""
223+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
224+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
225+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
226+
handler_started = anyio.Event()
227+
228+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
229+
if method == "probe":
230+
return {"alive": True}
231+
suppress_cancel_answer(ctx)
232+
handler_started.set()
233+
# Survive the interrupt so the cancel is observed, not delivered; the
234+
# raise below then reaches the dispatcher's error arm directly.
235+
with anyio.CancelScope(shield=True):
236+
await ctx.cancel_requested.wait()
237+
raise MCPError(code=INTERNAL_ERROR, message="computed after the cancel")
238+
239+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
240+
pass
241+
242+
try:
243+
async with anyio.create_task_group() as tg:
244+
await tg.start(server.run, on_request, on_notify)
245+
with anyio.fail_after(5):
246+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="slow")))
247+
await handler_started.wait()
248+
await c2s_send.send(
249+
SessionMessage(
250+
message=JSONRPCNotification(
251+
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 7}
252+
)
253+
)
254+
)
255+
await anyio.wait_all_tasks_blocked()
256+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=8, method="probe")))
257+
resp = await s2c_recv.receive()
258+
assert isinstance(resp, SessionMessage)
259+
assert isinstance(resp.message, JSONRPCResponse)
260+
# The probe's answer is the first frame ever written: the
261+
# post-cancel MCPError never reached the wire.
262+
assert resp.message.id == 8
263+
tg.cancel_scope.cancel()
264+
finally:
265+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
266+
s.close()
267+
268+
269+
@pytest.mark.anyio
270+
async def test_observe_success_frames_fires_for_notifications_and_the_result_but_not_errors():
271+
"""The observer runs immediately before each non-error frame for the
272+
request - the request-scoped notification and the success result - and
273+
never for an error response."""
274+
calls: list[str] = []
275+
276+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
277+
observe_success_frames(ctx, lambda: calls.append(method))
278+
if method == "fail":
279+
raise MCPError(code=INTERNAL_ERROR, message="nope")
280+
await ctx.notify("x/note", None)
281+
return {"ok": True}
282+
283+
async with running_pair(jsonrpc_pair, server_on_request=on_request) as (client, *_):
284+
with anyio.fail_after(5):
285+
assert await client.send_raw_request("emit", None) == {"ok": True}
286+
# Once before the notification frame, once before the result frame.
287+
assert calls == ["emit", "emit"]
288+
with pytest.raises(MCPError):
289+
await client.send_raw_request("fail", None)
290+
# Error responses never fire the observer.
291+
assert calls == ["emit", "emit"]
292+
293+
294+
@pytest.mark.anyio
295+
async def test_cancel_and_frame_seams_are_noops_on_non_jsonrpc_contexts():
296+
"""`suppress_cancel_answer` / `observe_success_frames` configure the
297+
JSON-RPC loop context's late-answer machinery; other dispatch contexts
298+
are already structurally silent after a cancel, so on the direct
299+
dispatcher both are safe no-ops."""
300+
301+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
302+
suppress_cancel_answer(ctx)
303+
observe_success_frames(ctx, lambda: None)
304+
return {"ok": True}
305+
306+
async with running_pair(direct_pair, server_on_request=on_request) as (client, *_):
307+
with anyio.fail_after(5):
308+
assert await client.send_raw_request("m", None) == {"ok": True}
309+
310+
159311
@pytest.mark.anyio
160312
async def test_peer_cancel_landing_after_handlers_last_checkpoint_writes_only_the_result():
161313
"""A peer cancel that fails to interrupt the handler writes only the result: one answer per

0 commit comments

Comments
 (0)