test: interaction-model end-to-end suite with a requirements manifest (#2691)
This commit is contained in:
parent
3eb579948a
commit
24725633f1
19 changed files with 14355 additions and 121 deletions
0
tests/interaction/lowlevel/__init__.py
Normal file
0
tests/interaction/lowlevel/__init__.py
Normal file
234
tests/interaction/lowlevel/test_cancellation.py
Normal file
234
tests/interaction/lowlevel/test_cancellation.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""Cancellation interactions against the low-level Server, driven through the public Client API.
|
||||
|
||||
There is no client-side cancellation API: cancelling means sending a CancelledNotification
|
||||
carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so
|
||||
these tests capture the id from inside the blocked handler before cancelling. The handler blocks
|
||||
on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
|
||||
"""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import 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 mcp.types import (
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
PingRequest,
|
||||
ServerCapabilities,
|
||||
TextContent,
|
||||
)
|
||||
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:late-response-ignored")
|
||||
async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_handler() -> None:
|
||||
"""A response whose id matches no in-flight request is surfaced to the message handler as a RuntimeError.
|
||||
|
||||
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 depending on the cancellation API the SDK does not yet provide.
|
||||
See the divergence note on the requirement.
|
||||
|
||||
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 answer with a fabricated id that matches nothing in flight, then the real id.
|
||||
await server_write.send(respond(9999, EmptyResult()))
|
||||
await server_write.send(respond(ping.message.id, EmptyResult()))
|
||||
|
||||
incoming: list[IncomingMessage] = []
|
||||
|
||||
async def message_handler(message: IncomingMessage) -> None:
|
||||
incoming.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())
|
||||
assert len(incoming) == 1
|
||||
assert isinstance(incoming[0], RuntimeError)
|
||||
# The full message embeds the response object's repr; only the prefix is stable.
|
||||
assert str(incoming[0]).startswith("Received response with an unknown request ID:")
|
||||
131
tests/interaction/lowlevel/test_completion.py
Normal file
131
tests/interaction/lowlevel/test_completion.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Completion interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
INVALID_PARAMS,
|
||||
METHOD_NOT_FOUND,
|
||||
CompleteResult,
|
||||
Completion,
|
||||
ErrorData,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
)
|
||||
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.initialize_result.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"))
|
||||
662
tests/interaction/lowlevel/test_elicitation.py
Normal file
662
tests/interaction/lowlevel/test_elicitation.py
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, UrlElicitationRequiredError, types
|
||||
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 mcp.types import (
|
||||
CallToolResult,
|
||||
ElicitCompleteNotification,
|
||||
ElicitCompleteNotificationParams,
|
||||
ElicitRequestedSchema,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
JSONRPCMessage,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ServerCapabilities,
|
||||
TextContent,
|
||||
)
|
||||
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] = []
|
||||
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": {
|
||||
"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).
|
||||
"""
|
||||
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
|
||||
203
tests/interaction/lowlevel/test_flows.py
Normal file
203
tests/interaction/lowlevel/test_flows.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, UrlElicitationRequiredError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.types import (
|
||||
URL_ELICITATION_REQUIRED,
|
||||
CallToolResult,
|
||||
ElicitCompleteNotification,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
EmptyResult,
|
||||
ListToolsResult,
|
||||
ReadResourceResult,
|
||||
ResourceLink,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
Tool,
|
||||
)
|
||||
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")
|
||||
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(
|
||||
"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")]))
|
||||
384
tests/interaction/lowlevel/test_initialize.py
Normal file
384
tests/interaction/lowlevel/test_initialize.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
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 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 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.initialize_result.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.initialize_result.instructions == snapshot("Call the add tool.")
|
||||
|
||||
async with connect(Server("unguided")) as client:
|
||||
assert client.initialize_result.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(
|
||||
"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.initialize_result.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.initialize_result.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")
|
||||
136
tests/interaction/lowlevel/test_list_changed.py
Normal file
136
tests/interaction/lowlevel/test_list_changed.py
Normal file
|
|
@ -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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
PromptListChangedNotification,
|
||||
ResourceListChangedNotification,
|
||||
TextContent,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._helpers import IncomingMessage
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("tools:list-changed")
|
||||
async def test_tool_list_changed_notification(connect: Connect) -> None:
|
||||
"""A tools/list_changed notification sent during a tool call reaches the client's message handler."""
|
||||
received: list[IncomingMessage] = []
|
||||
seen = anyio.Event()
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
seen.set()
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="install", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "install"
|
||||
await ctx.session.send_tool_list_changed()
|
||||
return CallToolResult(content=[TextContent(text="installed")])
|
||||
|
||||
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server, message_handler=collect) as client:
|
||||
await client.call_tool("install", {})
|
||||
with anyio.fail_after(5):
|
||||
await seen.wait()
|
||||
|
||||
assert received == snapshot([ToolListChangedNotification()])
|
||||
|
||||
|
||||
@requirement("resources:list-changed")
|
||||
async def test_resource_list_changed_notification(connect: Connect) -> None:
|
||||
"""A resources/list_changed notification sent during a tool call reaches the client's message handler."""
|
||||
received: list[IncomingMessage] = []
|
||||
seen = anyio.Event()
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
seen.set()
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="mount", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "mount"
|
||||
await ctx.session.send_resource_list_changed()
|
||||
return CallToolResult(content=[TextContent(text="mounted")])
|
||||
|
||||
async def list_resources(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListResourcesResult:
|
||||
"""Registered so the resources capability is advertised; the client never lists resources."""
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_resources=list_resources)
|
||||
|
||||
async with connect(server, message_handler=collect) as client:
|
||||
await client.call_tool("mount", {})
|
||||
with anyio.fail_after(5):
|
||||
await seen.wait()
|
||||
|
||||
assert received == snapshot([ResourceListChangedNotification()])
|
||||
|
||||
|
||||
@requirement("prompts:list-changed")
|
||||
async def test_prompt_list_changed_notification(connect: Connect) -> None:
|
||||
"""A prompts/list_changed notification sent during a tool call reaches the client's message handler."""
|
||||
received: list[IncomingMessage] = []
|
||||
seen = anyio.Event()
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
seen.set()
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="learn", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "learn"
|
||||
await ctx.session.send_prompt_list_changed()
|
||||
return CallToolResult(content=[TextContent(text="learned")])
|
||||
|
||||
async def list_prompts(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListPromptsResult:
|
||||
"""Registered so the prompts capability is advertised; the client never lists prompts."""
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_prompts=list_prompts)
|
||||
|
||||
async with connect(server, message_handler=collect) as client:
|
||||
await client.call_tool("learn", {})
|
||||
with anyio.fail_after(5):
|
||||
await seen.wait()
|
||||
|
||||
assert received == snapshot([PromptListChangedNotification()])
|
||||
127
tests/interaction/lowlevel/test_logging.py
Normal file
127
tests/interaction/lowlevel/test_logging.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Logging interactions against the low-level Server, driven through the public Client API.
|
||||
|
||||
Notification ordering: the in-memory transport delivers every server-to-client message on one
|
||||
ordered stream, and the client's receive loop dispatches each incoming message to completion
|
||||
before reading the next one. Over streamable HTTP that ordered single-stream guarantee holds
|
||||
only for messages that carry a ``related_request_id`` (they ride the originating request's POST
|
||||
stream); without it the message routes to the standalone GET stream and may arrive after the
|
||||
response. These tests pass ``related_request_id`` so they can collect into a plain list and
|
||||
assert after the request completes on every transport leg -- no events, no waiting.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
|
||||
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)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.set_logging_level("warning")
|
||||
|
||||
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(
|
||||
level="info", data="starting up", logger="app.lifecycle", related_request_id=ctx.request_id
|
||||
)
|
||||
await ctx.session.send_log_message(
|
||||
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("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(
|
||||
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("logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level)
|
||||
|
||||
async with connect(server, logging_callback=collect) as client:
|
||||
await client.call_tool("siren", {})
|
||||
|
||||
assert [params.level for params in received] == list(ALL_LEVELS)
|
||||
63
tests/interaction/lowlevel/test_meta.py
Normal file
63
tests/interaction/lowlevel/test_meta.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Request and result _meta round trips against the low-level Server, through the public Client API.
|
||||
|
||||
Meta is opaque pass-through data, so these tests assert identity against the value that was sent
|
||||
rather than snapshotting a literal: the expected value and the sent value are the same variable,
|
||||
which also proves the SDK injected nothing alongside it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import CallToolResult, RequestParamsMeta, TextContent
|
||||
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)
|
||||
242
tests/interaction/lowlevel/test_pagination.py
Normal file
242
tests/interaction/lowlevel/test_pagination.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
INVALID_PARAMS,
|
||||
ListPromptsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ListToolsResult,
|
||||
Prompt,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Tool,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("tools:list:pagination")
|
||||
async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
|
||||
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
|
||||
back on the following call reaches the handler verbatim.
|
||||
"""
|
||||
cursor = "page-2"
|
||||
seen_cursors: list[str | None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
assert params is not None # the client always sends params, even without a cursor
|
||||
seen_cursors.append(params.cursor)
|
||||
if params.cursor is None:
|
||||
return ListToolsResult(
|
||||
tools=[Tool(name="alpha", input_schema={"type": "object"})],
|
||||
next_cursor=cursor,
|
||||
)
|
||||
return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("paginated", on_list_tools=list_tools)
|
||||
|
||||
async with connect(server) as client:
|
||||
first_page = await client.list_tools()
|
||||
second_page = await client.list_tools(cursor=first_page.next_cursor)
|
||||
|
||||
assert first_page.next_cursor == cursor
|
||||
assert seen_cursors == [None, cursor]
|
||||
assert [tool.name for tool in first_page.tools] == ["alpha"]
|
||||
assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
|
||||
|
||||
|
||||
@requirement("pagination:exhaustion")
|
||||
@requirement("tools:list:pagination")
|
||||
async def test_paginating_until_next_cursor_is_absent_yields_every_page(connect: Connect) -> None:
|
||||
"""Following next_cursor until it is absent visits every page exactly once, in order."""
|
||||
pages: dict[str | None, tuple[str, str | None]] = {
|
||||
None: ("alpha", "page-2"),
|
||||
"page-2": ("beta", "page-3"),
|
||||
"page-3": ("gamma", None),
|
||||
}
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
assert params is not None
|
||||
tool_name, next_cursor = pages[params.cursor]
|
||||
return ListToolsResult(tools=[Tool(name=tool_name, input_schema={"type": "object"})], next_cursor=next_cursor)
|
||||
|
||||
server = Server("paginated", on_list_tools=list_tools)
|
||||
|
||||
collected: list[str] = []
|
||||
cursor: str | None = None
|
||||
requests_made = 0
|
||||
async with connect(server) as client:
|
||||
while True:
|
||||
result = await client.list_tools(cursor=cursor)
|
||||
requests_made += 1
|
||||
assert requests_made <= len(pages), "the server kept returning next_cursor past the last page"
|
||||
collected.extend(tool.name for tool in result.tools)
|
||||
if result.next_cursor is None:
|
||||
break
|
||||
cursor = result.next_cursor
|
||||
|
||||
assert collected == snapshot(["alpha", "beta", "gamma"])
|
||||
assert requests_made == len(pages)
|
||||
|
||||
|
||||
@requirement("pagination:client:cursor-handling")
|
||||
async def test_the_client_follows_opaque_cursors_through_pages_of_varying_sizes(connect: Connect) -> None:
|
||||
"""The client passes a server-issued cursor back byte-for-byte and follows pages of varying sizes.
|
||||
|
||||
The cursors are deliberately base64-looking strings (with padding and URL-unsafe characters) to
|
||||
show the client treats them as opaque tokens; the page sizes [3, 1, 2] show the loop relies only
|
||||
on next_cursor, not on a fixed page size.
|
||||
"""
|
||||
cursor_to_page_2 = "YWxwaGE+YnJhdm8/Y2hhcmxpZQ=="
|
||||
cursor_to_page_3 = "ZGVsdGE="
|
||||
pages: dict[str | None, tuple[list[str], str | None]] = {
|
||||
None: (["alpha", "beta", "gamma"], cursor_to_page_2),
|
||||
cursor_to_page_2: (["delta"], cursor_to_page_3),
|
||||
cursor_to_page_3: (["epsilon", "zeta"], None),
|
||||
}
|
||||
received_cursors: list[str | None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
assert params is not None
|
||||
received_cursors.append(params.cursor)
|
||||
names, next_cursor = pages[params.cursor]
|
||||
return ListToolsResult(
|
||||
tools=[Tool(name=name, input_schema={"type": "object"}) for name in names], next_cursor=next_cursor
|
||||
)
|
||||
|
||||
server = Server("paginated", on_list_tools=list_tools)
|
||||
|
||||
page_sizes: list[int] = []
|
||||
cursor: str | None = None
|
||||
async with connect(server) as client:
|
||||
while True:
|
||||
result = await client.list_tools(cursor=cursor)
|
||||
page_sizes.append(len(result.tools))
|
||||
if result.next_cursor is None:
|
||||
break
|
||||
cursor = result.next_cursor
|
||||
|
||||
# Identity, not a snapshot: what arrived at the handler is exactly what the handler issued.
|
||||
assert received_cursors == [None, cursor_to_page_2, cursor_to_page_3]
|
||||
assert page_sizes == [3, 1, 2]
|
||||
|
||||
|
||||
@requirement("pagination:invalid-cursor")
|
||||
async def test_an_unrecognized_pagination_cursor_is_rejected_with_invalid_params(connect: Connect) -> None:
|
||||
"""A list request with a cursor the server did not issue is answered with -32602 Invalid params.
|
||||
|
||||
The lowlevel server does not validate cursors itself (they are opaque to it); rejecting an
|
||||
unrecognized cursor is the handler's job, and this test pins the spec-recommended way to do it.
|
||||
"""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
assert params is not None
|
||||
assert params.cursor == "never-issued"
|
||||
raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}")
|
||||
|
||||
server = Server("paginated", on_list_tools=list_tools)
|
||||
|
||||
async with connect(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.list_tools(cursor="never-issued")
|
||||
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
@requirement("resources:list:pagination")
|
||||
async def test_resources_list_supports_cursor_pagination(connect: Connect) -> None:
|
||||
"""resources/list round-trips the cursor like every other list operation."""
|
||||
cursor = "page-2"
|
||||
seen_cursors: list[str | None] = []
|
||||
|
||||
async def list_resources(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> ListResourcesResult:
|
||||
assert params is not None
|
||||
seen_cursors.append(params.cursor)
|
||||
if params.cursor is None:
|
||||
return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor=cursor)
|
||||
return ListResourcesResult(resources=[Resource(uri="memo://2", name="second")])
|
||||
|
||||
server = Server("paginated", on_list_resources=list_resources)
|
||||
|
||||
async with connect(server) as client:
|
||||
first_page = await client.list_resources()
|
||||
second_page = await client.list_resources(cursor=first_page.next_cursor)
|
||||
|
||||
assert first_page.next_cursor == cursor
|
||||
assert seen_cursors == [None, cursor]
|
||||
assert [resource.name for resource in first_page.resources] == ["first"]
|
||||
assert [resource.name for resource in second_page.resources] == ["second"]
|
||||
assert second_page.next_cursor is None
|
||||
|
||||
|
||||
@requirement("resources:templates:pagination")
|
||||
async def test_resource_templates_list_supports_cursor_pagination(connect: Connect) -> None:
|
||||
"""resources/templates/list round-trips the cursor like every other list operation."""
|
||||
cursor = "page-2"
|
||||
seen_cursors: list[str | None] = []
|
||||
|
||||
async def list_resource_templates(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> ListResourceTemplatesResult:
|
||||
assert params is not None
|
||||
seen_cursors.append(params.cursor)
|
||||
if params.cursor is None:
|
||||
return ListResourceTemplatesResult(
|
||||
resource_templates=[ResourceTemplate(name="first", uri_template="users://{id}")],
|
||||
next_cursor=cursor,
|
||||
)
|
||||
return ListResourceTemplatesResult(
|
||||
resource_templates=[ResourceTemplate(name="second", uri_template="teams://{id}")]
|
||||
)
|
||||
|
||||
server = Server("paginated", on_list_resource_templates=list_resource_templates)
|
||||
|
||||
async with connect(server) as client:
|
||||
first_page = await client.list_resource_templates()
|
||||
second_page = await client.list_resource_templates(cursor=first_page.next_cursor)
|
||||
|
||||
assert first_page.next_cursor == cursor
|
||||
assert seen_cursors == [None, cursor]
|
||||
assert [template.name for template in first_page.resource_templates] == ["first"]
|
||||
assert [template.name for template in second_page.resource_templates] == ["second"]
|
||||
assert second_page.next_cursor is None
|
||||
|
||||
|
||||
@requirement("prompts:list:pagination")
|
||||
async def test_prompts_list_supports_cursor_pagination(connect: Connect) -> None:
|
||||
"""prompts/list round-trips the cursor like every other list operation."""
|
||||
cursor = "page-2"
|
||||
seen_cursors: list[str | None] = []
|
||||
|
||||
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
|
||||
assert params is not None
|
||||
seen_cursors.append(params.cursor)
|
||||
if params.cursor is None:
|
||||
return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor=cursor)
|
||||
return ListPromptsResult(prompts=[Prompt(name="second")])
|
||||
|
||||
server = Server("paginated", on_list_prompts=list_prompts)
|
||||
|
||||
async with connect(server) as client:
|
||||
first_page = await client.list_prompts()
|
||||
second_page = await client.list_prompts(cursor=first_page.next_cursor)
|
||||
|
||||
assert first_page.next_cursor == cursor
|
||||
assert seen_cursors == [None, cursor]
|
||||
assert [prompt.name for prompt in first_page.prompts] == ["first"]
|
||||
assert [prompt.name for prompt in second_page.prompts] == ["second"]
|
||||
assert second_page.next_cursor is None
|
||||
53
tests/interaction/lowlevel/test_ping.py
Normal file
53
tests/interaction/lowlevel/test_ping.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Ping interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import CallToolResult, EmptyResult, TextContent
|
||||
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()
|
||||
|
||||
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")]))
|
||||
301
tests/interaction/lowlevel/test_progress.py
Normal file
301
tests/interaction/lowlevel/test_progress.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.session import ProgressFnT
|
||||
from mcp.types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
|
||||
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"
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 1.0, total=3.0, message="first chunk", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 2.0, total=3.0, message="second chunk", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 3.0, total=3.0, message="done", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
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 0).
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="1")]))
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
async with connect(server) as client:
|
||||
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway")
|
||||
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 (token A delivered to callback B and vice versa) would fail: each
|
||||
callback receiving exactly its own values proves notifications are routed by token, not by arrival
|
||||
order or by chance.
|
||||
"""
|
||||
progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)}
|
||||
tokens: dict[str, ProgressToken] = {}
|
||||
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
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
label = params.arguments["label"]
|
||||
tokens[label] = token
|
||||
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.send_progress_notification(
|
||||
token, progress_values[label][0], related_request_id=str(ctx.request_id)
|
||||
)
|
||||
turns[first + 1].set()
|
||||
await turns[second].wait()
|
||||
await ctx.session.send_progress_notification(
|
||||
token, progress_values[label][1], related_request_id=str(ctx.request_id)
|
||||
)
|
||||
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 tokens["a"] != tokens["b"]
|
||||
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"
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
|
||||
await ctx.session.send_progress_notification(token, 0.3, related_request_id=str(ctx.request_id))
|
||||
await ctx.session.send_progress_notification(token, 0.9, related_request_id=str(ctx.request_id))
|
||||
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])
|
||||
209
tests/interaction/lowlevel/test_prompts.py
Normal file
209
tests/interaction/lowlevel/test_prompts.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Prompt interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
INVALID_PARAMS,
|
||||
AudioContent,
|
||||
EmbeddedResource,
|
||||
ErrorData,
|
||||
GetPromptResult,
|
||||
Icon,
|
||||
ImageContent,
|
||||
ListPromptsResult,
|
||||
Prompt,
|
||||
PromptArgument,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
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"))
|
||||
309
tests/interaction/lowlevel/test_resources.py
Normal file
309
tests/interaction/lowlevel/test_resources.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Resource interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import base64
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
METHOD_NOT_FOUND,
|
||||
Annotations,
|
||||
BlobResourceContents,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
Icon,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
ResourceUpdatedNotification,
|
||||
ResourceUpdatedNotificationParams,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
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),
|
||||
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"])],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
assert result == snapshot(EmptyResult())
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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"))]
|
||||
)
|
||||
166
tests/interaction/lowlevel/test_roots.py
Normal file
166
tests/interaction/lowlevel/test_roots.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Roots interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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)
|
||||
|
||||
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()
|
||||
with anyio.fail_after(5):
|
||||
await delivered.wait()
|
||||
|
||||
assert received == snapshot([None])
|
||||
687
tests/interaction/lowlevel/test_sampling.py
Normal file
687
tests/interaction/lowlevel/test_sampling.py
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
"""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 pydantic
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
CallToolResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
ErrorData,
|
||||
ImageContent,
|
||||
ModelHint,
|
||||
ModelPreferences,
|
||||
SamplingCapability,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
ToolResultContent,
|
||||
ToolUseContent,
|
||||
)
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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")]))
|
||||
114
tests/interaction/lowlevel/test_timeouts.py
Normal file
114
tests/interaction/lowlevel/test_timeouts.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""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. The timeout is therefore
|
||||
set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself
|
||||
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
|
||||
"""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, TextContent
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
No cancellation is sent to the server (see the divergence note on the requirement): the handler
|
||||
starts and is still running after the caller has already given up. The test waits for the
|
||||
handler to have started only after the timeout has fired, so the timeout itself races nothing.
|
||||
"""
|
||||
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 the session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) 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 still runs even though the caller gave up.
|
||||
with anyio.fail_after(5):
|
||||
await handler_started.wait()
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=REQUEST_TIMEOUT,
|
||||
message="Timed out while waiting for response to CallToolRequest. Waited 1e-06 seconds.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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 session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) 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")]))
|
||||
|
||||
|
||||
@requirement("protocol:timeout:session-default")
|
||||
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 session is torn down
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
# The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
|
||||
# per-request timeouts: a session-level timeout also governs the initialize handshake, so the
|
||||
# value must be long enough for the in-process handshake to complete before the blocked tool
|
||||
# call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
|
||||
# latency; lowering it only erodes the margin against CI scheduler jitter without saving
|
||||
# anything perceptible.
|
||||
async with Client(server, 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="Timed out while waiting for response to CallToolRequest. Waited 0.05 seconds.",
|
||||
)
|
||||
)
|
||||
512
tests/interaction/lowlevel/test_tools.py
Normal file
512
tests/interaction/lowlevel/test_tools.py
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
"""Tool interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
INVALID_PARAMS,
|
||||
AudioContent,
|
||||
CallToolResult,
|
||||
EmbeddedResource,
|
||||
ErrorData,
|
||||
Icon,
|
||||
ImageContent,
|
||||
ListToolsResult,
|
||||
ResourceLink,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
Tool,
|
||||
ToolAnnotations,
|
||||
)
|
||||
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
|
||||
309
tests/interaction/lowlevel/test_wire.py
Normal file
309
tests/interaction/lowlevel/test_wire.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
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 mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INVALID_PARAMS,
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ListRootsResult,
|
||||
TextContent,
|
||||
)
|
||||
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 zero, in send order.
|
||||
"""
|
||||
recording = RecordingTransport(InMemoryTransport(_echo_server()))
|
||||
|
||||
async with Client(recording) as client:
|
||||
await client.list_tools()
|
||||
await client.call_tool("echo", {})
|
||||
await client.call_tool("echo", {})
|
||||
await client.send_ping()
|
||||
|
||||
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([0, 1, 2, 3, 4])
|
||||
|
||||
|
||||
@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, list_roots_callback=list_roots) as client:
|
||||
await client.send_roots_list_changed()
|
||||
await client.send_ping()
|
||||
|
||||
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) 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)
|
||||
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
|
||||
Loading…
Reference in a new issue