Files
modelcontextprotocol--pytho…/tests/shared/test_dispatcher.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

579 lines
25 KiB
Python

"""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