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

205 lines
9.5 KiB
Python

"""Composed multi-feature flows against the low-level Server, driven through the public Client API.
Each test reads as the scenario it proves: the steps run top to bottom in the order a real client
would perform them, composing two or more feature areas (a tool call followed by a resource read;
a chain of elicitations inside one tool call; the full URL-elicitation-required retry loop). The
individual features are pinned by their own tests; these prove they compose.
"""
from collections.abc import Awaitable, Callable
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
EmptyResult,
ListToolsResult,
ReadResourceResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
ListToolsHandler = Callable[
[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[types.ListToolsResult]
]
def _list_tools(*names: str) -> ListToolsHandler:
"""A list_tools handler advertising the named tools, so call_tool's implicit list succeeds."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in names])
return list_tools
@requirement("flow:tool-result:resource-link-follow")
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
link's URI, (4) the read result carries the linked contents.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "generate"
return CallToolResult(content=[ResourceLink(uri="file:///report.txt", name="report")])
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
assert str(params.uri) == "file:///report.txt"
return ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
server = Server(
"linker", on_list_tools=_list_tools("generate"), on_call_tool=call_tool, on_read_resource=read_resource
)
async with connect(server) as client:
called = await client.call_tool("generate", {})
link = called.content[0]
assert isinstance(link, ResourceLink)
read = await client.read_resource(link.uri)
assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
assert read == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
)
@requirement("flow:elicitation:multi-step-form")
async def test_a_tool_handler_chains_form_elicitations_feeding_each_answer_forward(connect: Connect) -> None:
"""Sequential form elicitations inside one tool call: each accepted answer feeds the next step.
Steps: (1) call the tool, (2) the handler issues a step-one form elicitation that the client
accepts with content, (3) the handler issues a step-two elicitation whose message references
the step-one answer, (4) the client accepts step two, (5) the tool result summarises both
answers. The callback is invoked exactly twice with the expected messages and schemas. The
short-circuit on decline is the application's choice (proven separately by the per-action
elicitation tests); what this flow pins is that the chain itself works end to end.
"""
received: list[ElicitRequestFormParams] = []
answers: list[dict[str, str | int | float | bool | list[str] | None]] = [{"name": "ada"}, {"age": 37}]
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "onboard"
first = await ctx.session.elicit_form(
"Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}
)
assert first.action == "accept" and first.content is not None
second = await ctx.session.elicit_form(
f"Step 2: confirm age for {first.content['name']}.",
{"type": "object", "properties": {"age": {"type": "integer"}}},
)
assert second.action == "accept" and second.content is not None
return CallToolResult(content=[TextContent(text=f"{first.content['name']} is {second.content['age']}")])
server = Server("onboarder", on_list_tools=_list_tools("onboard"), on_call_tool=call_tool)
async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
received.append(params)
return ElicitResult(action="accept", content=answers[len(received) - 1])
async with connect(server, elicitation_callback=answer) as client:
result = await client.call_tool("onboard", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ada is 37")]))
assert [(p.message, p.requested_schema) for p in received] == snapshot(
[
("Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}),
("Step 2: confirm age for ada.", {"type": "object", "properties": {"age": {"type": "integer"}}}),
]
)
@requirement("flow:elicitation:url-required-then-retry")
async def test_a_tool_rejected_with_url_elicitation_required_succeeds_on_retry_after_completion(
connect: Connect,
) -> None:
"""The full URL-elicitation-required retry loop: -32042, completion announced, retry succeeds.
Steps: (1) the first call is rejected with -32042 carrying the required URL elicitation in
its error data, (2) the client extracts the elicitation id from the error, (3) the server
announces completion via the elicitation/complete notification (driven via the captured
session, the same way a real out-of-band callback would reach a held session reference),
(4) the client observes the matching completion notification and retries, (5) the retry
succeeds. The handler distinguishes the two calls by a closure flag the test flips between
them; the test waits on the completion notification with an event so the retry only happens
after the announcement has arrived.
"""
elicitation_id = "auth-001"
authorised: list[bool] = [False]
captured: list[ServerSession] = []
completed = anyio.Event()
notifications: list[ElicitCompleteNotification] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "read_files"
captured.append(ctx.session)
if not authorised[0]:
# The log line gives the message handler a non-completion notification, so the test's
# filtering branch is exercised in both directions and the wait remains specific.
await ctx.session.send_log_message(level="warning", data="authorisation required", logger="gate") # pyright: ignore[reportDeprecated]
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
message="Authorize file access.",
url="https://example.com/oauth/authorize",
elicitation_id=elicitation_id,
)
]
)
return CallToolResult(content=[TextContent(text="contents")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"gatekeeper",
on_list_tools=_list_tools("read_files"),
on_call_tool=call_tool,
on_set_logging_level=set_logging_level,
)
async def collect(message: IncomingMessage) -> None:
if isinstance(message, ElicitCompleteNotification):
notifications.append(message)
completed.set()
async with connect(server, message_handler=collect) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("read_files", {})
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
required = UrlElicitationRequiredError.from_error(exc_info.value.error)
assert [e.elicitation_id for e in required.elicitations] == [elicitation_id]
# The out-of-band interaction completes; the server announces it on the same session.
await captured[0].send_elicit_complete(elicitation_id)
with anyio.fail_after(5):
await completed.wait()
assert notifications[0].params.elicitation_id == elicitation_id
authorised[0] = True
result = await client.call_tool("read_files", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="contents")]))