Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998)
This commit is contained in:
parent
3945bdde11
commit
08b62308d4
3 changed files with 1258 additions and 202 deletions
|
|
@ -33,14 +33,16 @@ from mcp_types import (
|
|||
ToolsCapability,
|
||||
)
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client._transport import TransportStreams
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.session import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
|
||||
from mcp.shared.message import SessionMessage
|
||||
from tests.interaction._connect import BASE_URL, mounted_app
|
||||
|
|
@ -513,3 +515,237 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None:
|
|||
Client(server, mode="2025-06-18")
|
||||
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
|
||||
Client(server, mode="not-a-version")
|
||||
|
||||
|
||||
# ── SEP-2322 multi-round-trip auto-loop ────────────────────────────────────────
|
||||
|
||||
|
||||
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
|
||||
|
||||
|
||||
def _name_elicitation(message: str = "What is your name?") -> types.ElicitRequest:
|
||||
return types.ElicitRequest(params=types.ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA))
|
||||
|
||||
|
||||
async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result() -> None:
|
||||
"""When the server returns `InputRequiredResult` carrying an elicitation,
|
||||
`Client.call_tool` routes it to `elicitation_callback` and retries
|
||||
automatically — the caller sees only the terminal `CallToolResult`."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
async def greet(ctx: Context) -> str | types.InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "user_name" in responses:
|
||||
answer = responses["user_name"]
|
||||
assert isinstance(answer, types.ElicitResult)
|
||||
assert answer.content is not None
|
||||
return f"Hello, {answer.content['name']}!"
|
||||
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
|
||||
|
||||
callback_params: list[types.ElicitRequestParams] = []
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
callback_params.append(params)
|
||||
assert context.request_id == "user_name" # the inputRequests key is the request id
|
||||
return types.ElicitResult(action="accept", content={"name": "Ada"})
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("greet")
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
|
||||
)
|
||||
assert len(callback_params) == 1
|
||||
assert isinstance(callback_params[0], types.ElicitRequestFormParams)
|
||||
assert callback_params[0].message == "What is your name?"
|
||||
assert callback_params[0].requested_schema == _NAME_SCHEMA
|
||||
|
||||
|
||||
async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None:
|
||||
"""`InputRequiredResult` with an embedded `CreateMessageRequest` is routed
|
||||
to `sampling_callback` and the call retried with the model's reply."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
async def ask(ctx: Context) -> str | types.InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "q" in responses:
|
||||
answer = responses["q"]
|
||||
assert isinstance(answer, types.CreateMessageResult)
|
||||
assert answer.content.type == "text"
|
||||
return f"Model said: {answer.content.text}"
|
||||
return types.InputRequiredResult(
|
||||
input_requests={
|
||||
"q": types.CreateMessageRequest(
|
||||
params=types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=TextContent(text="Capital of France?"))],
|
||||
max_tokens=10,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
callback_params: list[types.CreateMessageRequestParams] = []
|
||||
|
||||
async def sampling_callback(
|
||||
context: ClientRequestContext, params: types.CreateMessageRequestParams
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
callback_params.append(params)
|
||||
return types.CreateMessageResult(role="assistant", content=TextContent(text="Paris"), model="echo")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, sampling_callback=sampling_callback) as client:
|
||||
result = await client.call_tool("ask")
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
|
||||
)
|
||||
)
|
||||
assert len(callback_params) == 1
|
||||
assert callback_params[0].messages[0].content == TextContent(text="Capital of France?")
|
||||
|
||||
|
||||
async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None:
|
||||
"""`InputRequiredResult` with an embedded `ListRootsRequest` is routed to
|
||||
`list_roots_callback` and the call retried with the returned roots."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
async def count_roots(ctx: Context) -> str | types.InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "roots" in responses:
|
||||
answer = responses["roots"]
|
||||
assert isinstance(answer, types.ListRootsResult)
|
||||
return f"Client exposed {len(answer.roots)} root(s)."
|
||||
return types.InputRequiredResult(input_requests={"roots": types.ListRootsRequest()})
|
||||
|
||||
callback_called: list[ClientRequestContext] = []
|
||||
|
||||
async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
|
||||
callback_called.append(context)
|
||||
return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, list_roots_callback=list_roots_callback) as client:
|
||||
result = await client.call_tool("count_roots")
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="Client exposed 1 root(s).")],
|
||||
structured_content={"result": "Client exposed 1 root(s)."},
|
||||
)
|
||||
)
|
||||
assert len(callback_called) == 1
|
||||
assert callback_called[0].request_id == "roots"
|
||||
|
||||
|
||||
async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_three_rounds() -> None:
|
||||
"""A three-round flow where each `InputRequiredResult.request_state`
|
||||
encodes the round number: the driver echoes it back byte-exact, the server
|
||||
advances per round, and the elicitation callback runs once per round."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
async def multi(ctx: Context) -> str | types.InputRequiredResult:
|
||||
# Round number is the integer the server stashed in `request_state` last leg.
|
||||
round_num = int(ctx.request_state) if ctx.request_state else 0
|
||||
if round_num == 3:
|
||||
return "done after 3 rounds"
|
||||
next_round = round_num + 1
|
||||
return types.InputRequiredResult(
|
||||
input_requests={f"step{next_round}": _name_elicitation(f"Round {next_round}?")},
|
||||
request_state=str(next_round),
|
||||
)
|
||||
|
||||
messages: list[str] = []
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
messages.append(params.message)
|
||||
return types.ElicitResult(action="accept", content={"name": "x"})
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("multi")
|
||||
|
||||
assert result.content == [TextContent(text="done after 3 rounds")]
|
||||
assert messages == ["Round 1?", "Round 2?", "Round 3?"]
|
||||
|
||||
|
||||
async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered() -> None:
|
||||
"""SDK-defined: with no `elicitation_callback`, the default returns
|
||||
`ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError`
|
||||
rather than retrying."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
async def needs_input(ctx: Context) -> str | types.InputRequiredResult:
|
||||
if ctx.input_responses:
|
||||
raise NotImplementedError # unreachable: client errors before retrying
|
||||
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
|
||||
|
||||
async with Client(server) as client:
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.call_tool("needs_input")
|
||||
assert exc.value.error.code == types.INVALID_REQUEST
|
||||
|
||||
|
||||
async def test_get_prompt_auto_loop_resolves_input_required_via_callbacks() -> None:
|
||||
"""`Client.get_prompt` runs the same driver as `call_tool`: an
|
||||
`InputRequiredResult` from `prompts/get` is fulfilled and retried."""
|
||||
|
||||
async def handler(
|
||||
ctx: ServerRequestContext, params: types.GetPromptRequestParams
|
||||
) -> types.GetPromptResult | types.InputRequiredResult:
|
||||
assert params.name == "summary"
|
||||
if params.input_responses and "ask" in params.input_responses:
|
||||
return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])
|
||||
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
|
||||
|
||||
server = Server("test")
|
||||
server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
return types.ElicitResult(action="accept", content={"name": "x"})
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.get_prompt("summary")
|
||||
assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]))
|
||||
|
||||
|
||||
async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None:
|
||||
"""`Client.read_resource` runs the same driver as `call_tool`: an
|
||||
`InputRequiredResult` from `resources/read` is fulfilled and retried."""
|
||||
|
||||
async def handler(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult | types.InputRequiredResult:
|
||||
assert params.uri == "memory://gated"
|
||||
if params.input_responses and "ask" in params.input_responses:
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
|
||||
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
|
||||
|
||||
server = Server("test")
|
||||
server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
return types.ElicitResult(action="accept", content={"name": "x"})
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.read_resource("memory://gated")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
|
||||
)
|
||||
|
|
|
|||
266
tests/client/test_input_required.py
Normal file
266
tests/client/test_input_required.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1662,7 +1662,10 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data()
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_returns_input_required_result_when_server_requests_input() -> None:
|
||||
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
|
||||
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the
|
||||
raw `InputRequiredResult` so the caller can drive the loop manually."""
|
||||
|
||||
# `on_call_tool` is still typed `-> CallToolResult` on this branch (#2967 widens it later);
|
||||
# `add_request_handler` is `HandlerResult`-typed and accepts `InputRequiredResult` cleanly.
|
||||
async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult:
|
||||
|
|
@ -1672,7 +1675,7 @@ async def test_call_tool_returns_input_required_result_when_server_requests_inpu
|
|||
server.add_request_handler("tools/call", types.CallToolRequestParams, handler)
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
result = await client.call_tool("ask", allow_input_required=True)
|
||||
result = await client.session.call_tool("ask", allow_input_required=True)
|
||||
assert isinstance(result, types.InputRequiredResult)
|
||||
assert result.request_state == "s"
|
||||
|
||||
|
|
@ -1703,7 +1706,11 @@ async def test_call_tool_threads_input_responses_and_request_state_into_params()
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_call_tool_raises_on_input_required_without_opt_in() -> None:
|
||||
async def test_session_call_tool_raises_on_input_required_without_opt_in() -> None:
|
||||
"""SDK-defined: `ClientSession.call_tool` is mechanics-only; an
|
||||
`InputRequiredResult` with the default `allow_input_required=False` raises
|
||||
`RuntimeError` (the auto-loop policy lives on `Client`, not here)."""
|
||||
|
||||
async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult:
|
||||
return types.InputRequiredResult(request_state="s")
|
||||
|
||||
|
|
@ -1711,7 +1718,45 @@ async def test_client_call_tool_raises_on_input_required_without_opt_in() -> Non
|
|||
server.add_request_handler("tools/call", types.CallToolRequestParams, handler)
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
with pytest.raises(RuntimeError):
|
||||
await client.call_tool("t")
|
||||
result = await client.call_tool("t", allow_input_required=True)
|
||||
with pytest.raises(RuntimeError, match="allow_input_required=True"):
|
||||
await client.session.call_tool("t")
|
||||
result = await client.session.call_tool("t", allow_input_required=True)
|
||||
assert isinstance(result, types.InputRequiredResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_get_prompt_returns_input_required_result_when_opted_in() -> None:
|
||||
"""`ClientSession.get_prompt` mirrors `call_tool`: opting in returns the
|
||||
raw `InputRequiredResult`; the default raises `RuntimeError`."""
|
||||
|
||||
async def handler(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.InputRequiredResult:
|
||||
return types.InputRequiredResult(request_state="prompt-state")
|
||||
|
||||
server = Server("test")
|
||||
server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
with pytest.raises(RuntimeError, match="allow_input_required=True"):
|
||||
await client.session.get_prompt("p")
|
||||
result = await client.session.get_prompt("p", allow_input_required=True)
|
||||
assert isinstance(result, types.InputRequiredResult)
|
||||
assert result.request_state == "prompt-state"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_read_resource_returns_input_required_result_when_opted_in() -> None:
|
||||
"""`ClientSession.read_resource` mirrors `call_tool`: opting in returns the
|
||||
raw `InputRequiredResult`; the default raises `RuntimeError`."""
|
||||
|
||||
async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> types.InputRequiredResult:
|
||||
return types.InputRequiredResult(request_state="resource-state")
|
||||
|
||||
server = Server("test")
|
||||
server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
with pytest.raises(RuntimeError, match="allow_input_required=True"):
|
||||
await client.session.read_resource("memory://r")
|
||||
result = await client.session.read_resource("memory://r", allow_input_required=True)
|
||||
assert isinstance(result, types.InputRequiredResult)
|
||||
assert result.request_state == "resource-state"
|
||||
|
|
|
|||
Loading…
Reference in a new issue