-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtest_flows.py
More file actions
208 lines (171 loc) · 9.59 KB
/
Copy pathtest_flows.py
File metadata and controls
208 lines (171 loc) · 9.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""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, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
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, unstamped: Unstamp
) -> 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 unstamped(called) == snapshot(
CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")])
)
assert unstamped(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")]))