"""Behavioral tests for the Dispatcher Protocol. The contract tests are parametrized over every `Dispatcher` implementation via the `pair_factory` fixture (see `conftest.py`); they must pass for both `DirectDispatcher` and `JSONRPCDispatcher`. Implementation-specific tests pass a concrete factory directly. """ from collections.abc import AsyncIterator, Mapping from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any import anyio import pytest from mcp_types import ( CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, REQUEST_TIMEOUT, ErrorData, RequestId, Tool, ) from mcp.shared._compat import resync_tracer from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound from mcp.shared.exceptions import MCPError from mcp.shared.transport_context import TransportContext from .conftest import PairFactory, direct_pair class Recorder: def __init__(self) -> None: self.requests: list[tuple[str, Mapping[str, Any] | None]] = [] self.notifications: list[tuple[str, Mapping[str, Any] | None]] = [] self.contexts: list[DispatchContext[TransportContext]] = [] self.notified = anyio.Event() def echo_handlers(recorder: Recorder) -> tuple[OnRequest, OnNotify]: async def on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: # Strip `_meta` so JSON-RPC and direct dispatch record identically: # the JSON-RPC outbound path always attaches `_meta` (otel injection). recorded = {k: v for k, v in (params or {}).items() if k != "_meta"} if params is not None else None recorder.requests.append((method, recorded)) recorder.contexts.append(ctx) return {"echoed": method, "params": recorded or {}} async def on_notify(ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: recorder.notifications.append((method, params)) recorder.notified.set() return on_request, on_notify @asynccontextmanager async def running_pair( factory: PairFactory, *, server_on_request: OnRequest | None = None, server_on_notify: OnNotify | None = None, client_on_request: OnRequest | None = None, client_on_notify: OnNotify | None = None, client_on_notify_intercept: OnNotifyIntercept | None = None, can_send_request: bool = True, ) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]: """Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live.""" client, server, close = factory(can_send_request=can_send_request) client_rec, server_rec = Recorder(), Recorder() c_req, c_notify = echo_handlers(client_rec) s_req, s_notify = echo_handlers(server_rec) try: async with anyio.create_task_group() as tg: await tg.start( client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept ) await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify) try: yield client, server, client_rec, server_rec finally: tg.cancel_scope.cancel() finally: await resync_tracer() close() @pytest.mark.anyio async def test_send_raw_request_returns_result_from_peer_on_request(pair_factory: PairFactory): async with running_pair(pair_factory) as (client, _server, _crec, srec): with anyio.fail_after(5): result = await client.send_raw_request("tools/list", {"cursor": "abc"}) assert result == {"echoed": "tools/list", "params": {"cursor": "abc"}} assert srec.requests == [("tools/list", {"cursor": "abc"})] @pytest.mark.anyio async def test_send_raw_request_reraises_mcperror_from_handler_unchanged(pair_factory: PairFactory): async def on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: raise MCPError(code=INVALID_PARAMS, message="bad cursor") async with running_pair(pair_factory, server_on_request=on_request) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", {}) assert exc.value.error.code == INVALID_PARAMS assert exc.value.error.message == "bad cursor" @pytest.mark.anyio async def test_send_raw_request_maps_validation_error_to_invalid_params(pair_factory: PairFactory): """A pydantic `ValidationError` from the handler surfaces as the normalized INVALID_PARAMS shape on every dispatcher.""" async def on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: Tool.model_validate({"name": 123}) # raises ValidationError raise NotImplementedError async with running_pair(pair_factory, server_on_request=on_request) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", None) assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") @pytest.mark.anyio async def test_send_raw_request_with_timeout_raises_mcperror_request_timeout(pair_factory: PairFactory): async def on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: await anyio.sleep_forever() raise NotImplementedError async with running_pair(pair_factory, server_on_request=on_request) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("slow", None, {"timeout": 0}) assert exc.value.error.code == REQUEST_TIMEOUT @pytest.mark.anyio async def test_notify_invokes_peer_on_notify(pair_factory: PairFactory): async with running_pair(pair_factory) as (client, _server, _crec, srec): with anyio.fail_after(5): await client.notify("notifications/initialized", {"v": 1}) await srec.notified.wait() assert srec.notifications == [("notifications/initialized", {"v": 1})] @pytest.mark.anyio async def test_ctx_send_raw_request_round_trips_to_calling_side(pair_factory: PairFactory): """A handler's ctx.send_raw_request reaches the side that made the inbound request.""" async def server_on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: sample = await ctx.send_raw_request("sampling/createMessage", {"prompt": "hi"}) return {"sampled": sample} async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec): with anyio.fail_after(5): result = await client.send_raw_request("tools/call", None) assert crec.requests == [("sampling/createMessage", {"prompt": "hi"})] assert result == {"sampled": {"echoed": "sampling/createMessage", "params": {"prompt": "hi"}}} @pytest.mark.anyio async def test_ctx_send_raw_request_raises_nobackchannelerror_when_transport_disallows(pair_factory: PairFactory): async def server_on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: return await ctx.send_raw_request("sampling/createMessage", None) async with running_pair(pair_factory, server_on_request=server_on_request, can_send_request=False) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("tools/call", None) assert exc.value.error.code == INVALID_REQUEST @pytest.mark.anyio async def test_ctx_notify_invokes_calling_side_on_notify(pair_factory: PairFactory): async def server_on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: await ctx.notify("notifications/message", {"level": "info"}) return {} async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec): with anyio.fail_after(5): await client.send_raw_request("tools/call", None) await crec.notified.wait() assert crec.notifications == [("notifications/message", {"level": "info"})] @pytest.mark.anyio async def test_ctx_progress_invokes_caller_on_progress_callback(pair_factory: PairFactory): async def server_on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: await ctx.progress(0.5, total=1.0, message="halfway") return {} received: list[tuple[float, float | None, str | None]] = [] async def on_progress(progress: float, total: float | None, message: str | None) -> None: received.append((progress, total, message)) async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_): with anyio.fail_after(5): await client.send_raw_request("tools/call", None, {"on_progress": on_progress}) assert received == [(0.5, 1.0, "halfway")] @pytest.mark.anyio async def test_ctx_progress_is_noop_when_caller_supplied_no_callback(pair_factory: PairFactory): async def server_on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: await ctx.progress(0.5) return {"ok": True} async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_): with anyio.fail_after(5): result = await client.send_raw_request("tools/call", None) assert result == {"ok": True} @pytest.mark.anyio async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair_factory: PairFactory): """Plain requests carry no transport metadata, so handlers see `None`.""" async with running_pair(pair_factory) as (client, _server, _crec, srec): with anyio.fail_after(5): await client.send_raw_request("tools/call", None) assert len(srec.contexts) == 1 assert srec.contexts[0].message_metadata is None @pytest.mark.anyio async def test_ctx_request_id_exposes_inbound_id(pair_factory: PairFactory): """Every dispatcher assigns each inbound request a distinct int id; JSON-RPC carries the wire id through, DirectDispatcher synthesizes one (SDK-defined).""" async with running_pair(pair_factory) as (client, _server, _crec, srec): with anyio.fail_after(5): await client.send_raw_request("tools/call", None) await client.send_raw_request("tools/call", None) a, b = (ctx.request_id for ctx in srec.contexts) assert isinstance(a, int) and isinstance(b, int) assert a != b @pytest.mark.anyio async def test_direct_send_raw_request_wraps_non_mcperror_exception_as_internal_error_with_cause(): """DirectDispatcher-specific: the original exception is chained via __cause__.""" async def on_request( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: raise ValueError("oops") async with running_pair(direct_pair, server_on_request=on_request) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", {}) assert exc.value.error.code == INTERNAL_ERROR assert isinstance(exc.value.__cause__, ValueError) @pytest.mark.anyio async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_ready(): client, server = create_direct_dispatcher_pair() s_req, s_notify = echo_handlers(Recorder()) c_req, c_notify = echo_handlers(Recorder()) async with anyio.create_task_group() as tg: await tg.start(client.run, c_req, c_notify) # start_soon: the server side only becomes ready once the request below has parked. tg.start_soon(server.run, s_req, s_notify) with anyio.fail_after(5): result = await client.send_raw_request("ping", None) assert result == {"echoed": "ping", "params": {}} client.close() server.close() @pytest.mark.anyio async def test_direct_send_raw_request_before_run_raises_runtimeerror(): """The not-running guard fires immediately - before any waiting on the peer - matching JSONRPCDispatcher.""" client, _server = create_direct_dispatcher_pair() with anyio.fail_after(5), pytest.raises(RuntimeError) as exc: await client.send_raw_request("ping", None) assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()" @pytest.mark.anyio async def test_direct_send_raw_request_to_never_run_peer_honors_timeout(): """A configured timeout bounds the wait for a peer whose run() has not started.""" client, _server = create_direct_dispatcher_pair() c_req, c_notify = echo_handlers(Recorder()) async with anyio.create_task_group() as tg: await tg.start(client.run, c_req, c_notify) with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("ping", None, {"timeout": 0}) assert exc.value.error.code == REQUEST_TIMEOUT client.close() @pytest.mark.anyio async def test_direct_request_parked_waiting_for_peer_run_is_woken_by_peer_close(): """A request waiting on a never-run peer fails with CONNECTION_CLOSED when that peer closes.""" client, server = create_direct_dispatcher_pair() c_req, c_notify = echo_handlers(Recorder()) with anyio.fail_after(5): async with anyio.create_task_group() as tg: await tg.start(client.run, c_req, c_notify) async def send() -> None: with pytest.raises(MCPError) as exc: await client.send_raw_request("ping", None) assert exc.value.error.code == CONNECTION_CLOSED client.close() tg.start_soon(send) await anyio.wait_all_tasks_blocked() server.close() @pytest.mark.anyio async def test_direct_send_raw_request_after_local_close_raises_and_notify_is_dropped(): """After this side has closed, send_raw_request raises CONNECTION_CLOSED and notify drops fire-and-forget, matching JSONRPCDispatcher (SDK-defined).""" async with running_pair(direct_pair) as (client, _server, _crec, srec): pass # exiting cancels both run() loops, closing both sides with pytest.raises(MCPError) as exc: await client.send_raw_request("ping", None) assert exc.value.error.code == CONNECTION_CLOSED await client.notify("notifications/roots/list_changed", None) assert srec.requests == [] assert srec.notifications == [] @pytest.mark.anyio async def test_direct_inbound_after_peer_close_refuses_requests_and_drops_notifications(): """Dispatch to a closed side fails the peer's request with CONNECTION_CLOSED and silently drops the peer's notify; the closed side's handlers are never invoked (SDK-defined).""" client, server = create_direct_dispatcher_pair() crec, srec = Recorder(), Recorder() c_req, c_notify = echo_handlers(crec) s_req, s_notify = echo_handlers(srec) with anyio.fail_after(5): async with anyio.create_task_group() as tg: await tg.start(client.run, c_req, c_notify) await tg.start(server.run, s_req, s_notify) client.close() with pytest.raises(MCPError) as exc: await server.send_raw_request("roots/list", None) assert exc.value.error.code == CONNECTION_CLOSED await server.notify("notifications/message", None) server.close() assert crec.requests == [] assert crec.notifications == [] @pytest.mark.anyio async def test_direct_inbound_to_closed_never_run_peer_fails_with_connection_closed(): """A peer that closed without ever running refuses dispatch instead of parking the caller.""" client, server = create_direct_dispatcher_pair() c_req, c_notify = echo_handlers(Recorder()) server.close() with anyio.fail_after(5): async with anyio.create_task_group() as tg: await tg.start(client.run, c_req, c_notify) with pytest.raises(MCPError) as exc: await client.send_raw_request("ping", None) assert exc.value.error.code == CONNECTION_CLOSED client.close() @pytest.mark.anyio async def test_direct_send_raw_request_and_notify_raise_runtimeerror_when_no_peer_connected(): d = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) with pytest.raises(RuntimeError, match="no peer"): await d.send_raw_request("ping", None) with pytest.raises(RuntimeError, match="no peer"): await d.notify("ping", None) @pytest.mark.anyio async def test_direct_close_makes_run_return(): client, server = create_direct_dispatcher_pair() on_request, on_notify = echo_handlers(Recorder()) with anyio.fail_after(5): async with anyio.create_task_group() as tg: tg.start_soon(server.run, on_request, on_notify) tg.start_soon(client.run, on_request, on_notify) client.close() server.close() @pytest.mark.anyio async def test_send_raw_request_honors_caller_supplied_request_id_verbatim_typed(pair_factory: PairFactory): """A caller-supplied `CallOptions["request_id"]` reaches the peer's context verbatim — "7" stays a string, never the integer 7 — and the next call without one still mints a dispatcher id as before.""" async with running_pair(pair_factory) as (client, _server, _crec, srec): with anyio.fail_after(5): await client.send_raw_request("first", None, {"request_id": "7"}) await client.send_raw_request("second", None) supplied, minted = (ctx.request_id for ctx in srec.contexts) assert supplied == "7" assert type(supplied) is str assert type(minted) is int @pytest.mark.anyio async def test_send_raw_request_with_in_flight_request_id_raises_and_frees_id_on_completion( pair_factory: PairFactory, ): """Reusing an id while it is in flight is a loud `ValueError` — silent reuse would corrupt response correlation. Once the first request completes, the id is free again: the reservation is in-flight-scoped, not permanent.""" entered = anyio.Event() release = anyio.Event() async def parked( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: entered.set() await release.wait() return {"served": method} async with running_pair(pair_factory, server_on_request=parked) as (client, *_): with anyio.fail_after(5): async with anyio.create_task_group() as tg: async def first() -> None: await client.send_raw_request("slow", None, {"request_id": "listen-1"}) tg.start_soon(first) await entered.wait() with pytest.raises(ValueError, match="already in flight"): await client.send_raw_request("duplicate", None, {"request_id": "listen-1"}) release.set() result = await client.send_raw_request("again", None, {"request_id": "listen-1"}) assert result == {"served": "again"} @pytest.mark.anyio async def test_minted_ids_skip_a_caller_supplied_id_still_in_flight(pair_factory: PairFactory): """The dispatcher mints PAST a key a supplied id occupies — the collision error is reserved for the caller who chose the id, never an innocent minted request.""" entered = anyio.Event() release = anyio.Event() seen_ids: list[RequestId | None] = [] async def maybe_park( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: seen_ids.append(ctx.request_id) if method == "park": entered.set() await release.wait() return {} async with running_pair(pair_factory, server_on_request=maybe_park) as (client, *_): with anyio.fail_after(5): async with anyio.create_task_group() as tg: async def parked() -> None: await client.send_raw_request("park", None, {"request_id": "3"}) tg.start_soon(parked) await entered.wait() # The counter mints 1 and 2, then skips the occupied 3 to 4. for _ in range(3): await client.send_raw_request("plain", None) release.set() assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4] @pytest.mark.anyio async def test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factory: PairFactory): """ "7" and 7 are one id in the collision domain on BOTH dispatchers, so the in-memory pair raises exactly where the wire dispatcher (whose pending keys are coerced for response correlation) would.""" entered = anyio.Event() release = anyio.Event() async def parked( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: entered.set() await release.wait() return {} async with running_pair(pair_factory, server_on_request=parked) as (client, *_): with anyio.fail_after(5): async with anyio.create_task_group() as tg: async def first() -> None: await client.send_raw_request("slow", None, {"request_id": 7}) tg.start_soon(first) await entered.wait() with pytest.raises(ValueError, match="already in flight"): await client.send_raw_request("duplicate", None, {"request_id": "7"}) release.set() # Completion frees the id for either spelling. assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} @pytest.mark.anyio async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory): """The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do.""" intercepted: list[str] = [] def intercept(method: str, params: Mapping[str, Any] | None) -> bool: intercepted.append(method) return method == "notifications/consumed" async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec): with anyio.fail_after(5): await server.notify("notifications/consumed", None) await server.notify("notifications/passed", None) await crec.notified.wait() assert intercepted == ["notifications/consumed", "notifications/passed"] assert [method for method, _ in crec.notifications] == ["notifications/passed"] @pytest.mark.anyio async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory): """Notifications written before a response are intercepted before it resolves, whatever spawned handlers do.""" seen: list[str] = [] def intercept(method: str, params: Mapping[str, Any] | None) -> bool: seen.append(method) return False async def notify_then_answer( ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: await ctx.notify("notifications/first", None) await ctx.notify("notifications/second", None) return {} async with running_pair( pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept ) as (client, *_): with anyio.fail_after(5): await client.send_raw_request("burst", None) assert seen == ["notifications/first", "notifications/second"] @pytest.mark.anyio async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory): """An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives.""" def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool: raise RuntimeError("intercept exploded") async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as ( _client, server, crec, _srec, ): with anyio.fail_after(5): await server.notify("notifications/survives", None) await crec.notified.wait() assert [method for method, _ in crec.notifications] == ["notifications/survives"] if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d