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
205 lines
9.5 KiB
Python
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")]))
|