forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_progress.py
More file actions
280 lines (212 loc) · 13.2 KB
/
Copy pathtest_progress.py
File metadata and controls
280 lines (212 loc) · 13.2 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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""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 mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
from mcp.client import IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import ProgressFnT
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
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, unstamped: Unstamp) -> 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"
await ctx.session.report_progress(1.0, total=3.0, message="first chunk")
await ctx.session.report_progress(2.0, total=3.0, message="second chunk")
await ctx.session.report_progress(3.0, total=3.0, message="done")
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 unstamped(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 1).
assert result == snapshot(CallToolResult(content=[TextContent(text="2")]))
@requirement("protocol:progress:no-token")
async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> 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 unstamped(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) # pyright: ignore[reportDeprecated]
async with connect(server) as client:
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated]
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 (request A's progress delivered to callback B and vice versa)
would fail: each callback receiving exactly its own values proves notifications are routed
per-request, not by arrival order or by chance.
"""
progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)}
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
label = params.arguments["label"]
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.report_progress(progress_values[label][0])
turns[first + 1].set()
await turns[second].wait()
await ctx.session.report_progress(progress_values[label][1])
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 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"
await ctx.session.report_progress(0.5)
await ctx.session.report_progress(0.3)
await ctx.session.report_progress(0.9)
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])