chore: import upstream snapshot with attribution
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) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
@@ -0,0 +1,467 @@
"""Cancellation interactions against the low-level Server, driven through the public Client API.
Client-side, cancelling means abandoning: cancelling the task that awaits a call makes the SDK
carry the signal in the transport's own spelling (a cancelled frame on stream wires, closing the
request's own response stream at 2026-07-28 streamable HTTP). The receiving-side tests instead
script a CancelledNotification by hand, capturing the request id from inside the blocked handler.
Handlers block on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
REQUEST_TIMEOUT,
CallToolResult,
EmptyResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
PingRequest,
ServerCapabilities,
TextContent,
Tool,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("protocol:cancel:in-flight")
@requirement("protocol:cancel:handler-abort-propagates")
async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None:
"""Cancelling an in-flight request interrupts its handler and fails the pending call.
The server answers the cancelled request with an error response (the spec says it should
not respond at all; see the divergence note on the requirement), so the caller's pending
request raises rather than hanging.
"""
started = anyio.Event()
handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
errors: list[ErrorData] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
try:
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable: the wait above never completes normally
server = Server("blocker", on_call_tool=call_tool)
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group:
async def call_and_capture_error() -> None:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {})
errors.append(exc_info.value.error)
task_group.start_soon(call_and_capture_error)
await started.wait()
await client.session.send_notification(
types.CancelledNotification(
params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
)
)
await handler_cancelled.wait()
assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
@requirement("protocol:cancel:server-survives")
async def test_session_serves_requests_after_cancellation(connect: Connect) -> None:
"""A request cancelled mid-flight does not poison the session: the next request succeeds."""
started = anyio.Event()
request_ids: list[types.RequestId] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(name="block", input_schema={"type": "object"}),
types.Tool(name="echo", input_schema={"type": "object"}),
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "echo":
return CallToolResult(content=[TextContent(text="still alive")])
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
await anyio.Event().wait() # blocks until cancelled
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group:
async def call_and_swallow_cancellation_error() -> None:
with pytest.raises(MCPError):
await client.call_tool("block", {})
task_group.start_soon(call_and_swallow_cancellation_error)
await started.wait()
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
@requirement("protocol:cancel:unknown-id-ignored")
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
"""A cancellation referencing a request id that is not in flight is ignored without error."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="unbothered")])
server = Server("calm", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=9999))
)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
@requirement("protocol:cancel:server-to-client")
async def test_abandoned_server_request_cancels_the_client_callback(connect: Connect) -> None:
"""A server that abandons a sampling request cancels it, interrupting the client's callback mid-await."""
callback_started = anyio.Event()
callback_cancelled = anyio.Event()
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
try:
await anyio.Event().wait() # blocks until the cancellation interrupts it
except anyio.get_cancelled_exc_class():
callback_cancelled.set()
raise
raise NotImplementedError # unreachable
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
async with anyio.create_task_group() as abandon_scope:
async def sample() -> None:
await ctx.session.send_request(request, types.CreateMessageResult)
raise NotImplementedError # unreachable: the scope is cancelled
abandon_scope.start_soon(sample)
with anyio.fail_after(5):
await callback_started.wait()
abandon_scope.cancel_scope.cancel()
with anyio.fail_after(5):
await callback_cancelled.wait()
return CallToolResult(content=[TextContent(text="abandoned")])
server = Server("abandoner", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="abandoned")]))
assert callback_cancelled.is_set()
@requirement("protocol:cancel:late-response-ignored")
async def test_a_response_for_an_unknown_request_id_is_ignored() -> None:
"""A response whose id matches no in-flight request is ignored, as the spec asks.
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
that is the same client-side code path as any response with an unknown id, and that form is
deterministic to test without a client-side cancellation API.
"Ignored" is proved in two halves: the pong round-trip proves the read loop survived the
fabricated response (the ordered in-memory stream routed it first), and `surfaced` holding
only the control notification proves the fabricated response was never delivered to
`message_handler` (v1 surfaced it there as a RuntimeError).
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
other tests in this file run over the transport matrix; this one is in-memory only because the
scripted-peer mechanism is the in-memory stream pair, not because the behaviour is
transport-specific.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
def respond(request_id: types.RequestId, result: types.Result) -> SessionMessage:
return SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request_id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
assert init.message.method == "initialize"
await server_write.send(
respond(
init.message.id,
InitializeResult(
protocol_version="2025-11-25",
capabilities=ServerCapabilities(),
server_info=Implementation(name="scripted", version="0.0.1"),
),
)
)
initialized = await server_read.receive()
assert isinstance(initialized, SessionMessage)
assert isinstance(initialized.message, JSONRPCNotification)
assert initialized.message.method == "notifications/initialized"
ping = await server_read.receive()
assert isinstance(ping, SessionMessage)
assert isinstance(ping.message, JSONRPCRequest)
assert ping.message.method == "ping"
# First a fabricated id that matches nothing in flight, then a control notification that
# is surfaced to message_handler (proving the handler is live), then the real id.
await server_write.send(respond(9999, EmptyResult()))
await server_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await server_write.send(respond(ping.message.id, EmptyResult()))
surfaced: list[IncomingMessage] = []
async def message_handler(message: IncomingMessage) -> None:
surfaced.append(message)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
ClientSession(client_read, client_write, message_handler=message_handler) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
await session.initialize()
pong = await session.send_request(PingRequest(), EmptyResult)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so the fabricated response was routed before the control
# notification: only the control surfaced, so the unknown-id response was dropped.
assert surfaced == snapshot([types.ToolListChangedNotification()])
@requirement("protocol:cancel:initialize-not-cancellable")
async def test_timed_out_initialize_sends_no_cancellation() -> None:
"""An abandoned initialize is not followed by notifications/cancelled on the wire (spec-mandated).
A real Server always answers initialize, so the test plays a stalling server by hand.
"""
received_methods: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
# Hold the initialize request unanswered until the client's read timeout fires.
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
received_methods.append(init.message.method)
follow_up = await server_read.receive()
assert isinstance(follow_up, SessionMessage)
assert isinstance(follow_up.message, JSONRPCRequest)
received_methods.append(follow_up.message.method)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=follow_up.message.id,
result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
# The session-level read timeout is the only public pathway that abandons initialize.
ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.initialize()
assert exc_info.value.error.code == REQUEST_TIMEOUT
# Override the session-level timeout: this ping must round-trip normally.
pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
assert received_methods == snapshot(["initialize", "ping"])
@requirement("protocol:cancel:abort-signal")
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
the server-side handler is interrupted, and the session serves later requests normally.
Spec-mandated (cancellation flow): the sender cancels requests it abandons; the wire spelling
is per-transport (frame on stream wires, response-stream close at 2026 streamable HTTP).
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "block":
handler_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
assert abandon.cancelled_caught
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("protocol:cancel:abort-scoped")
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
abandoning the first interrupts only its handler and the second returns its result.
Steps:
1. `doomed` and `survivor` are both mid-flight (each handler has started).
2. The client abandons `doomed`; its handler observes cancellation.
3. `survivor` is released and completes normally.
"""
doomed_started = anyio.Event()
doomed_cancelled = anyio.Event()
survivor_started = anyio.Event()
release_survivor = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "doomed":
doomed_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
doomed_cancelled.set()
raise
assert params.name == "survivor"
survivor_started.set()
with anyio.fail_after(5):
await release_survivor.wait()
return CallToolResult(content=[TextContent(text="survived")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("doomed", "survivor")]
)
server = Server("pair", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
abandon = anyio.CancelScope()
results: list[CallToolResult] = []
async def doomed_call() -> None:
with abandon:
await client.call_tool("doomed", {})
raise NotImplementedError # unreachable: the call never resolves
async def survivor_call() -> None:
results.append(await client.call_tool("survivor", {}))
async with anyio.create_task_group() as tg:
tg.start_soon(doomed_call)
with anyio.fail_after(5):
await doomed_started.wait()
tg.start_soon(survivor_call)
with anyio.fail_after(5):
await survivor_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await doomed_cancelled.wait()
release_survivor.set()
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])
@@ -0,0 +1,370 @@
"""Client connect-time negotiation: mode selection, server/discover, and the per-request envelope.
These tests pin what `Client(..., mode=...)` puts on the wire BEFORE the caller's first call --
the legacy initialize handshake, the modern `server/discover` probe, or nothing at all -- and
that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta`
envelope on every subsequent request. Each test drives the highest public surface (`Client`)
and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and
`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport.
The fallback test alone hand-plays the server's side of the wire, because no real `Server`
answers `server/discover` with -32601.
"""
import json
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import anyio
import httpx
import mcp_types as types
import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
DiscoverResult,
Implementation,
InitializeResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
ToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
from mcp import MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, Connect, mounted_app
from tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _tools_server(name: str = "negotiator") -> Server:
"""A low-level server with one list-tools handler, so a feature request has something to reach."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="noop", input_schema={"type": "object"})])
return Server(name, on_list_tools=list_tools)
def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]:
"""Return a list and an `on_request` hook that appends each outgoing httpx request to it."""
captured: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
captured.append(request)
return captured, on_request
@requirement("lifecycle:mode:legacy-never-probes")
async def test_legacy_mode_sends_initialize_and_never_probes_discover() -> None:
"""`Client(server, mode='legacy')` opens with `initialize` and never sends `server/discover`.
Requirement `lifecycle:mode:legacy-never-probes` (sdk-defined): ``mode='legacy'`` must remain
byte-identical to the pre-2026 client so a 2025-era server never observes modern vocabulary.
"""
recording = RecordingTransport(InMemoryTransport(_tools_server()))
with anyio.fail_after(5):
async with Client(recording, mode="legacy") as client:
await client.list_tools()
sent = [m.message for m in recording.sent]
methods = [m.method for m in sent if isinstance(m, JSONRPCRequest | JSONRPCNotification)]
assert methods[0] == "initialize"
assert "server/discover" not in methods
assert "notifications/initialized" in methods
@requirement("lifecycle:mode:pin-never-handshakes")
async def test_pinned_mode_sends_no_connect_time_traffic() -> None:
"""`Client(..., mode='2026-07-28')` sends nothing on entry; the caller's first call is the first wire request.
Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a
synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross
the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
assert requests == [] # entering the Client produced zero HTTP traffic
result = await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert [b["method"] for b in bodies] == ["tools/list"]
assert PROTOCOL_VERSION_META_KEY in bodies[0]["params"]["_meta"]
assert [t.name for t in result.tools] == ["noop"]
@requirement("lifecycle:mode:prior-discover-zero-rtt")
async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -> None:
"""`Client(..., mode=<pin>, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
DiscoverResult is installed via `adopt()` so server_info and capabilities are available
immediately with zero round trips.
"""
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
server_info=Implementation(name="cached-server", version="9.9.9"),
)
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=prior,
) as client,
):
assert requests == []
assert client.server_info == Implementation(name="cached-server", version="9.9.9")
assert client.server_capabilities.tools == ToolsCapability(list_changed=False)
await client.list_tools()
assert [json.loads(r.content)["method"] for r in requests] == ["tools/list"]
@requirement("lifecycle:discover:basic")
async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
"""`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info.
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
single `server/discover` request whose result carries supported versions, capabilities,
server_info and the cache-hint fields, after which the session is modern-negotiated.
"""
requests, on_request = _request_recorder()
server = _tools_server("discoverable")
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.server_info.name == "discoverable"
await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert bodies[0]["method"] == "server/discover"
assert "initialize" not in [b["method"] for b in bodies]
@requirement("lifecycle:discover:retry-on-32022")
async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None:
"""A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version.
Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the
client intersects `error.data.supported` with its own modern versions and re-probes once;
the second success is adopted. The server's `server/discover` handler is overridden to fail
the first call and succeed on the second.
"""
calls: list[str | None] = []
async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult:
proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None
calls.append(proposed)
if len(calls) == 1:
raise MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": proposed},
)
return DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="picky", version="1.0.0"),
)
server = _tools_server("picky")
server.add_request_handler("server/discover", types.RequestParams, discover)
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert calls == [LATEST_MODERN_VERSION, LATEST_MODERN_VERSION]
assert [json.loads(r.content)["method"] for r in requests][:2] == ["server/discover", "server/discover"]
@requirement("lifecycle:discover:network-error-raises")
async def test_auto_mode_propagates_a_network_error_from_discover_without_initializing() -> None:
"""A network/connection error during `server/discover` propagates to the caller without falling back.
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy
every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the
only probe failures that reach the caller are real outages — network errors, anyio resource
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from
the underlying transport, which the policy must not classify as an era verdict. The error
reaches the test wrapped in the streamable-http transport's task-group teardown, so
`pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the
transport raises, so the `initialize` fallback observably did not happen.
"""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
raise httpx.ConnectError("connection refused")
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch
async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"):
raise NotImplementedError("entering the Client should have raised") # pragma: no cover
assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"]
@requirement("lifecycle:discover:fallback-method-not-found")
@pytest.mark.parametrize(
("probe_code", "probe_message"),
[
(METHOD_NOT_FOUND, "Method not found"),
(INVALID_REQUEST, "Bad Request: Missing session ID"),
],
ids=["method-not-found", "invalid-request"],
)
async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
probe_code: int, probe_message: str
) -> None:
"""A legacy server's rejection of `server/discover` makes an auto-negotiating client fall back to `initialize`.
Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility):
a legacy-era server that does not implement `server/discover` is connected to via the
handshake, and the session lands at a handshake-era protocol version. The probe rejection
arrives as METHOD_NOT_FOUND from a server that routes the unknown method, or as
INVALID_REQUEST from a deployed v1.x stateful streamable-HTTP server that rejects the
session-id-less probe before dispatch. A real `Server` always implements `server/discover`,
so this test plays the server's side of the wire by hand. Reserve this pattern for behaviour
no real server can be made to produce.
"""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, JSONRPCRequest | JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover":
error = types.ErrorData(code=probe_code, message=probe_message)
await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize":
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# notifications/initialized (and anything else) is observed and ignored.
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@requirement("lifecycle:envelope:stamped-on-every-request")
async def test_every_request_on_a_modern_session_carries_the_three_key_meta_envelope(connect: Connect) -> None:
"""Each modern-session request's `params._meta` carries protocolVersion, clientInfo and clientCapabilities.
Requirement `lifecycle:envelope:stamped-on-every-request` (spec basic#_meta): the per-request
envelope replaces the initialize handshake's once-per-session exchange. Asserted server-side
by capturing `ctx.meta` inside the handler.
"""
observed: list[dict[str, object]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
assert ctx.meta is not None
observed.append(dict(ctx.meta))
return types.ListToolsResult(tools=[])
server = Server("stamped", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect(server, client_info=Implementation(name="enveloper", version="1.2.3")) as client:
await client.list_tools()
await client.list_tools()
assert len(observed) == 2
for meta in observed:
assert meta[PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION
assert meta[CLIENT_INFO_META_KEY] == {"name": "enveloper", "version": "1.2.3"}
assert CLIENT_CAPABILITIES_META_KEY in meta
@requirement("lifecycle:envelope:header-matches-meta")
async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None:
"""On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body.
Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the
body-derived header and the envelope's protocol version are kept in lockstep so the server's
header-based routing and body-based validation never disagree.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
await client.list_tools()
await client.list_tools()
assert requests, "no HTTP traffic recorded"
for request in requests:
body = json.loads(request.content)
assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY]
assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
@@ -0,0 +1,134 @@
"""Completion interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
METHOD_NOT_FOUND,
CompleteResult,
Completion,
ErrorData,
PromptReference,
ResourceTemplateReference,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("completion:prompt-arg")
@requirement("completion:result-shape")
async def test_complete_prompt_argument(connect: Connect) -> None:
"""Completing a prompt argument delivers the ref, argument name, and current value to the handler.
The returned values are filtered by the argument's value, proving the value reached the handler.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, PromptReference)
assert params.ref.name == "code_review"
assert params.argument.name == "language"
candidates = ["python", "pytorch", "ruby"]
matches = [candidate for candidate in candidates if candidate.startswith(params.argument.value)]
return CompleteResult(completion=Completion(values=matches, total=len(matches), has_more=False))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
PromptReference(name="code_review"), argument={"name": "language", "value": "py"}
)
assert result == snapshot(
CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False))
)
@requirement("completion:resource-template-arg")
async def test_complete_resource_template_variable(connect: Connect) -> None:
"""Completing a URI template variable delivers the template URI and variable name to the handler."""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "github://repos/{owner}/{repo}"
assert params.argument.name == "owner"
return CompleteResult(completion=Completion(values=[f"{params.argument.value}contextprotocol"]))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
argument={"name": "owner", "value": "model"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
@requirement("completion:context-arguments")
async def test_complete_receives_context_arguments(connect: Connect) -> None:
"""Previously-resolved arguments passed as completion context reach the handler.
The returned value is derived from the context, proving it arrived.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert params.argument.name == "repo"
assert params.context is not None
assert params.context.arguments is not None
return CompleteResult(completion=Completion(values=[f"{params.context.arguments['owner']}/python-sdk"]))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
argument={"name": "repo", "value": ""},
context_arguments={"owner": "modelcontextprotocol"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])))
@requirement("completion:error:invalid-ref")
async def test_completion_against_an_unknown_ref_is_rejected_with_invalid_params(connect: Connect) -> None:
"""completion/complete with a ref naming an unknown prompt is answered with -32602 Invalid params.
The lowlevel server does not validate refs itself (it has no prompt/template registry to check
against); rejecting an unknown ref is the handler's job, and this test pins the spec-recommended
way to do it.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, PromptReference)
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {params.ref.name!r}")
server = Server("completer", on_completion=completion)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="ghost"), argument={"name": "x", "value": ""})
assert exc_info.value.error.code == INVALID_PARAMS
@requirement("completion:complete:not-supported")
@requirement("protocol:error:method-not-found")
async def test_complete_without_handler_is_method_not_found(connect: Connect) -> None:
"""A server with no completion handler advertises no completions capability and rejects the request."""
server = Server("incomplete")
async with connect(server) as client:
assert client.server_capabilities.completions is None
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="completion/complete")
)
@@ -0,0 +1,665 @@
"""Form- and URL-mode elicitation against the low-level Server, driven through the public Client API.
The final test plays the server's side of the wire by hand to issue an elicitation request with no
mode field, because the typed server API (`elicit_form`/`elicit_url`) always serializes one.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
ElicitCompleteNotification,
ElicitCompleteNotificationParams,
ElicitRequestedSchema,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
TextContent,
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
REQUESTED_SCHEMA: dict[str, object] = {
"type": "object",
"properties": {
"username": {"type": "string"},
"newsletter": {"type": "boolean"},
},
"required": ["username"],
}
@requirement("elicitation:form:action:accept")
@requirement("elicitation:form:basic")
@requirement("tools:call:elicitation-roundtrip")
async def test_elicit_form_accepted_content_returns_to_handler(connect: Connect) -> None:
"""An accepted form elicitation returns the user's content to the requesting handler.
The tool reports the action as text and the received content as structured content, proving
the client's answer made it back into the tool's own result.
"""
received: list[types.ElicitRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="signup", description="Register the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "signup"
answer = await ctx.session.elicit_form("Choose a username.", REQUESTED_SCHEMA)
return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content)
server = Server("registrar", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"username": "ada", "newsletter": True})
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("signup", {})
assert received == snapshot(
[
ElicitRequestFormParams(
_meta={},
message="Choose a username.",
requested_schema={
"type": "object",
"properties": {
"username": {"type": "string"},
"newsletter": {"type": "boolean"},
},
"required": ["username"],
},
)
]
)
assert result == snapshot(
CallToolResult(
content=[TextContent(text="accept")],
structured_content={"username": "ada", "newsletter": True},
)
)
@requirement("elicitation:form:action:decline")
async def test_elicit_form_decline_returns_no_content(connect: Connect) -> None:
"""A declined form elicitation returns the decline action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "confirm"
answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("confirm", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")]))
@requirement("elicitation:form:action:cancel")
async def test_elicit_form_cancel_returns_no_content(connect: Connect) -> None:
"""A cancelled form elicitation returns the cancel action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "confirm"
answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("confirm", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")]))
@requirement("elicitation:form:not-supported")
@requirement("elicitation:capability:server-respects-mode")
async def test_elicit_form_without_callback_is_error(connect: Connect) -> None:
"""Eliciting from a client that configured no elicitation callback fails with an error.
The client's default callback answers with an Invalid request error, which the server-side
elicit call raises as an MCPError; the tool reports the code and message it caught. The spec
requires -32602 for an undeclared mode (see the divergence note on the requirement). The
request reaching the client also shows the server does not check the client's declared
elicitation capability before sending (see the divergence on `server-respects-mode`).
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="ask", description="Ask the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask"
try:
await ctx.session.elicit_form("Anyone there?", {"type": "object", "properties": {}})
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # elicit_form cannot succeed without a client callback
server = Server("asker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("ask", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Elicitation not supported")]))
@requirement("elicitation:url:action:accept-no-content")
@requirement("elicitation:url:basic")
async def test_elicit_url_delivers_url_and_returns_accept_without_content(connect: Connect) -> None:
"""A URL elicitation delivers the message, URL, and elicitation id to the client; accepting it
returns the action with no content.
Accept means the user agreed to visit the URL, not that the out-of-band interaction finished,
so there is never form content to return.
"""
received: list[types.ElicitRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "authorize"
answer = await ctx.session.elicit_url(
"Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
)
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept")
async with connect(server, elicitation_callback=answer_url) as client:
result = await client.call_tool("authorize", {})
assert received == snapshot(
[
ElicitRequestURLParams(
_meta={},
message="Authorize access to your calendar.",
url="https://example.com/oauth/authorize",
elicitation_id="auth-001",
)
]
)
assert result == snapshot(CallToolResult(content=[TextContent(text="accept content=None")]))
@requirement("elicitation:url:decline")
async def test_elicit_url_decline_returns_no_content(connect: Connect) -> None:
"""A declined URL elicitation returns the decline action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "authorize"
answer = await ctx.session.elicit_url(
"Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
)
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with connect(server, elicitation_callback=answer_url) as client:
result = await client.call_tool("authorize", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")]))
@requirement("elicitation:url:cancel")
async def test_elicit_url_cancel_returns_no_content(connect: Connect) -> None:
"""A cancelled URL elicitation returns the cancel action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "authorize"
answer = await ctx.session.elicit_url(
"Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
)
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with connect(server, elicitation_callback=answer_url) as client:
result = await client.call_tool("authorize", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")]))
@requirement("elicitation:url:complete-notification")
async def test_elicitation_complete_notification_carries_the_elicited_id_back_to_the_client(connect: Connect) -> None:
"""After a URL elicitation finishes, the server announces it with a notification carrying the same id.
The lifecycle under test: the tool elicits a URL interaction with an elicitationId, the user
agrees to visit the URL, the out-of-band interaction finishes, and the server emits
elicitation/complete so the client can correlate the completion with the elicitation it
accepted earlier. The completion notification carries ``related_request_id`` so over
streamable HTTP it rides the tool call's own stream and reaches the client before the call
returns; the same ordering already holds on in-memory and SSE transports.
"""
elicitation_id = "auth-001"
elicited_ids: list[str | None] = []
received: list[IncomingMessage] = []
async def collect(message: IncomingMessage) -> None:
received.append(message)
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="link_account", description="Link an account.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "link_account"
answer = await ctx.session.elicit_url(
"Authorize access to your files.", "https://example.com/oauth/authorize", elicitation_id
)
assert answer.action == "accept"
await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id)
return CallToolResult(content=[TextContent(text="linked")])
server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestURLParams)
elicited_ids.append(params.elicitation_id)
return ElicitResult(action="accept")
async with connect(server, message_handler=collect, elicitation_callback=answer_url) as client:
await client.call_tool("link_account", {})
# The completion notification refers to the same elicitation the client accepted.
assert elicited_ids == [elicitation_id]
assert received == snapshot(
[ElicitCompleteNotification(params=ElicitCompleteNotificationParams(elicitation_id="auth-001"))]
)
@requirement("elicitation:url:required-error")
async def test_url_elicitation_required_error_carries_pending_elicitations(connect: Connect) -> None:
"""A request that cannot proceed until a URL interaction completes is rejected with error -32042.
This is the non-interactive alternative to elicit_url: instead of asking and waiting, the
handler rejects the whole request and lists the required URL elicitations in the error data.
The client is expected to present those URLs, wait for the matching elicitation/complete
notifications, and retry the original request.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "read_files"
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
message="Authorization required for your files.",
url="https://example.com/oauth/authorize",
elicitation_id="auth-001",
)
]
)
server = Server("authorizer", on_call_tool=call_tool)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("read_files", {})
assert exc_info.value.error == snapshot(
ErrorData(
code=-32042,
message="URL elicitation required",
data={
"elicitations": [
{
"mode": "url",
"message": "Authorization required for your files.",
"url": "https://example.com/oauth/authorize",
"elicitationId": "auth-001",
}
]
},
)
)
@requirement("elicitation:form:schema:primitives")
@requirement("elicitation:form:schema:enum-variants")
async def test_elicit_form_schema_with_every_primitive_and_enum_type_reaches_the_callback_as_sent(
connect: Connect,
) -> None:
"""A requested schema covering every spec-listed property kind is delivered to the callback unchanged.
One schema with one property per kind: a formatted string, an integer with bounds, a number,
a boolean, a plain enum, a oneOf-const titled enum, and a multi-select array-of-enum. The
callback observing the same schema as the handler sent proves both the primitive coverage and
the enum-variant coverage in one snapshot.
"""
schema: ElicitRequestedSchema = {
"type": "object",
"properties": {
"email": {"type": "string", "format": "email", "title": "Email", "description": "Contact address."},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"score": {"type": "number"},
"subscribe": {"type": "boolean", "default": False},
"tier": {"type": "string", "enum": ["free", "pro", "team"]},
"region": {
"type": "string",
"oneOf": [
{"const": "eu", "title": "Europe"},
{"const": "na", "title": "North America"},
],
},
"channels": {"type": "array", "items": {"type": "string", "enum": ["email", "sms", "push"]}},
},
"required": ["email"],
}
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="onboard", description="Onboard the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "onboard"
answer = await ctx.session.elicit_form("Tell us about yourself.", schema)
return CallToolResult(content=[TextContent(text=answer.action)])
server = Server("onboarder", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[types.ElicitRequestParams] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"email": "ada@example.com"})
async with connect(server, elicitation_callback=answer_form) as client:
await client.call_tool("onboard", {})
assert len(received) == 1
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].requested_schema == schema
@requirement("elicitation:form:schema:restricted-subset")
async def test_elicit_form_with_a_nested_schema_is_forwarded_unchanged(connect: Connect) -> None:
"""A requested schema with nested-object and array-of-object properties passes through unchanged.
The spec restricts form-mode requested schemas to flat objects with primitive-typed properties;
this test pins that the SDK does not enforce that restriction on either side (see the
divergence on the requirement). The inbound surface gate is deliberately relaxed here so older
servers that emit `anyOf` for `Optional` form fields still reach the elicitation callback.
"""
schema: ElicitRequestedSchema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
},
"contacts": {
"type": "array",
"items": {"type": "object", "properties": {"name": {"type": "string"}}},
},
},
}
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="profile", description="Collect a profile.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "profile"
answer = await ctx.session.elicit_form("Profile details.", schema)
return CallToolResult(content=[TextContent(text=answer.action)])
server = Server("profiler", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[types.ElicitRequestParams] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="decline")
async with connect(server, elicitation_callback=answer_form) as client:
await client.call_tool("profile", {})
assert len(received) == 1
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].requested_schema == schema
@requirement("elicitation:form:response-validation")
async def test_accepted_elicitation_content_that_violates_the_schema_reaches_the_handler_unchanged(
connect: Connect,
) -> None:
"""Accepted form content that contradicts the requested schema is delivered to the handler unchanged.
The schema requires a string `name`; the callback answers with a wrong-type value and an extra
field. Nothing on either side validates the response against the schema (see the divergence on
the requirement), so the handler observes exactly what the callback sent.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="signup", description="Register the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "signup"
answer = await ctx.session.elicit_form(
"Choose a name.",
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
)
return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content)
server = Server("registrar", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"name": 42, "extra": "field"})
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("signup", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="accept")], structured_content={"name": 42, "extra": "field"})
)
@requirement("elicitation:url:complete-unknown-ignored")
async def test_elicitation_complete_for_an_unknown_id_is_received_without_error(connect: Connect) -> None:
"""An elicitation/complete for an id the client never elicited is delivered and does not fail anything.
No URL elicitation precedes the notification; the client neither tracks elicitation ids nor
rejects unknown ones, so the call completes normally and the message handler observes the
notification as-is.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="noop", description="Send a stray complete.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "noop"
await ctx.session.send_elicit_complete("never-elicited", related_request_id=ctx.request_id)
return CallToolResult(content=[TextContent(text="ok")])
server = Server("notifier", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[IncomingMessage] = []
async def collect(message: IncomingMessage) -> None:
received.append(message)
async with connect(server, message_handler=collect) as client:
result = await client.call_tool("noop", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
assert received == snapshot(
[ElicitCompleteNotification(params=ElicitCompleteNotificationParams(elicitation_id="never-elicited"))]
)
@requirement("elicitation:form:mode-omitted-default")
async def test_a_mode_less_elicitation_request_is_treated_as_form_mode() -> None:
"""An elicitation/create request with no mode field reaches the client callback as form-mode.
The typed server API always serializes a mode (`elicit_form` writes 'form', `elicit_url` writes
'url'), so this test plays the server's side of the wire by hand to send a request body without
one. Reserve this pattern for behaviour the typed server API cannot produce.
"""
received: list[types.ElicitRequestParams] = []
answered = anyio.Event()
server_received: list[JSONRPCMessage] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={})
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
initialize = await server_read.receive()
assert isinstance(initialize, SessionMessage)
request = initialize.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="2025-11-25",
capabilities=ServerCapabilities(),
server_info=Implementation(name="legacy", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
initialized = await server_read.receive()
assert isinstance(initialized, SessionMessage)
assert isinstance(initialized.message, JSONRPCNotification)
assert initialized.message.method == "notifications/initialized"
# No mode key: a server speaking a pre-mode revision of the spec sends only message + schema.
await server_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="elicitation/create",
params={"message": "Legacy ask.", "requestedSchema": {"type": "object", "properties": {}}},
)
)
)
response = await server_read.receive()
assert isinstance(response, SessionMessage)
server_received.append(response.message)
answered.set()
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write, elicitation_callback=answer_form) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
await session.initialize()
await answered.wait()
assert received == snapshot(
[
ElicitRequestFormParams(
_meta=None,
message="Legacy ask.",
requested_schema={"type": "object", "properties": {}},
)
]
)
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].mode == "form"
assert len(server_received) == 1
assert isinstance(server_received[0], JSONRPCResponse)
assert server_received[0].id == 2
+204
View File
@@ -0,0 +1,204 @@
"""Composed multi-feature flows against the low-level Server, driven through the public Client API.
Each test reads as the scenario it proves: the steps run top to bottom in the order a real client
would perform them, composing two or more feature areas (a tool call followed by a resource read;
a chain of elicitations inside one tool call; the full URL-elicitation-required retry loop). The
individual features are pinned by their own tests; these prove they compose.
"""
from collections.abc import Awaitable, Callable
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
EmptyResult,
ListToolsResult,
ReadResourceResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
ListToolsHandler = Callable[
[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[types.ListToolsResult]
]
def _list_tools(*names: str) -> ListToolsHandler:
"""A list_tools handler advertising the named tools, so call_tool's implicit list succeeds."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in names])
return list_tools
@requirement("flow:tool-result:resource-link-follow")
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
link's URI, (4) the read result carries the linked contents.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "generate"
return CallToolResult(content=[ResourceLink(uri="file:///report.txt", name="report")])
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
assert str(params.uri) == "file:///report.txt"
return ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
server = Server(
"linker", on_list_tools=_list_tools("generate"), on_call_tool=call_tool, on_read_resource=read_resource
)
async with connect(server) as client:
called = await client.call_tool("generate", {})
link = called.content[0]
assert isinstance(link, ResourceLink)
read = await client.read_resource(link.uri)
assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
assert read == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
)
@requirement("flow:elicitation:multi-step-form")
async def test_a_tool_handler_chains_form_elicitations_feeding_each_answer_forward(connect: Connect) -> None:
"""Sequential form elicitations inside one tool call: each accepted answer feeds the next step.
Steps: (1) call the tool, (2) the handler issues a step-one form elicitation that the client
accepts with content, (3) the handler issues a step-two elicitation whose message references
the step-one answer, (4) the client accepts step two, (5) the tool result summarises both
answers. The callback is invoked exactly twice with the expected messages and schemas. The
short-circuit on decline is the application's choice (proven separately by the per-action
elicitation tests); what this flow pins is that the chain itself works end to end.
"""
received: list[ElicitRequestFormParams] = []
answers: list[dict[str, str | int | float | bool | list[str] | None]] = [{"name": "ada"}, {"age": 37}]
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "onboard"
first = await ctx.session.elicit_form(
"Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}
)
assert first.action == "accept" and first.content is not None
second = await ctx.session.elicit_form(
f"Step 2: confirm age for {first.content['name']}.",
{"type": "object", "properties": {"age": {"type": "integer"}}},
)
assert second.action == "accept" and second.content is not None
return CallToolResult(content=[TextContent(text=f"{first.content['name']} is {second.content['age']}")])
server = Server("onboarder", on_list_tools=_list_tools("onboard"), on_call_tool=call_tool)
async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
received.append(params)
return ElicitResult(action="accept", content=answers[len(received) - 1])
async with connect(server, elicitation_callback=answer) as client:
result = await client.call_tool("onboard", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ada is 37")]))
assert [(p.message, p.requested_schema) for p in received] == snapshot(
[
("Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}),
("Step 2: confirm age for ada.", {"type": "object", "properties": {"age": {"type": "integer"}}}),
]
)
@requirement("flow:elicitation:url-required-then-retry")
async def test_a_tool_rejected_with_url_elicitation_required_succeeds_on_retry_after_completion(
connect: Connect,
) -> None:
"""The full URL-elicitation-required retry loop: -32042, completion announced, retry succeeds.
Steps: (1) the first call is rejected with -32042 carrying the required URL elicitation in
its error data, (2) the client extracts the elicitation id from the error, (3) the server
announces completion via the elicitation/complete notification (driven via the captured
session, the same way a real out-of-band callback would reach a held session reference),
(4) the client observes the matching completion notification and retries, (5) the retry
succeeds. The handler distinguishes the two calls by a closure flag the test flips between
them; the test waits on the completion notification with an event so the retry only happens
after the announcement has arrived.
"""
elicitation_id = "auth-001"
authorised: list[bool] = [False]
captured: list[ServerSession] = []
completed = anyio.Event()
notifications: list[ElicitCompleteNotification] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "read_files"
captured.append(ctx.session)
if not authorised[0]:
# The log line gives the message handler a non-completion notification, so the test's
# filtering branch is exercised in both directions and the wait remains specific.
await ctx.session.send_log_message(level="warning", data="authorisation required", logger="gate") # pyright: ignore[reportDeprecated]
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
message="Authorize file access.",
url="https://example.com/oauth/authorize",
elicitation_id=elicitation_id,
)
]
)
return CallToolResult(content=[TextContent(text="contents")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"gatekeeper",
on_list_tools=_list_tools("read_files"),
on_call_tool=call_tool,
on_set_logging_level=set_logging_level,
)
async def collect(message: IncomingMessage) -> None:
if isinstance(message, ElicitCompleteNotification):
notifications.append(message)
completed.set()
async with connect(server, message_handler=collect) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("read_files", {})
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
required = UrlElicitationRequiredError.from_error(exc_info.value.error)
assert [e.elicitation_id for e in required.elicitations] == [elicitation_id]
# The out-of-band interaction completes; the server announces it on the same session.
await captured[0].send_elicit_complete(elicitation_id)
with anyio.fail_after(5):
await completed.wait()
assert notifications[0].params.elicitation_id == elicitation_id
authorised[0] = True
result = await client.call_tool("read_files", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="contents")]))
@@ -0,0 +1,385 @@
"""Initialization handshake against the low-level Server, driven through the public Client API.
The later tests drive a bare ClientSession over an InMemoryTransport instead: Client always
performs the full handshake with the latest protocol version, so skipping initialization or
requesting a different version can only be expressed one level down. The final test goes one step
further and plays the server's side of the wire by hand, because no real Server can be made to
answer initialize with an unsupported protocol version.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
CallToolResult,
ClientCapabilities,
CompletionsCapability,
EmptyResult,
ErrorData,
Icon,
Implementation,
InitializeRequest,
InitializeRequestParams,
InitializeResult,
JSONRPCRequest,
JSONRPCResponse,
ListToolsRequest,
ListToolsResult,
LoggingCapability,
PromptsCapability,
ResourcesCapability,
ServerCapabilities,
TextContent,
ToolsCapability,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("lifecycle:initialize:basic")
@requirement("lifecycle:initialize:server-info")
async def test_initialize_returns_server_info(connect: Connect) -> None:
"""Every identity field the server declares is returned to the client in server_info."""
server = Server(
"greeter",
version="1.2.3",
title="Greeter",
description="Greets people.",
website_url="https://example.com/greeter",
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
)
async with connect(server) as client:
server_info = client.server_info
assert server_info == snapshot(
Implementation(
name="greeter",
title="Greeter",
description="Greets people.",
version="1.2.3",
website_url="https://example.com/greeter",
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
)
)
@requirement("lifecycle:initialize:instructions")
async def test_initialize_returns_instructions(connect: Connect) -> None:
"""Instructions are returned when the server declares them and omitted when it does not."""
async with connect(Server("guided", instructions="Call the add tool.")) as client:
assert client.instructions == snapshot("Call the add tool.")
async with connect(Server("unguided")) as client:
assert client.instructions is None
@requirement("lifecycle:initialize:capabilities:from-handlers")
@requirement("tools:capability:declared")
@requirement("resources:capability:declared")
@requirement("prompts:capability:declared")
@requirement("completion:capability:declared")
async def test_initialize_capabilities_reflect_registered_handlers(connect: Connect) -> None:
"""Each feature area with a registered handler is advertised as a capability.
The in-memory transport connects with default initialization options, so the
list_changed flags are always False regardless of the server's notification behaviour.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
"""Registered only so the tools capability is advertised; never called."""
raise NotImplementedError
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
"""Registered only so the resources capability is advertised; never called."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> types.EmptyResult:
"""Registered only so the subscribe sub-capability is advertised; never called."""
raise NotImplementedError
async def list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
"""Registered only so the prompts capability is advertised; never called."""
raise NotImplementedError
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> types.EmptyResult:
"""Registered only so the logging capability is advertised; never called."""
raise NotImplementedError
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
"""Registered only so the completions capability is advertised; never called."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"full",
on_list_tools=list_tools,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
on_list_prompts=list_prompts,
on_set_logging_level=set_logging_level,
on_completion=completion,
)
async with connect(server) as client:
capabilities = client.server_capabilities
assert capabilities == snapshot(
ServerCapabilities(
experimental={},
logging=LoggingCapability(),
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=True, list_changed=False),
tools=ToolsCapability(list_changed=False),
completions=CompletionsCapability(),
)
)
@requirement("lifecycle:initialize:capabilities:minimal")
async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None:
"""A server with no feature handlers advertises no feature capabilities."""
async with connect(Server("bare")) as client:
capabilities = client.server_capabilities
assert capabilities == snapshot(ServerCapabilities(experimental={}))
@requirement("lifecycle:initialize:client-info")
async def test_initialize_server_sees_client_info(connect: Connect) -> None:
"""The client identity supplied to Client is visible to server handlers after initialization."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="whoami", description="Report the caller.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "whoami"
assert ctx.session.client_params is not None
client_info = ctx.session.client_params.client_info
return CallToolResult(content=[TextContent(text=f"{client_info.name} {client_info.version}")])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, client_info=Implementation(name="acme-agent", version="9.9.9")) as client:
result = await client.call_tool("whoami", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="acme-agent 9.9.9")]))
@requirement("lifecycle:initialize:client-capabilities")
async def test_initialize_server_sees_client_capabilities(connect: Connect) -> None:
"""The client capabilities visible to the server reflect which callbacks the client configured."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="abilities", description="Report capabilities.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "abilities"
assert ctx.session.client_params is not None
capabilities = ctx.session.client_params.capabilities
declared = [
name
for name, value in (
("sampling", capabilities.sampling),
("elicitation", capabilities.elicitation),
)
if value is not None
]
if capabilities.roots is not None:
declared.append(f"roots(list_changed={capabilities.roots.list_changed})")
return CallToolResult(content=[TextContent(text=",".join(declared) or "none")])
async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
"""Registered only so the client declares the roots capability; never called."""
raise NotImplementedError
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("abilities", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="none")]))
async with connect(server, list_roots_callback=list_roots) as client:
result = await client.call_tool("abilities", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="roots(list_changed=True)")]))
@requirement("lifecycle:requests-before-initialized")
async def test_request_before_initialization_is_rejected() -> None:
"""A feature request sent before the handshake completes is rejected; ping is exempt.
Client always initializes on entry, so this drives a bare ClientSession that never sends
initialize. The server's stated reason for the rejection never reaches the client: the error
is reported as a generic invalid-params failure.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
"""Registered so the request is routed to a real handler; never reached."""
raise NotImplementedError
server = Server("strict", on_list_tools=list_tools)
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.send_request(ListToolsRequest(), ListToolsResult)
# Ping is explicitly permitted before initialization completes.
pong = await session.send_ping()
assert exc_info.value.error == snapshot(
ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
)
assert pong == snapshot(EmptyResult())
@requirement("lifecycle:version:match")
@requirement("lifecycle:version:server-fallback-latest")
async def test_initialize_negotiates_protocol_version() -> None:
"""The server echoes a supported requested version and answers an unsupported one with its latest.
Client always requests the latest version, so each half hand-builds an InitializeRequest on a
bare ClientSession to control the requested version.
"""
server = Server("negotiator")
def initialize_request(protocol_version: str) -> InitializeRequest:
return InitializeRequest(
params=InitializeRequestParams(
protocol_version=protocol_version,
capabilities=ClientCapabilities(),
client_info=Implementation(name="time-traveller", version="0.0.1"),
)
)
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
result = await session.send_request(initialize_request("2025-03-26"), InitializeResult)
assert result.protocol_version == snapshot("2025-03-26")
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
result = await session.send_request(initialize_request("1999-01-01"), InitializeResult)
assert result.protocol_version == snapshot("2025-11-25")
@requirement("lifecycle:version:reject-unsupported")
async def test_unsupported_server_protocol_version_fails_initialization() -> None:
"""An initialize response carrying a protocol version the client does not support fails initialization.
A real Server only ever answers with a version it supports, so this test alone plays the
server's side of the wire by hand: it reads the initialize request off the raw stream and
answers it with a hand-built result. Reserve this pattern for behaviour no real server can
be made to produce.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
message = await server_read.receive()
assert isinstance(message, SessionMessage)
request = message.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="1991-08-06",
capabilities=ServerCapabilities(),
server_info=Implementation(name="relic", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(RuntimeError) as exc_info:
await session.initialize()
assert str(exc_info.value) == snapshot("Unsupported protocol version from the server: 1991-08-06")
@requirement("lifecycle:version:downgrade")
async def test_an_older_supported_protocol_version_from_the_server_is_accepted() -> None:
"""An initialize response carrying an older supported protocol version completes the handshake at that version.
A real Server answers with the version the client requested (or its own latest), so this test
plays the server's side of the wire by hand to return a fixed older version regardless of what
was requested. Reserve this pattern for behaviour no real server can be made to produce.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
message = await server_read.receive()
assert isinstance(message, SessionMessage)
request = message.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="2025-06-18",
capabilities=ServerCapabilities(),
server_info=Implementation(name="conservative", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
initialize_result = await session.initialize()
assert initialize_result.protocol_version == snapshot("2025-06-18")
@@ -0,0 +1,136 @@
"""List-changed notifications from the low-level Server, driven through the public Client API.
``send_*_list_changed`` does not take a ``related_request_id``, so over streamable HTTP the
notification routes to the standalone GET stream and is not guaranteed to arrive before the tool
result on its POST stream. Tests therefore wait on an event the collector sets, the same pattern
as ``transports/test_streamable_http.py::test_unrelated_server_messages_arrive_on_the_standalone_stream``.
The collector still records every message it receives, so the snapshot also proves nothing else
was delivered.
The servers register the parent capability (resources/prompts) so that part of the spec's
precondition holds, but the ``listChanged`` sub-capability stays ``False``: ``NotificationOptions``
is not threaded through any of the suite's connection paths. The tests therefore rely on the
recorded ``lifecycle:capability:server-not-advertised`` divergence and will need updating
alongside the fix that introduces capability gating.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
PromptListChangedNotification,
ResourceListChangedNotification,
TextContent,
ToolListChangedNotification,
)
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("tools:list-changed")
async def test_tool_list_changed_notification(connect: Connect) -> None:
"""A tools/list_changed notification sent during a tool call reaches the client's message handler."""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="install", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "install"
await ctx.session.send_tool_list_changed()
return CallToolResult(content=[TextContent(text="installed")])
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, message_handler=collect) as client:
await client.call_tool("install", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot([ToolListChangedNotification()])
@requirement("resources:list-changed")
async def test_resource_list_changed_notification(connect: Connect) -> None:
"""A resources/list_changed notification sent during a tool call reaches the client's message handler."""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="mount", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "mount"
await ctx.session.send_resource_list_changed()
return CallToolResult(content=[TextContent(text="mounted")])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_resources=list_resources)
async with connect(server, message_handler=collect) as client:
await client.call_tool("mount", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot([ResourceListChangedNotification()])
@requirement("prompts:list-changed")
async def test_prompt_list_changed_notification(connect: Connect) -> None:
"""A prompts/list_changed notification sent during a tool call reaches the client's message handler."""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="learn", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "learn"
await ctx.session.send_prompt_list_changed()
return CallToolResult(content=[TextContent(text="learned")])
async def list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
"""Registered so the prompts capability is advertised; the client never lists prompts."""
raise NotImplementedError
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_prompts=list_prompts)
async with connect(server, message_handler=collect) as client:
await client.call_tool("learn", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot([PromptListChangedNotification()])
+127
View File
@@ -0,0 +1,127 @@
"""Logging interactions against the low-level Server, driven through the public Client API.
Notification ordering: await-free callbacks finish in arrival order, and passing
``related_request_id`` keeps each notification on the originating request's POST stream over
streamable HTTP, so plain-list collection is deterministic on every transport leg.
"""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
ALL_LEVELS: tuple[types.LoggingLevel, ...] = (
"debug",
"info",
"notice",
"warning",
"error",
"critical",
"alert",
"emergency",
)
@requirement("logging:set-level")
async def test_set_logging_level_reaches_handler(connect: Connect) -> None:
"""The level requested by the client is delivered to the server's handler verbatim."""
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
assert params.level == "warning"
return EmptyResult()
server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated]
async with connect(server) as client:
result = await client.set_logging_level("warning") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@requirement("logging:message:fields")
@requirement("tools:call:logging-mid-execution")
async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None:
"""Log messages sent during a tool call arrive at the logging callback, in order, before the call returns.
The two messages pin the full notification shape: severity, optional logger name, and both
string and structured data payloads.
"""
received: list[LoggingMessageNotificationParams] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params)
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="chatty", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "chatty"
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="starting up", logger="app.lifecycle", related_request_id=ctx.request_id
)
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
level="error", data={"code": 502, "retryable": True}, related_request_id=ctx.request_id
)
return CallToolResult(content=[TextContent(text="done")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async with connect(server, logging_callback=collect) as client:
result = await client.call_tool("chatty", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="done")]))
assert received == snapshot(
[
LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"),
LoggingMessageNotificationParams(level="error", data={"code": 502, "retryable": True}),
]
)
@requirement("logging:message:all-levels")
async def test_log_messages_at_every_severity_level(connect: Connect) -> None:
"""Each of the eight RFC 5424 severity levels is deliverable as a log message notification."""
received: list[LoggingMessageNotificationParams] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params)
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="siren", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "siren"
for level in ALL_LEVELS:
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
level=level, data=f"a {level} message", related_request_id=ctx.request_id
)
return CallToolResult(content=[TextContent(text="logged")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async with connect(server, logging_callback=collect) as client:
await client.call_tool("siren", {})
assert [params.level for params in received] == list(ALL_LEVELS)
+63
View File
@@ -0,0 +1,63 @@
"""Request and result _meta round trips against the low-level Server, through the public Client API.
Meta is opaque pass-through data, so these tests assert identity against the value that was sent
rather than snapshotting a literal: the expected value and the sent value are the same variable,
which also proves the SDK injected nothing alongside it.
"""
import mcp_types as types
import pytest
from mcp_types import CallToolResult, RequestParamsMeta, TextContent
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("meta:request-to-handler")
async def test_request_meta_reaches_handler(connect: Connect) -> None:
"""The _meta object the client attaches to a request arrives at the tool handler unchanged."""
request_meta: RequestParamsMeta = {"example.com/trace": "abc-123"}
observed_metas: list[dict[str, object]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="traced", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "traced"
assert ctx.meta is not None
observed_metas.append(dict(ctx.meta))
return CallToolResult(content=[TextContent(text="traced")])
server = Server("observability", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.call_tool("traced", {}, meta=request_meta)
assert observed_metas == [dict(request_meta)]
@requirement("meta:result-to-client")
async def test_result_meta_reaches_client(connect: Connect) -> None:
"""The _meta object a handler attaches to its result is delivered to the client unchanged."""
result_meta = {"example.com/cost": 3}
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="metered", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "metered"
return CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
server = Server("observability", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("metered", {})
assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
@@ -0,0 +1,243 @@
"""Cursor pagination of the list operations against the low-level Server.
The cursor is an opaque string chosen by the server: the suite only asserts that whatever the
handler returns as next_cursor comes back verbatim on the client's next call, not any particular
pagination scheme.
"""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
Prompt,
Resource,
ResourceTemplate,
Tool,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("tools:list:pagination")
async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
back on the following call reaches the handler verbatim.
"""
cursor = "page-2"
seen_cursors: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None # the client always sends params, even without a cursor
seen_cursors.append(params.cursor)
if params.cursor is None:
return ListToolsResult(
tools=[Tool(name="alpha", input_schema={"type": "object"})],
next_cursor=cursor,
)
return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
server = Server("paginated", on_list_tools=list_tools)
async with connect(server) as client:
first_page = await client.list_tools()
second_page = await client.list_tools(cursor=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [tool.name for tool in first_page.tools] == ["alpha"]
assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
@requirement("pagination:exhaustion")
@requirement("tools:list:pagination")
async def test_paginating_until_next_cursor_is_absent_yields_every_page(connect: Connect) -> None:
"""Following next_cursor until it is absent visits every page exactly once, in order."""
pages: dict[str | None, tuple[str, str | None]] = {
None: ("alpha", "page-2"),
"page-2": ("beta", "page-3"),
"page-3": ("gamma", None),
}
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
tool_name, next_cursor = pages[params.cursor]
return ListToolsResult(tools=[Tool(name=tool_name, input_schema={"type": "object"})], next_cursor=next_cursor)
server = Server("paginated", on_list_tools=list_tools)
collected: list[str] = []
cursor: str | None = None
requests_made = 0
async with connect(server) as client:
while True:
result = await client.list_tools(cursor=cursor)
requests_made += 1
assert requests_made <= len(pages), "the server kept returning next_cursor past the last page"
collected.extend(tool.name for tool in result.tools)
if result.next_cursor is None:
break
cursor = result.next_cursor
assert collected == snapshot(["alpha", "beta", "gamma"])
assert requests_made == len(pages)
@requirement("pagination:client:cursor-handling")
async def test_the_client_follows_opaque_cursors_through_pages_of_varying_sizes(connect: Connect) -> None:
"""The client passes a server-issued cursor back byte-for-byte and follows pages of varying sizes.
The cursors are deliberately base64-looking strings (with padding and URL-unsafe characters) to
show the client treats them as opaque tokens; the page sizes [3, 1, 2] show the loop relies only
on next_cursor, not on a fixed page size.
"""
cursor_to_page_2 = "YWxwaGE+YnJhdm8/Y2hhcmxpZQ=="
cursor_to_page_3 = "ZGVsdGE="
pages: dict[str | None, tuple[list[str], str | None]] = {
None: (["alpha", "beta", "gamma"], cursor_to_page_2),
cursor_to_page_2: (["delta"], cursor_to_page_3),
cursor_to_page_3: (["epsilon", "zeta"], None),
}
received_cursors: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
received_cursors.append(params.cursor)
names, next_cursor = pages[params.cursor]
return ListToolsResult(
tools=[Tool(name=name, input_schema={"type": "object"}) for name in names], next_cursor=next_cursor
)
server = Server("paginated", on_list_tools=list_tools)
page_sizes: list[int] = []
cursor: str | None = None
async with connect(server) as client:
while True:
result = await client.list_tools(cursor=cursor)
page_sizes.append(len(result.tools))
if result.next_cursor is None:
break
cursor = result.next_cursor
# Identity, not a snapshot: what arrived at the handler is exactly what the handler issued.
assert received_cursors == [None, cursor_to_page_2, cursor_to_page_3]
assert page_sizes == [3, 1, 2]
@requirement("pagination:invalid-cursor")
async def test_an_unrecognized_pagination_cursor_is_rejected_with_invalid_params(connect: Connect) -> None:
"""A list request with a cursor the server did not issue is answered with -32602 Invalid params.
The lowlevel server does not validate cursors itself (they are opaque to it); rejecting an
unrecognized cursor is the handler's job, and this test pins the spec-recommended way to do it.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
assert params.cursor == "never-issued"
raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}")
server = Server("paginated", on_list_tools=list_tools)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.list_tools(cursor="never-issued")
assert exc_info.value.error.code == INVALID_PARAMS
@requirement("resources:list:pagination")
async def test_resources_list_supports_cursor_pagination(connect: Connect) -> None:
"""resources/list round-trips the cursor like every other list operation."""
cursor = "page-2"
seen_cursors: list[str | None] = []
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
assert params is not None
seen_cursors.append(params.cursor)
if params.cursor is None:
return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor=cursor)
return ListResourcesResult(resources=[Resource(uri="memo://2", name="second")])
server = Server("paginated", on_list_resources=list_resources)
async with connect(server) as client:
first_page = await client.list_resources()
second_page = await client.list_resources(cursor=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [resource.name for resource in first_page.resources] == ["first"]
assert [resource.name for resource in second_page.resources] == ["second"]
assert second_page.next_cursor is None
@requirement("resources:templates:pagination")
async def test_resource_templates_list_supports_cursor_pagination(connect: Connect) -> None:
"""resources/templates/list round-trips the cursor like every other list operation."""
cursor = "page-2"
seen_cursors: list[str | None] = []
async def list_resource_templates(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
assert params is not None
seen_cursors.append(params.cursor)
if params.cursor is None:
return ListResourceTemplatesResult(
resource_templates=[ResourceTemplate(name="first", uri_template="users://{id}")],
next_cursor=cursor,
)
return ListResourceTemplatesResult(
resource_templates=[ResourceTemplate(name="second", uri_template="teams://{id}")]
)
server = Server("paginated", on_list_resource_templates=list_resource_templates)
async with connect(server) as client:
first_page = await client.list_resource_templates()
second_page = await client.list_resource_templates(cursor=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [template.name for template in first_page.resource_templates] == ["first"]
assert [template.name for template in second_page.resource_templates] == ["second"]
assert second_page.next_cursor is None
@requirement("prompts:list:pagination")
async def test_prompts_list_supports_cursor_pagination(connect: Connect) -> None:
"""prompts/list round-trips the cursor like every other list operation."""
cursor = "page-2"
seen_cursors: list[str | None] = []
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
assert params is not None
seen_cursors.append(params.cursor)
if params.cursor is None:
return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor=cursor)
return ListPromptsResult(prompts=[Prompt(name="second")])
server = Server("paginated", on_list_prompts=list_prompts)
async with connect(server) as client:
first_page = await client.list_prompts()
second_page = await client.list_prompts(cursor=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [prompt.name for prompt in first_page.prompts] == ["first"]
assert [prompt.name for prompt in second_page.prompts] == ["second"]
assert second_page.next_cursor is None
+53
View File
@@ -0,0 +1,53 @@
"""Ping interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, TextContent
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("lifecycle:ping")
@requirement("ping:client-to-server")
async def test_client_ping_returns_empty_result(connect: Connect) -> None:
"""A client ping is answered with an empty result, even by a server with no handlers."""
server = Server("silent")
async with connect(server) as client:
result = await client.send_ping() # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@requirement("lifecycle:ping")
@requirement("ping:server-to-client")
async def test_server_ping_returns_empty_result(connect: Connect) -> None:
"""A server-initiated ping sent while a request is in flight is answered by the client.
The tool returns the type of the ping response, proving the round trip completed inside
the handler before the tool result was produced.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="ping_back", description="Ping the client.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ping_back"
pong = await ctx.session.send_ping()
return CallToolResult(content=[TextContent(text=type(pong).__name__)])
server = Server("pinger", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("ping_back", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="EmptyResult")]))
+279
View File
@@ -0,0 +1,279 @@
"""Progress interactions against the low-level Server, driven through the public Client API.
Server-to-client progress emitted during a request follows the same ordering guarantee as
logging notifications (see test_logging.py) -- on the in-memory transport unconditionally, and
over streamable HTTP only when sent with ``related_request_id`` so the notification rides the
originating request's POST stream rather than the standalone GET stream. These tests pass
``related_request_id`` so no synchronisation is needed. The client-to-server direction is a
standalone notification with no response to await, so that test waits on an event set by the
server's handler.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from mcp.shared.session import ProgressFnT
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("protocol:progress:callback")
@requirement("tools:call:progress")
async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None:
"""Progress notifications emitted by a tool handler reach the caller's progress callback in order."""
received: list[tuple[float, float | None, str | None]] = []
async def collect(progress: float, total: float | None, message: str | None) -> None:
received.append((progress, total, message))
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="download", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "download"
await ctx.session.report_progress(1.0, total=3.0, message="first chunk")
await ctx.session.report_progress(2.0, total=3.0, message="second chunk")
await ctx.session.report_progress(3.0, total=3.0, message="done")
return CallToolResult(content=[TextContent(text="downloaded")])
server = Server("downloader", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("download", {}, progress_callback=collect)
assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")])
@requirement("protocol:progress:token-injected")
async def test_progress_token_visible_to_handler(connect: Connect) -> None:
"""Supplying a progress callback attaches a progress token that the handler can read from the request meta."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="inspect", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "inspect"
assert ctx.meta is not None
return CallToolResult(content=[TextContent(text=str(ctx.meta.get("progress_token")))])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async def ignore(progress: float, total: float | None, message: str | None) -> None:
"""A progress callback that is never invoked; the tool only inspects the token."""
raise NotImplementedError
async with connect(server) as client:
result = await client.call_tool("inspect", {}, progress_callback=ignore)
# The token is the request id of the tools/call request itself (initialize is request 1).
assert result == snapshot(CallToolResult(content=[TextContent(text="2")]))
@requirement("protocol:progress:no-token")
async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
"""Without a progress callback the request carries no progress token.
The low-level API has no way to report request-scoped progress without a token, so a handler
that sees no token has nothing to send progress against.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="inspect", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "inspect"
assert ctx.meta is not None
return CallToolResult(content=[TextContent(text=str(ctx.meta.get("progress_token")))])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("inspect", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="None")]))
@requirement("protocol:progress:client-to-server")
async def test_client_progress_notification_reaches_server_handler(connect: Connect) -> None:
"""A progress notification sent by the client is delivered to the server's progress handler."""
received: list[ProgressNotificationParams] = []
delivered = anyio.Event()
async def on_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
received.append(params)
delivered.set()
server = Server("observer", on_progress=on_progress) # pyright: ignore[reportDeprecated]
async with connect(server) as client:
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
await delivered.wait()
assert received == snapshot(
[ProgressNotificationParams(progress_token="upload-1", progress=0.5, total=1.0, message="halfway")]
)
@requirement("protocol:progress:token-unique")
async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Connect) -> None:
"""Two concurrent requests carry distinct progress tokens, and each callback sees only its own progress.
Without the barrier the first call could run to completion before the second starts, so only one
token would be live at a time and the demultiplexing would never be exercised. The handlers each
block until both have started and then hand control back and forth so the four progress
notifications are emitted in strict a, b, a, b order on the wire. The two handlers send different
progress values so a stream swap (request A's progress delivered to callback B and vice versa)
would fail: each callback receiving exactly its own values proves notifications are routed
per-request, not by arrival order or by chance.
"""
progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)}
entered = {"a": anyio.Event(), "b": anyio.Event()}
# turns[n] is set to release the nth emission; each emission releases the next.
turns = [anyio.Event() for _ in range(4)]
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="report", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "report"
assert params.arguments is not None
label = params.arguments["label"]
entered[label].set()
# The two handlers interleave by waiting on alternating turns: a takes 0 and 2, b takes 1 and 3.
first, second = (0, 2) if label == "a" else (1, 3)
await turns[first].wait()
await ctx.session.report_progress(progress_values[label][0])
turns[first + 1].set()
await turns[second].wait()
await ctx.session.report_progress(progress_values[label][1])
if second + 1 < len(turns):
turns[second + 1].set()
return CallToolResult(content=[TextContent(text="done")])
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
received_a: list[float] = []
received_b: list[float] = []
async def collect_a(progress: float, total: float | None, message: str | None) -> None:
received_a.append(progress)
async def collect_b(progress: float, total: float | None, message: str | None) -> None:
received_b.append(progress)
async with connect(server) as client:
async def call(label: str, collect: ProgressFnT) -> None:
await client.call_tool("report", {"label": label}, progress_callback=collect)
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(call, "a", collect_a)
task_group.start_soon(call, "b", collect_b)
await entered["a"].wait()
await entered["b"].wait()
turns[0].set()
assert received_a == [1.0, 2.0]
assert received_b == [10.0, 20.0]
@requirement("protocol:progress:stops-after-completion")
@requirement("protocol:progress:late-dropped-by-client")
async def test_progress_sent_after_the_response_is_not_delivered_to_the_callback(connect: Connect) -> None:
"""A progress notification sent after the response is emitted, and the client drops it from the callback.
This single body proves both halves: the server's `send_progress_notification` happily sends for
a token whose request has already completed (the spec MUST that progress stops is not enforced;
see the divergence on `stops-after-completion`), and the client, having removed the callback when
the call returned, does not deliver the late notification to it. The message handler observes the
late notification arriving so the test knows when to assert without polling.
"""
captured: list[tuple[ServerSession, ProgressToken]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="report", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "report"
assert ctx.meta is not None
token = ctx.meta.get("progress_token")
assert token is not None
captured.append((ctx.session, token))
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
return CallToolResult(content=[TextContent(text="done")])
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[float] = []
late_progress_arrived = anyio.Event()
async def collect(progress: float, total: float | None, message: str | None) -> None:
received.append(progress)
async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, ProgressNotification) and message.params.progress == 1.0:
late_progress_arrived.set()
async with connect(server, message_handler=message_handler) as client:
with anyio.fail_after(5):
await client.call_tool("report", {}, progress_callback=collect)
assert received == [0.5]
server_session, token = captured[0]
await server_session.send_progress_notification(token, 1.0)
await late_progress_arrived.wait()
assert received == [0.5]
@requirement("protocol:progress:monotonic")
async def test_non_increasing_progress_values_are_forwarded_unchanged(connect: Connect) -> None:
"""A handler that emits non-increasing progress values has them forwarded to the callback unchanged.
The spec says progress MUST increase with each notification; the SDK does not enforce that on
either side. See the divergence note on the requirement.
"""
received: list[float] = []
async def collect(progress: float, total: float | None, message: str | None) -> None:
received.append(progress)
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="zigzag", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "zigzag"
await ctx.session.report_progress(0.5)
await ctx.session.report_progress(0.3)
await ctx.session.report_progress(0.9)
return CallToolResult(content=[TextContent(text="done")])
server = Server("zigzagger", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.call_tool("zigzag", {}, progress_callback=collect)
assert received == snapshot([0.5, 0.3, 0.9])
+210
View File
@@ -0,0 +1,210 @@
"""Prompt interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
AudioContent,
EmbeddedResource,
ErrorData,
GetPromptResult,
Icon,
ImageContent,
ListPromptsResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
TextResourceContents,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("prompts:list:basic")
async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None:
"""The prompts returned by the handler reach the client with their argument declarations intact."""
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
return ListPromptsResult(
prompts=[
Prompt(
name="code_review",
description="Review a piece of code.",
arguments=[
PromptArgument(name="code", description="The code to review.", required=True),
PromptArgument(name="style_guide", description="Optional style guide to apply."),
],
icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
),
Prompt(name="daily_standup"),
]
)
server = Server("prompter", on_list_prompts=list_prompts)
async with connect(server) as client:
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
prompts=[
Prompt(
name="code_review",
description="Review a piece of code.",
arguments=[
PromptArgument(name="code", description="The code to review.", required=True),
PromptArgument(name="style_guide", description="Optional style guide to apply."),
],
icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
),
Prompt(name="daily_standup"),
]
)
)
@requirement("prompts:get:with-args")
async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
"""Arguments supplied by the client reach the prompt handler; the templated message comes back."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "greet"
assert params.arguments is not None
return GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text=f"Hello, {params.arguments['name']}!"))],
)
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("greet", {"name": "Ada"})
assert result == snapshot(
GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))],
)
)
@requirement("prompts:get:multi-message")
async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None:
"""A prompt returning a user/assistant conversation reaches the client with roles and order intact."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "geography_quiz"
return GetPromptResult(
messages=[
PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
PromptMessage(role="assistant", content=TextContent(text="The capital of France is Paris.")),
PromptMessage(role="user", content=TextContent(text="And of Italy?")),
]
)
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("geography_quiz")
assert result == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
PromptMessage(role="assistant", content=TextContent(text="The capital of France is Paris.")),
PromptMessage(role="user", content=TextContent(text="And of Italy?")),
]
)
)
@requirement("prompts:get:no-args")
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None:
"""A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "static"
assert params.arguments is None
return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("static")
assert result == snapshot(
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
)
@requirement("prompts:get:content:image")
@requirement("prompts:get:content:audio")
@requirement("prompts:get:content:embedded-resource")
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None:
"""Prompt messages can carry image, audio, and embedded-resource content; all reach the client.
A single full-result snapshot proves all three content types round-trip: each block in the result
is one of the three behaviours under test. Tiny fixed base64 payloads ("aW1n" is b"img", "YXVk"
is b"aud") so the snapshot pins the exact bytes.
"""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "media"
return GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),
PromptMessage(role="assistant", content=AudioContent(data="YXVk", mime_type="audio/wav")),
PromptMessage(
role="user",
content=EmbeddedResource(
resource=TextResourceContents(uri="resource://notes/1", mime_type="text/plain", text="attached")
),
),
]
)
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("media", {})
assert result == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),
PromptMessage(role="assistant", content=AudioContent(data="YXVk", mime_type="audio/wav")),
PromptMessage(
role="user",
content=EmbeddedResource(
resource=TextResourceContents(uri="resource://notes/1", mime_type="text/plain", text="attached")
),
),
]
)
)
@requirement("prompts:get:unknown-name")
async def test_get_prompt_unknown_name_is_protocol_error(connect: Connect) -> None:
"""A handler that rejects an unrecognised prompt name with MCPError produces a JSON-RPC error.
The error's code and message chosen by the handler reach the client verbatim.
"""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {params.name}")
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("nope")
assert exc_info.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Unknown prompt: nope"))
@@ -0,0 +1,317 @@
"""Resource interactions against the low-level Server, driven through the public Client API."""
import base64
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
Annotations,
BlobResourceContents,
CallToolResult,
EmptyResult,
ErrorData,
Icon,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Resource,
ResourceTemplate,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
TextResourceContents,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("resources:list:basic")
@requirement("resources:annotations")
async def test_list_resources_returns_registered_resources(connect: Connect) -> None:
"""Listed resources reach the client with their URIs, names, and optional descriptive fields intact.
The fully-populated entry includes annotations, so the snapshot also proves they round-trip.
The SDK's Annotations model omits the schema's lastModified field (see the divergence on
resources:annotations); the input is built via model_validate with lastModified set so the
snapshot pins the drop and will fail once the SDK adds the field.
"""
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(
resources=[
Resource(uri="memo://minimal", name="minimal"),
Resource(
uri="file:///project/README.md",
name="readme",
title="Project README",
description="The project's front page.",
mime_type="text/markdown",
size=1024,
annotations=Annotations.model_validate(
{"audience": ["user", "assistant"], "priority": 0.8, "lastModified": "2025-01-01T00:00:00Z"}
),
icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
),
]
)
server = Server("library", on_list_resources=list_resources)
async with connect(server) as client:
result = await client.list_resources()
assert result == snapshot(
ListResourcesResult(
resources=[
Resource(uri="memo://minimal", name="minimal"),
Resource(
uri="file:///project/README.md",
name="readme",
title="Project README",
description="The project's front page.",
mime_type="text/markdown",
size=1024,
annotations=Annotations(
audience=["user", "assistant"], priority=0.8, last_modified="2025-01-01T00:00:00Z"
),
icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
),
]
)
)
@requirement("resources:read:text")
async def test_read_resource_text(connect: Connect) -> None:
"""Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[TextResourceContents(uri=params.uri, mime_type="text/plain", text="Hello, world!")]
)
server = Server("library", on_read_resource=read_resource)
async with connect(server) as client:
result = await client.read_resource("file:///greeting.txt")
assert result == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")]
)
)
@requirement("resources:read:blob")
async def test_read_resource_binary(connect: Connect) -> None:
"""Reading a binary resource returns its contents base64-encoded in the blob field."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[
BlobResourceContents(
uri=params.uri,
mime_type="image/png",
blob=base64.b64encode(b"\x89PNG").decode(),
)
]
)
server = Server("library", on_read_resource=read_resource)
async with connect(server) as client:
result = await client.read_resource("file:///pixel.png")
assert result == snapshot(
ReadResourceResult(
contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")]
)
)
@requirement("resources:read:unknown-uri")
async def test_read_resource_unknown_uri_is_protocol_error(connect: Connect) -> None:
"""A handler that rejects an unrecognised URI with MCPError produces a JSON-RPC error.
The spec reserves -32002 for resource-not-found; the code is the handler's choice and reaches
the client verbatim.
"""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
raise MCPError(code=-32002, message=f"Resource not found: {params.uri}")
server = Server("library", on_read_resource=read_resource)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("file:///missing.txt")
assert exc_info.value.error == snapshot(ErrorData(code=-32002, message="Resource not found: file:///missing.txt"))
@requirement("resources:templates:list")
async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None:
"""Listed resource templates reach the client with their URI templates and descriptive fields intact."""
async def list_resource_templates(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(uri_template="users://{user_id}", name="user"),
ResourceTemplate(
uri_template="logs://{service}/{date}",
name="service_logs",
title="Service logs",
description="One day of logs for one service.",
mime_type="text/plain",
icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
),
]
)
server = Server("library", on_list_resource_templates=list_resource_templates)
async with connect(server) as client:
result = await client.list_resource_templates()
assert result == snapshot(
ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(uri_template="users://{user_id}", name="user"),
ResourceTemplate(
uri_template="logs://{service}/{date}",
name="service_logs",
title="Service logs",
description="One day of logs for one service.",
mime_type="text/plain",
icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
),
]
)
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:subscribe")
async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
"""Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result."""
async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
assert params.uri == "file:///watched.txt"
return EmptyResult()
server = Server("library", on_subscribe_resource=subscribe_resource)
async with connect(server) as client:
result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:subscribe:capability-required")
async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None:
"""Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND.
The rejection comes from no handler being registered, not from any capability check; see the
divergence on lifecycle:capability:server-not-advertised.
"""
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
"""Registered only so the resources capability is advertised; never called."""
raise NotImplementedError
server = Server("library", on_list_resources=list_resources)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe")
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:unsubscribe")
async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
"""Unsubscribing from a resource delivers the URI to the server's unsubscribe handler."""
async def unsubscribe_resource(ctx: ServerRequestContext, params: types.UnsubscribeRequestParams) -> EmptyResult:
assert params.uri == "file:///watched.txt"
return EmptyResult()
server = Server("library", on_unsubscribe_resource=unsubscribe_resource)
async with connect(server) as client:
result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@requirement("resources:updated-notification")
async def test_resource_updated_notification_reaches_client(connect: Connect) -> None:
"""A resources/updated notification sent during a tool call reaches the client with the resource URI.
``send_resource_updated`` does not take a ``related_request_id``, so over streamable HTTP the
notification routes to the standalone GET stream and is not guaranteed to arrive before the
tool result; the test waits on an event the collector sets. The collector records every
message the handler receives, so the assertion also proves nothing else was delivered.
"""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="touch", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "touch"
await ctx.session.send_resource_updated("file:///watched.txt")
return CallToolResult(content=[TextContent(text="touched")])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
"""Registered so the resources subscribe sub-capability is advertised; the client never subscribes."""
raise NotImplementedError
server = Server(
"library",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
)
async with connect(server, message_handler=collect) as client:
await client.call_tool("touch", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot(
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
)
+167
View File
@@ -0,0 +1,167 @@
"""Roots interactions against the low-level Server, driven through the public Client API."""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("roots:list:basic")
async def test_list_roots_round_trip(connect: Connect) -> None:
"""A roots/list request from a tool handler is answered by the client's roots callback.
The tool reports the URIs and names it received, proving the client's roots reached the server.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "show_roots"
result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
lines = [f"{root.uri} name={root.name}" for root in result.roots]
return CallToolResult(content=[TextContent(text="\n".join(lines))])
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(
roots=[
Root(uri=FileUrl("file:///home/alice/project"), name="project"),
Root(uri=FileUrl("file:///home/alice/scratch")),
]
)
async with connect(server, list_roots_callback=list_roots) as client:
result = await client.call_tool("show_roots", {})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="file:///home/alice/project name=project\nfile:///home/alice/scratch name=None")]
)
)
@requirement("roots:list:empty")
async def test_list_roots_empty(connect: Connect) -> None:
"""A client with no roots to offer answers roots/list with an empty list, not an error."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="count_roots", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "count_roots"
result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
return CallToolResult(content=[TextContent(text=str(len(result.roots)))])
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(roots=[])
async with connect(server, list_roots_callback=list_roots) as client:
result = await client.call_tool("count_roots", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="0")]))
@requirement("roots:list:not-supported")
async def test_list_roots_without_callback_is_error(connect: Connect) -> None:
"""A roots/list request to a client with no roots callback fails with an error the handler can observe.
The client's default callback answers with INVALID_REQUEST rather than leaving the server
hanging; the spec names -32601 for this case (see the divergence note on the requirement).
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "show_roots"
try:
await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # list_roots cannot succeed without a client callback
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("show_roots", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: List roots not supported")]))
@requirement("roots:list:client-error")
async def test_list_roots_callback_error_surfaces_to_the_handler(connect: Connect) -> None:
"""A roots callback that answers with an error fails the roots/list request with that exact error.
The callback's code and message reach the requesting handler verbatim as an MCPError.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "show_roots"
try:
await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # the callback always answers with an error
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
async def list_roots(context: ClientRequestContext) -> ErrorData:
return ErrorData(code=INTERNAL_ERROR, message="roots provider crashed")
async with connect(server, list_roots_callback=list_roots) as client:
result = await client.call_tool("show_roots", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="-32603: roots provider crashed")]))
@requirement("roots:list-changed")
async def test_roots_list_changed_reaches_server_handler(connect: Connect) -> None:
"""A roots/list_changed notification from the client is delivered to the server's handler.
Unlike a request, a notification has no response to await: the handler sets an event and the
test waits on it, which is the only synchronisation point proving delivery.
"""
delivered = anyio.Event()
received: list[types.NotificationParams | None] = []
async def roots_list_changed(ctx: ServerRequestContext, params: types.NotificationParams | None) -> None:
received.append(params)
delivered.set()
server = Server("rooted", on_roots_list_changed=roots_list_changed) # pyright: ignore[reportDeprecated]
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
"""Registered so the client declares the roots capability; the server never asks for roots."""
raise NotImplementedError
async with connect(server, list_roots_callback=list_roots) as client:
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
await delivered.wait()
assert received == snapshot([types.NotificationParams()])
+688
View File
@@ -0,0 +1,688 @@
"""Sampling interactions against the low-level Server, driven through the public Client API.
Each test nests a sampling/createMessage request inside a tool call: the tool handler calls
ctx.session.create_message(), the client's sampling callback answers it, and the handler
round-trips what it received back to the test through its tool result.
"""
import mcp_types as types
import pydantic
import pytest
from inline_snapshot import snapshot
from mcp_types import (
AudioContent,
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ErrorData,
ImageContent,
ModelHint,
ModelPreferences,
SamplingCapability,
SamplingMessage,
TextContent,
ToolResultContent,
ToolUseContent,
)
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("sampling:create:basic")
@requirement("tools:call:sampling-roundtrip")
async def test_create_message_round_trip(connect: Connect) -> None:
"""A handler's sampling request is answered by the client callback, and the callback's result
(role, content, model, stop reason) is returned to the handler.
"""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=100,
)
assert isinstance(result.content, TextContent)
return CallToolResult(content=[TextContent(text=f"{result.model}/{result.stop_reason}: {result.content.text}")])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
received.append(params)
return CreateMessageResult(
role="assistant",
content=TextContent(text="Hello to you too."),
model="mock-llm-1",
stop_reason="endTurn",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: Hello to you too.")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=100,
)
]
)
@requirement("sampling:create:include-context")
@requirement("sampling:create:model-preferences")
@requirement("sampling:create:system-prompt")
@requirement("sampling:context:server-gated-by-capability")
async def test_create_message_params_reach_callback(connect: Connect) -> None:
"""Every sampling parameter the handler supplies arrives at the client callback unchanged.
The client has not declared the sampling.context capability (Client cannot declare it), yet
include_context="thisServer" reaches the callback regardless: the spec's SHOULD NOT is not
enforced. See the divergence note on `sampling:context:server-gated-by-capability`.
"""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
max_tokens=50,
system_prompt="You are terse.",
include_context="thisServer",
temperature=0.7,
stop_sequences=["\n\n", "END"],
model_preferences=ModelPreferences(
hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
cost_priority=0.2,
speed_priority=0.3,
intelligence_priority=0.9,
),
)
assert isinstance(result.content, TextContent)
return CallToolResult(content=[TextContent(text=result.content.text)])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
received.append(params)
return CreateMessageResult(role="assistant", content=TextContent(text="ok"), model="mock-llm-1")
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
model_preferences=ModelPreferences(
hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
cost_priority=0.2,
speed_priority=0.3,
intelligence_priority=0.9,
),
system_prompt="You are terse.",
include_context="thisServer",
temperature=0.7,
max_tokens=50,
stop_sequences=["\n\n", "END"],
)
]
)
@requirement("sampling:create-message:image-content")
async def test_create_message_request_with_image_content_reaches_callback(connect: Connect) -> None:
"""A sampling request message carrying image content arrives at the client callback intact.
This is the server-to-client direction: the server includes an image in the conversation it
asks the client to sample from.
"""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="describe_image", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "describe_image"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
max_tokens=100,
)
assert isinstance(result.content, TextContent)
return CallToolResult(content=[TextContent(text=result.content.text)])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
received.append(params)
image = params.messages[0].content
assert isinstance(image, ImageContent)
return CreateMessageResult(
role="assistant",
content=TextContent(text=f"described {image.mime_type} ({image.data})"),
model="mock-vision-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("describe_image", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="described image/png (aW1n)")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
max_tokens=100,
)
]
)
@requirement("sampling:create-message:image-content")
async def test_create_message_result_with_image_content_returns_to_handler(connect: Connect) -> None:
"""A sampling result whose content is an image is returned to the requesting handler intact.
This is the client-to-server direction: the model's response is an image rather than text.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="draw", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "draw"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Draw a cat."))],
max_tokens=100,
)
image = result.content
assert isinstance(image, ImageContent)
return CallToolResult(content=[TextContent(text=f"{result.model}: {image.mime_type} {image.data}")])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
return CreateMessageResult(
role="assistant",
content=ImageContent(data="Y2F0", mime_type="image/png"),
model="mock-vision-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("draw", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mock-vision-1: image/png Y2F0")]))
@requirement("sampling:error:user-rejected")
async def test_create_message_callback_error(connect: Connect) -> None:
"""A sampling callback that answers with an error surfaces to the requesting handler as an MCPError.
The error here is the spec's own example for a user rejecting a sampling request (code -1);
the callback's code and message reach the handler verbatim, whatever they are.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=100,
)
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # the callback always answers with an error
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> ErrorData:
return ErrorData(code=-1, message="User rejected sampling request")
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="-1: User rejected sampling request")]))
@requirement("sampling:create-message:not-supported")
async def test_create_message_without_callback_is_error(connect: Connect) -> None:
"""A sampling request to a client with no sampling callback fails with the SDK's default error."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=100,
)
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # create_message cannot succeed without a client callback
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Sampling not supported")]))
@requirement("sampling:tools:server-gated-by-capability")
async def test_create_message_with_tools_is_rejected_for_unsupporting_client(connect: Connect) -> None:
"""A tool-enabled sampling request to a client that has not declared sampling.tools never leaves the server.
The client supports plain sampling but cannot declare the tools sub-capability (Client does not
expose it), so the server-side validator rejects the request before anything reaches the wire.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="What is the weather?"))],
max_tokens=100,
tools=[types.Tool(name="get_weather", input_schema={"type": "object"})],
)
except MCPError as exc:
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
raise NotImplementedError # the validator rejects every tool-enabled request
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
"""Declares the plain sampling capability; never invoked because the request is rejected first."""
raise NotImplementedError
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="-32602: Client does not support sampling tools capability")])
)
@requirement("sampling:tool-result:no-mixed-content")
async def test_create_message_with_mixed_tool_result_content_is_rejected(connect: Connect) -> None:
"""A sampling request whose user message mixes tool_result with other content never leaves the server.
The message-structure validation runs inside create_message before the request is sent, even
when no tools are passed, so the client callback is never invoked and the handler observes the
ValueError directly.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="summarise_tools", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "summarise_tools"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[
SamplingMessage(
role="user",
content=[
ToolResultContent(tool_use_id="call-1", content=[TextContent(text="42")]),
TextContent(text="Also, a comment alongside the result."),
],
)
],
max_tokens=100,
)
except ValueError as exc:
return CallToolResult(content=[TextContent(text=f"{type(exc).__name__}: {exc}")])
raise NotImplementedError # the validator rejects the malformed messages before sending
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
"""Declares the sampling capability; never invoked because the request is rejected first."""
raise NotImplementedError
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("summarise_tools", {})
assert result == snapshot(
CallToolResult(
content=[
TextContent(text="ValueError: The last message must contain only tool_result content if any is present")
]
)
)
@requirement("sampling:capability:declare")
async def test_a_client_with_a_sampling_callback_declares_the_sampling_capability(connect: Connect) -> None:
"""A client connecting with a sampling callback advertises the sampling capability to the server.
Client cannot declare any sub-capabilities (it does not expose ClientSession's
sampling_capabilities parameter), so the snapshot pins an empty SamplingCapability.
"""
captured: list[SamplingCapability | None] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="capabilities", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "capabilities"
assert ctx.session.client_params is not None
captured.append(ctx.session.client_params.capabilities.sampling)
return CallToolResult(content=[TextContent(text="ok")])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
"""Registered only so the sampling capability is advertised; never called."""
raise NotImplementedError
async with connect(server, sampling_callback=sampling_callback) as client:
await client.call_tool("capabilities", {})
assert captured == snapshot([SamplingCapability()])
@requirement("sampling:create-message:audio-content")
async def test_create_message_request_with_audio_content_reaches_callback(connect: Connect) -> None:
"""A sampling request message carrying audio content arrives at the client callback intact.
This is the server-to-client direction: the server includes audio in the conversation it asks
the client to sample from.
"""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="transcribe", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "transcribe"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
max_tokens=100,
)
assert isinstance(result.content, TextContent)
return CallToolResult(content=[TextContent(text=result.content.text)])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
received.append(params)
audio = params.messages[0].content
assert isinstance(audio, AudioContent)
return CreateMessageResult(
role="assistant",
content=TextContent(text=f"transcribed {audio.mime_type} ({audio.data})"),
model="mock-audio-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("transcribe", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="transcribed audio/wav (c25k)")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
max_tokens=100,
)
]
)
@requirement("sampling:create-message:audio-content")
async def test_create_message_result_with_audio_content_returns_to_handler(connect: Connect) -> None:
"""A sampling result whose content is audio is returned to the requesting handler intact.
This is the client-to-server direction: the model's response is audio rather than text.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="speak", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "speak"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello, aloud."))],
max_tokens=100,
)
audio = result.content
assert isinstance(audio, AudioContent)
return CallToolResult(content=[TextContent(text=f"{result.model}: {audio.mime_type} {audio.data}")])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
return CreateMessageResult(
role="assistant",
content=AudioContent(data="aGVsbG8=", mime_type="audio/wav"),
model="mock-audio-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("speak", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mock-audio-1: audio/wav aGVsbG8=")]))
@requirement("sampling:message:content-cardinality")
async def test_create_message_with_list_valued_message_content_reaches_callback(connect: Connect) -> None:
"""A sampling message whose content is a list of blocks arrives at the client callback as a list."""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="caption", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "caption"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[
SamplingMessage(
role="user",
content=[
TextContent(text="Caption this image."),
ImageContent(data="aW1n", mime_type="image/png"),
],
)
],
max_tokens=100,
)
assert isinstance(result.content, TextContent)
return CallToolResult(content=[TextContent(text=result.content.text)])
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
received.append(params)
content = params.messages[0].content
assert isinstance(content, list)
return CreateMessageResult(
role="assistant", content=TextContent(text=f"{len(content)} blocks"), model="mock-llm-1"
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("caption", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="2 blocks")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[
SamplingMessage(
role="user",
content=[
TextContent(text="Caption this image."),
ImageContent(data="aW1n", mime_type="image/png"),
],
)
],
max_tokens=100,
)
]
)
@requirement("sampling:tool-use:server-preflight")
async def test_create_message_with_mismatched_tool_use_and_result_ids_is_rejected(connect: Connect) -> None:
"""A sampling request whose tool_result ids do not match the preceding tool_use ids never leaves the server.
The message-structure validation runs inside create_message before the request is sent, so the
client callback is never invoked and the handler observes the ValueError directly. The spec's
client-side -32602 check is tracked separately at sampling:tool-use:result-balance.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="continue_tools", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "continue_tools"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[
SamplingMessage(
role="assistant",
content=[ToolUseContent(id="call-1", name="weather", input={})],
),
SamplingMessage(
role="user",
content=[ToolResultContent(tool_use_id="call-WRONG", content=[TextContent(text="42")])],
),
],
max_tokens=100,
)
except ValueError as exc:
return CallToolResult(content=[TextContent(text=f"{type(exc).__name__}: {exc}")])
raise NotImplementedError # the validator rejects the malformed messages before sending
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
"""Declares the sampling capability; never invoked because the request is rejected first."""
raise NotImplementedError
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("continue_tools", {})
assert result == snapshot(
CallToolResult(
content=[
TextContent(
text="ValueError: ids of tool_result blocks and tool_use blocks from previous message do not match"
)
]
)
)
@requirement("sampling:result:no-tools-single-content")
async def test_array_content_result_for_a_tool_free_request_surfaces_as_a_validation_error(connect: Connect) -> None:
"""An array-content sampling result for a tool-free request is accepted by the client and fails server-side.
Only the exception type is asserted: the message is pydantic's, which changes across releases.
See the divergence note on the requirement: the intended behaviour is that the client rejects
the result; instead the client accepts it and the server's response parsing raises.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "ask_model"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Two thoughts, please."))],
max_tokens=100,
)
except pydantic.ValidationError as exc:
return CallToolResult(content=[TextContent(text=type(exc).__name__)])
raise NotImplementedError # the array-content result fails server-side parsing every time
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(text="First thought."), TextContent(text="Second thought.")],
model="mock-llm-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask_model", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ValidationError")]))
@@ -0,0 +1,142 @@
"""Client.listen stream endings against lowlevel servers over the connect matrix."""
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp import MCPError
from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("subscriptions:listen:client:graceful-close")
async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None:
"""`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
@requirement("subscriptions:listen:client:lost")
async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None:
"""Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=params.notifications,
_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id},
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
@requirement("protocol:request-id:caller-supplied")
async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None:
"""The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller
while the request is still in flight - the key the server stamps every frame with for demux.
The assertion runs inside the open stream: the ack has arrived but the listen request's
response has not, so the id cannot have come from a response.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
seen: list[types.RequestId] = []
async def recording_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
seen.append(ctx.request_id)
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=recording_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert seen == [sub.subscription_id]
stock.close()
async for _event in sub:
raise NotImplementedError # unreachable: nothing was published
@requirement("subscriptions:listen:client:concurrent-demux")
@requirement("protocol:request-id:caller-supplied")
async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None:
"""Two subscriptions opened concurrently each surface the honored filter of their own request:
ack frames route by subscription id, not broadcast to every open route.
The server gates both acks until both listen requests have arrived, so both client routes are
live and unacknowledged when the first ack lands - a client that broadcast subscription frames
would cross-pollute that ack into both handles.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
arrived: list[types.RequestId] = []
both_arrived = anyio.Event()
async def gated_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
arrived.append(ctx.request_id)
if len(arrived) == 2:
both_arrived.set()
with anyio.fail_after(10):
await both_arrived.wait()
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=gated_listen)
honored: dict[str, types.SubscriptionFilter] = {}
async with connect(server) as client:
async def open_tools() -> None:
async with client.listen(tools_list_changed=True) as sub:
honored["tools"] = sub.honored
async def open_prompts() -> None:
async with client.listen(prompts_list_changed=True) as sub:
honored["prompts"] = sub.honored
with anyio.fail_after(10):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(open_tools)
tg.start_soon(open_prompts)
assert honored == {
"tools": types.SubscriptionFilter(tools_list_changed=True),
"prompts": types.SubscriptionFilter(prompts_list_changed=True),
}
assert len(set(arrived)) == 2
+197
View File
@@ -0,0 +1,197 @@
"""Request timeouts against the low-level Server, driven through the public Client API.
The handler blocks on an event that is never set, so the awaited response can never arrive and
any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero would
also time out immediately, but a tiny positive value keeps the duration visible in the
cancellation reason these tests snapshot.)
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import REQUEST_TIMEOUT, CallToolResult, ErrorData, JSONRPCNotification, TextContent
from trio.testing import MockClock
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.message import SessionMessage
from tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
@requirement("protocol:timeout:basic")
@requirement("protocol:timeout:sends-cancellation")
async def test_request_timeout_fails_the_pending_call() -> None:
"""A request whose response does not arrive within its read timeout fails with a timeout error.
The timeout is followed by notifications/cancelled, which interrupts the server's handler.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
try:
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
# The request was already on the wire: the handler started and was then cancelled.
with anyio.fail_after(5):
await handler_started.wait()
await handler_cancelled.wait()
assert exc_info.value.error == snapshot(
ErrorData(
code=REQUEST_TIMEOUT,
message="Request 'tools/call' timed out",
)
)
@requirement("protocol:timeout:basic")
@requirement("protocol:timeout:sends-cancellation")
async def test_server_request_timeout_sends_cancellation_to_the_client() -> None:
"""A server-initiated request that times out fails server-side and cancels the client's work.
The sampling callback answers only after the server gave up; the late response is discarded.
"""
release = anyio.Event()
callback_started = anyio.Event()
errors: list[ErrorData] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
with pytest.raises(MCPError) as exc_info:
await ctx.session.send_request(request, types.CreateMessageResult, request_read_timeout_seconds=0.000001)
errors.append(exc_info.value.error)
release.set()
return CallToolResult(content=[TextContent(text="gave up")])
server = Server("impatient", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
with anyio.fail_after(5):
await release.wait()
return types.CreateMessageResult(role="assistant", content=TextContent(text="too late"), model="test-model")
async with Client(recording, mode="legacy", sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="gave up")]))
assert callback_started.is_set()
assert errors == snapshot([ErrorData(code=REQUEST_TIMEOUT, message="Request 'sampling/createMessage' timed out")])
cancellations = [
item.message
for item in recording.received
if isinstance(item, SessionMessage)
and isinstance(item.message, JSONRPCNotification)
and item.message.method == "notifications/cancelled"
]
# requestId 1 is the sampling request, the server's first outbound request.
assert [notification.params for notification in cancellations] == snapshot(
[{"requestId": 1, "reason": "timed out after 1e-06s"}]
)
@requirement("protocol:timeout:session-survives")
async def test_session_serves_requests_after_timeout() -> None:
"""A timed-out request does not poison the session: the next request succeeds."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(name="block", input_schema={"type": "object"}),
types.Tool(name="echo", input_schema={"type": "object"}),
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "echo":
return CallToolResult(content=[TextContent(text="still alive")])
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError):
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
# A session-level timeout cannot use the effectively-zero pattern above: it also governs the
# initialize handshake, which must complete before the blocked tool call can wait the timeout
# out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
# that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
# this test runs on trio's virtual clock instead. With autojump, time advances only when every
# task is blocked: the handshake always has a runnable task and therefore cannot time out no
# matter how slow the runner, and once the tool call blocks on the never-answered request the
# run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
@requirement("protocol:timeout:session-default")
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_session_level_timeout_applies_to_every_request() -> None:
"""A read timeout configured on the client applies to requests that do not set their own."""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
async with Client(server, mode="legacy", read_timeout_seconds=0.05) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {})
assert exc_info.value.error == snapshot(
ErrorData(
code=REQUEST_TIMEOUT,
message="Request 'tools/call' timed out",
)
)
+513
View File
@@ -0,0 +1,513 @@
"""Tool interactions against the low-level Server, driven through the public Client API."""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
AudioContent,
CallToolResult,
EmbeddedResource,
ErrorData,
Icon,
ImageContent,
ListToolsResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
ToolAnnotations,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content(connect: Connect) -> None:
"""Arguments reach the tool handler; its content comes back as the call result."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "add"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
server = Server("adder", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
@requirement("tools:call:is-error")
async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None:
"""A tool reporting its own failure with is_error=True reaches the client as a result, not an exception.
Tool execution errors are part of the result so the caller (typically a model) can see
them; only protocol-level failures become JSON-RPC errors.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "flux"
return CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
server = Server("errors", on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("flux", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
)
@requirement("tools:call:unknown-name")
async def test_call_tool_unknown_tool_is_protocol_error(connect: Connect) -> None:
"""A handler that rejects an unrecognised tool name with MCPError produces a JSON-RPC error.
The error's code, message, and data chosen by the handler reach the client verbatim.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {params.name}", data={"requested": params.name})
server = Server("errors", on_call_tool=call_tool)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("nope", {})
assert exc_info.value.error == snapshot(
ErrorData(code=INVALID_PARAMS, message="Unknown tool: nope", data={"requested": "nope"})
)
@requirement("protocol:error:internal-error")
async def test_call_tool_uncaught_exception_becomes_error_response(connect: Connect) -> None:
"""An uncaught exception in the tool handler surfaces to the client as a JSON-RPC error.
The low-level server reports it with code 0 and the exception text as the message; see the
divergence note on the requirement.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "explode"
raise ValueError("boom")
server = Server("errors", on_call_tool=call_tool)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
assert exc_info.value.error == snapshot(ErrorData(code=0, message="boom"))
@requirement("tools:list:basic")
async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
"""The tools advertised by the server's list handler arrive at the client unchanged."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="add",
description="Add two integers.",
input_schema={
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
},
),
Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
]
)
server = Server("calculator", on_list_tools=list_tools)
async with connect(server) as client:
result = await client.list_tools()
assert result == snapshot(
ListToolsResult(
tools=[
Tool(
name="add",
description="Add two integers.",
input_schema={
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
},
),
Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
]
)
)
@requirement("tools:input-schema:json-schema-2020-12")
@requirement("tools:input-schema:preserve-additional-properties")
@requirement("tools:input-schema:preserve-defs")
@requirement("tools:input-schema:preserve-schema-dialect")
async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None:
"""A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable.
The single identity assertion below proves all four pass-through behaviours at once: the same
dict literal that was registered is the dict that arrives, so $schema, $defs, the nested object
property, and additionalProperties are each preserved by virtue of the whole schema being
preserved. The follow-up call proves the rich-schema tool is callable end to end.
"""
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {"positive": {"type": "integer", "exclusiveMinimum": 0}},
"properties": {
"count": {"$ref": "#/$defs/positive"},
"options": {
"type": "object",
"properties": {"verbose": {"type": "boolean"}},
"additionalProperties": False,
},
},
"required": ["count"],
"additionalProperties": False,
}
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="typed", input_schema=schema)])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "typed"
assert params.arguments == {"count": 3, "options": {"verbose": True}}
return CallToolResult(content=[TextContent(text="ok")])
server = Server("typed", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
listed = await client.list_tools()
called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}})
assert listed.tools[0].input_schema == schema
assert called == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("tools:list:metadata")
async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
"""Every optional Tool field the server supplies reaches the client unchanged."""
tool = Tool(
name="annotated",
title="Annotated tool",
description="A tool carrying every optional field.",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
_meta={"example.com/source": "interaction-suite"},
)
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[tool])
server = Server("annotated", on_list_tools=list_tools)
async with connect(server) as client:
result = await client.list_tools()
assert result == snapshot(
ListToolsResult(
tools=[
Tool(
name="annotated",
title="Annotated tool",
description="A tool carrying every optional field.",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
_meta={"example.com/source": "interaction-suite"},
)
]
)
)
@requirement("tools:call:content:mixed")
@requirement("tools:call:content:image")
@requirement("tools:call:content:audio")
@requirement("tools:call:content:resource-link")
@requirement("tools:call:content:embedded-resource")
async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
"""A tool result can mix every content block type; all of them arrive in order.
The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the
snapshot pins the exact bytes the client receives.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="render", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "render"
return CallToolResult(
content=[
TextContent(text="all five content block types"),
ImageContent(data="aW1n", mime_type="image/png"),
AudioContent(data="YXVk", mime_type="audio/wav"),
ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
EmbeddedResource(
resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
),
]
)
server = Server("renderer", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("render", {})
assert result == snapshot(
CallToolResult(
content=[
TextContent(text="all five content block types"),
ImageContent(data="aW1n", mime_type="image/png"),
AudioContent(data="YXVk", mime_type="audio/wav"),
ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
EmbeddedResource(
resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
),
]
)
)
@requirement("tools:call:structured-content")
async def test_call_tool_structured_content(connect: Connect) -> None:
"""A tool result carrying structured content alongside content delivers both to the client."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="sum", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "sum"
return CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})
server = Server("calculator", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("sum", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}))
@requirement("tools:call:concurrent")
async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None:
"""Two tool calls in flight at once run concurrently and each caller gets its own answer.
Both handlers are held on a shared event after signalling that they have started, and the test
only releases them once both signals have arrived -- a server that processed requests
sequentially would never start the second handler and the test would time out instead.
"""
started: list[str] = []
started_events = {"first": anyio.Event(), "second": anyio.Event()}
release = anyio.Event()
results: dict[str, CallToolResult] = {}
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
assert params.arguments is not None
tag = params.arguments["tag"]
assert isinstance(tag, str)
started.append(tag)
started_events[tag].set()
await release.wait()
return CallToolResult(content=[TextContent(text=tag)])
server = Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
async def call_and_record(tag: str) -> None:
results[tag] = await client.call_tool("echo", {"tag": tag})
task_group.start_soon(call_and_record, "first")
task_group.start_soon(call_and_record, "second")
# Both handlers are running at the same time before either is allowed to finish.
await started_events["first"].wait()
await started_events["second"].wait()
release.set()
assert sorted(started) == ["first", "second"]
assert results == snapshot(
{
"first": CallToolResult(content=[TextContent(text="first")]),
"second": CallToolResult(content=[TextContent(text="second")]),
}
)
@requirement("client:output-schema:validate")
async def test_call_tool_structured_content_violating_output_schema_is_rejected_by_the_client(connect: Connect) -> None:
"""A result whose structured content does not conform to the tool's declared output schema never
reaches the caller: the client validates it against the schema cached from tools/list and raises.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="forecast",
input_schema={"type": "object"},
output_schema={
"type": "object",
"properties": {"temperature": {"type": "number"}},
"required": ["temperature"],
},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(content=[TextContent(text="warm")], structured_content={"temperature": "warm"})
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.list_tools()
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("forecast", {})
# The message embeds the jsonschema validation error, so only the SDK-authored prefix is pinned.
assert str(exc_info.value).startswith("Invalid structured content returned by tool forecast")
@requirement("client:output-schema:skip-on-error")
async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None:
"""A tool result with isError true is returned as-is even when its structured content violates the schema.
The schema is cached up front so the client could validate, proving the bypass is specifically the
isError flag and not an empty cache.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="forecast",
input_schema={"type": "object"},
output_schema={
"type": "object",
"properties": {"temperature": {"type": "number"}},
"required": ["temperature"],
},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(
content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True
)
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.list_tools()
result = await client.call_tool("forecast", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True)
)
@requirement("client:output-schema:missing-structured")
async def test_declared_output_schema_with_no_structured_content_is_rejected_by_the_client(connect: Connect) -> None:
"""A tool that declared an output schema but returned no structuredContent fails the client-side check.
The error is the SDK's own message, so the full text is snapshotted.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="forecast",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"temperature": {"type": "number"}}},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(content=[TextContent(text="warm")])
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.list_tools()
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("forecast", {})
assert str(exc_info.value) == snapshot("Tool forecast has an output schema but did not return structured content")
@requirement("client:output-schema:auto-list")
async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None:
"""Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it.
The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the
second call hits the cache and does not. This is the SDK's chosen cache strategy and the cause of
the surprising behaviour where a server with only on_call_tool sees a successful call answered
with METHOD_NOT_FOUND from a request the caller never made; see the divergence on the requirement.
"""
list_calls: list[str] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
list_calls.append("called")
return ListToolsResult(
tools=[
Tool(
name="forecast",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"temperature": {"type": "number"}}},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
first = await client.call_tool("forecast", {})
assert list_calls == ["called"]
second = await client.call_tool("forecast", {})
assert list_calls == ["called"]
assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}))
assert second == first
+365
View File
@@ -0,0 +1,365 @@
"""Wire-level invariants observed at the client's transport boundary.
These behaviours are invisible to API callers -- they are properties of the raw JSON-RPC frames.
The tests wrap the in-memory transport in a RecordingTransport, which tees every message crossing
the transport seam into a list without touching the session, so the assertions hold for whatever
the session implementation sends rather than for what its API returns.
The later tests drive the wire by hand instead: one closes the server-to-client stream while a
request is in flight to pin the connection-closed teardown, and the last two send deliberately
malformed JSON-RPC requests that the typed client API cannot produce.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
EmptyResult,
ErrorData,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListRootsResult,
TextContent,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._helpers import RecordingTransport, _RecordingReadStream
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _echo_server() -> Server:
"""A server with one echo tool, used by every test in this module."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
return Server("wire", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("protocol:request-id:unique")
async def test_request_ids_are_unique_and_never_null() -> None:
"""Every request the client sends carries a distinct, non-null id.
The id sequence is pinned: sequential integers from one, in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy") as client:
await client.list_tools()
await client.call_tool("echo", {})
await client.call_tool("echo", {})
await client.send_ping() # pyright: ignore[reportDeprecated]
sent = [message.message for message in recording.sent]
request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
assert all(request_id is not None for request_id in request_ids)
assert len(request_ids) == len(set(request_ids))
# initialize, tools/list, tools/call, tools/call, ping -- the client does not issue a
# schema-cache refresh here because the explicit tools/list already populated the cache.
assert request_ids == snapshot([1, 2, 3, 4, 5])
@requirement("protocol:notifications:no-response")
async def test_notifications_are_never_answered() -> None:
"""A notification produces no response: everything the server sends back answers a request.
The client sends two notifications (initialized and roots/list_changed) and several requests;
the messages received from the server must be exactly one response per request, each carrying
the id of the request it answers, and nothing else.
"""
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
"""Registered so the client declares the roots capability; the server never asks for roots."""
raise NotImplementedError
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy", list_roots_callback=list_roots) as client:
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
await client.send_ping() # pyright: ignore[reportDeprecated]
sent = [message.message for message in recording.sent]
sent_request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
sent_notifications = [message for message in sent if isinstance(message, JSONRPCNotification)]
received = [message.message for message in recording.received if isinstance(message, SessionMessage)]
received_responses = [message for message in received if isinstance(message, JSONRPCResponse)]
assert len(sent_notifications) == 2 # notifications/initialized and notifications/roots/list_changed
assert len(received_responses) == len(received) # nothing the server sent was anything but a response
assert [message.id for message in received_responses] == sent_request_ids
async def test_recording_read_stream_ends_iteration_when_the_sender_closes() -> None:
"""The recording wrapper preserves the end-of-stream behaviour of the stream it wraps.
This exercises the helper itself rather than an interaction-model behaviour: a transport whose
far end closes must end the client's receive loop cleanly, and the wrapper must not swallow or
mistranslate that.
"""
send_stream, receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1)
log: list[SessionMessage | Exception] = []
async with send_stream, _RecordingReadStream(receive_stream, log) as wrapped:
await send_stream.aclose()
items = [item async for item in wrapped]
assert items == []
assert log == []
@requirement("lifecycle:initialized-notification")
async def test_exactly_one_initialized_notification_is_sent_after_the_handshake() -> None:
"""The client sends initialized exactly once, between the initialize response and its first request.
The full method sequence the client puts on the wire is pinned in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy") as client:
await client.list_tools()
sent_methods = [
message.message.method
for message in recording.sent
if isinstance(message.message, JSONRPCRequest | JSONRPCNotification)
]
assert sent_methods.count("notifications/initialized") == 1
assert sent_methods == snapshot(["initialize", "notifications/initialized", "tools/list"])
@requirement("protocol:error:connection-closed")
async def test_closing_the_transport_fails_in_flight_requests_with_connection_closed() -> None:
"""When the server-to-client stream closes, every in-flight client request fails with CONNECTION_CLOSED.
Driven over a bare ClientSession against a real Server so the test holds the transport stream
pair directly: once the request is in flight (the server handler signals it has started) the
test closes the server's write stream, which ends the client's receive loop and triggers the
teardown that fails the pending request.
"""
handler_started = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
raise NotImplementedError # unreachable: the wait above never completes normally
server = Server("blocker", on_call_tool=call_tool)
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
errors: list[ErrorData] = []
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
async with ClientSession(client_read, client_write) as session:
with anyio.fail_after(5):
await session.initialize()
async def call_and_capture_error() -> None:
with pytest.raises(MCPError) as exc_info:
await session.send_request(
CallToolRequest(params=CallToolRequestParams(name="block")), CallToolResult
)
errors.append(exc_info.value.error)
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(call_and_capture_error)
await handler_started.wait()
await server_write.aclose()
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=CONNECTION_CLOSED, message="Connection closed")])
@requirement("protocol:error:invalid-params")
async def test_malformed_request_params_are_answered_with_invalid_params() -> None:
"""A request whose params fail validation is answered with -32602 Invalid params.
The typed client API cannot construct a request with the wrong parameter types, so the test
plays the client's side of the wire by hand against a real Server: it completes the
initialization handshake at the JSON-RPC layer and then sends a tools/call whose `name` is an
integer. Reserve this pattern for behaviour the typed API cannot produce.
"""
server = Server("strict")
errors: list[ErrorData] = []
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": 42}))
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")])
@requirement("logging:set-level:invalid-level")
async def test_set_level_with_an_unrecognized_value_is_answered_with_invalid_params() -> None:
"""logging/setLevel with a value outside the spec's level enum is answered with -32602 Invalid params.
The typed client API cannot construct a setLevel request with an unrecognized level (pyright and
the client-side model both reject it), so the test plays the client's side of the wire by hand
against a real Server. Reserve this pattern for behaviour the typed API cannot produce.
"""
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; never called -- params validation fails first."""
raise NotImplementedError
server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated]
errors: list[ErrorData] = []
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "loud"})
)
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert len(errors) == 1
assert errors[0].code == INVALID_PARAMS
@requirement("protocol:cancel:stream-frame")
async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_frame() -> None:
"""At 2026-07-28 over a stream (stdio-shaped) wire, abandoning an in-flight call puts exactly
one notifications/cancelled naming that request on the wire, and the frame interrupts the
server-side handler - stream wires keep the frame spelling that 2026 streamable HTTP dropped.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError # registered so tools/call is served; the stream wire never lists
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async with Client(recording, mode="2026-07-28") as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the cancelled call's late error response arrive and be dropped while the client
# is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
call, cancel = [message.message for message in recording.sent]
assert isinstance(call, JSONRPCRequest)
assert call.method == "tools/call"
assert isinstance(cancel, JSONRPCNotification)
assert cancel.method == "notifications/cancelled"
assert cancel.params == {"requestId": call.id, "reason": "caller cancelled"}