-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtest_input_required.py
More file actions
271 lines (212 loc) · 11.5 KB
/
Copy pathtest_input_required.py
File metadata and controls
271 lines (212 loc) · 11.5 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
"""Unit tests for the SEP-2322 client-side multi-round-trip driver.
`run_input_required_driver` is pure: it takes the first `InputRequiredResult`
plus `dispatch` / `retry` closures and loops until a terminal result. These
tests build those closures by hand (scripted lists, recording lists) so the
driver is exercised without a `ClientSession`. Integration against a real
server lives in `test_client.py`.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
ErrorData,
InputRequest,
InputRequiredResult,
InputResponse,
InputResponses,
TextContent,
)
from trio.testing import MockClock
from mcp import MCPError
from mcp.client._input_required import (
_STATE_ONLY_BACKOFF_CAP_SECONDS,
_STATE_ONLY_BACKOFF_INITIAL_SECONDS,
DEFAULT_INPUT_REQUIRED_MAX_ROUNDS,
InputRequiredRoundsExceededError,
run_input_required_driver,
)
pytestmark = pytest.mark.anyio
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
def _elicit(message: str = "What is your name?") -> ElicitRequest:
schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema))
async def _never_dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
"""Dispatch closure for tests whose script never carries `input_requests`."""
raise NotImplementedError
async def test_single_round_dispatches_then_retries_to_terminal_result() -> None:
"""One `InputRequiredResult` with one elicit request: dispatch runs once,
retry runs once with the collected response, and the terminal result is returned."""
first = InputRequiredResult(input_requests={"ask": _elicit()})
terminal = CallToolResult(content=[TextContent(text="done")])
dispatched: list[tuple[str, InputRequest]] = []
retried: list[tuple[InputResponses | None, str | None]] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched.append((key, req))
return ElicitResult(action="accept", content={"name": "Ada"})
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return terminal
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert result is terminal
assert first.input_requests is not None
assert dispatched == [("ask", first.input_requests["ask"])]
assert retried == [({"ask": ElicitResult(action="accept", content={"name": "Ada"})}, None)]
async def test_multi_round_loops_until_retry_returns_non_input_required() -> None:
"""Two consecutive `InputRequiredResult` legs followed by a terminal result:
the driver dispatches and retries each leg in order."""
terminal = CallToolResult(content=[TextContent(text="done")])
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(input_requests={"b": _elicit("second?")}),
terminal,
]
retried: list[tuple[InputResponses | None, str | None]] = []
dispatched_keys: list[str] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched_keys.append(key)
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return script.pop(0)
first = InputRequiredResult(input_requests={"a": _elicit("first?")})
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=5)
assert result is terminal
assert dispatched_keys == ["a", "b"]
assert retried == snapshot(
[
({"a": ElicitResult(action="decline")}, None),
({"b": ElicitResult(action="decline")}, None),
]
)
async def test_exceeding_max_rounds_raises_with_the_configured_cap() -> None:
"""When every retry returns another `InputRequiredResult`, the driver gives
up after `max_rounds` retries with `InputRequiredRoundsExceededError`."""
rounds: list[int] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
rounds.append(len(rounds))
return InputRequiredResult(input_requests={"again": _elicit()})
first = InputRequiredResult(input_requests={"again": _elicit()})
with anyio.fail_after(5):
with pytest.raises(InputRequiredRoundsExceededError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.max_rounds == 3
# `first` counts as round 1; rounds 1-3 each retry, round 4 trips the cap before dispatching.
assert len(rounds) == 3
async def test_dispatch_returning_error_data_aborts_the_loop_as_mcp_error() -> None:
"""SDK-defined: a callback that refuses an embedded request returns
`ErrorData`; the driver surfaces it as `MCPError` rather than retrying."""
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ErrorData(code=INVALID_REQUEST, message="not supported")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
raise NotImplementedError # unreachable: dispatch errored before any retry
first = InputRequiredResult(input_requests={"ask": _elicit()})
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.error.code == INVALID_REQUEST
async def test_request_state_passes_through_byte_identical() -> None:
"""`request_state` is opaque to the driver: each leg's value reaches `retry`
as the same object the server sent, never parsed or rebuilt."""
states = ['{"round": 1, "tag": "héllo"}', '{"round": 2, "tag": "wörld"}']
received_states: list[str | None] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received_states.append(state)
if len(received_states) < 2:
return InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[1])
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[0])
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert received_states[0] is states[0]
assert received_states[1] is states[1]
# Runs on trio's autojumping virtual clock so the backoff sleeps add zero
# wall-clock and the recorded deltas are exact: `anyio.sleep` advances the
# MockClock by precisely the requested duration once every task is idle.
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_state_only_legs_back_off_exponentially_to_the_cap() -> None:
"""SDK-defined pacing: state-only legs sleep 50ms, 100ms, 200ms, then cap at
250ms. Six state-only rounds → deltas `[0.05, 0.1, 0.2, 0.25, 0.25, 0.25]`."""
retry_times: list[float] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
assert responses is None
if len(retry_times) == 6:
return CallToolResult(content=[])
return InputRequiredResult(request_state="poll")
start = anyio.current_time()
first = InputRequiredResult(request_state="poll")
await run_input_required_driver(first, dispatch=_never_dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
assert deltas == snapshot([0.05, 0.1, 0.2, 0.25, 0.25, 0.25])
assert _STATE_ONLY_BACKOFF_INITIAL_SECONDS == 0.05
assert _STATE_ONLY_BACKOFF_CAP_SECONDS == 0.25
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_backoff_counter_resets_after_a_leg_with_input_requests() -> None:
"""A leg carrying `input_requests` resets `consecutive_state_only`: the
next state-only leg sleeps the initial 50ms again, not the prior position."""
# state-only, state-only, dispatch leg (no sleep), state-only, terminal.
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(request_state="s"),
InputRequiredResult(input_requests={"k": _elicit()}),
InputRequiredResult(request_state="s"),
CallToolResult(content=[]),
]
retry_times: list[float] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
return script.pop(0)
start = anyio.current_time()
first = InputRequiredResult(request_state="s")
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
# 0.05, 0.1 (two state-only), 0.0 (dispatch leg has no sleep), 0.05 (reset).
assert deltas == snapshot([0.05, 0.1, 0.0, 0.05])
async def test_input_requests_are_dispatched_concurrently() -> None:
"""All `input_requests` in a round are dispatched together: each dispatch
blocks on a shared gate that only opens once every key has started, so a
sequential implementation would deadlock under the `fail_after`."""
keys = ["a", "b", "c"]
started: set[str] = set()
all_started = anyio.Event()
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
started.add(key)
if started == set(keys):
all_started.set()
await all_started.wait() # blocks until every sibling is in-flight
return ElicitResult(action="accept", content={"name": key})
received: list[InputResponses | None] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received.append(responses)
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={k: _elicit() for k in keys})
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=2)
assert received[0] is not None
assert received[0] == {k: ElicitResult(action="accept", content={"name": k}) for k in keys}
def test_default_max_rounds_constant() -> None:
"""SDK-defined default; matches the typescript-sdk."""
assert DEFAULT_INPUT_REQUIRED_MAX_ROUNDS == 10