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
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:
@@ -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
|
||||
@@ -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()])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")]))
|
||||
@@ -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])
|
||||
@@ -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"))]
|
||||
)
|
||||
@@ -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()])
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user