"""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")]))