Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998)

This commit is contained in:
Max 2026-06-26 17:35:23 +02:00 • committed by GitHub
commit 08b62308d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1258 additions and 202 deletions

View file

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

View 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

View file

@ -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"

View file

@ -4,6 +4,7 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
CallToolResult,
CreateMessageRequest,
CreateMessageRequestParams,
@ -14,7 +15,7 @@ from mcp_types import (
TextContent,
)
from docs_src.mrtr import tutorial001, tutorial002
from docs_src.mrtr import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
@ -24,7 +25,7 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDepre
async def test_first_call_returns_an_input_required_result() -> None:
"""tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back."""
async with Client(tutorial001.server) as client:
result = await client.call_tool("provision", {"name": "orders"}, allow_input_required=True)
result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
@ -47,15 +48,22 @@ async def test_first_call_returns_an_input_required_result() -> None:
)
async def test_call_tool_raises_without_the_opt_in() -> None:
"""The page's `!!! check`: `allow_input_required` defaults to `False` and the result is a hard error."""
async def test_the_auto_loop_drives_the_call_to_completion() -> None:
"""tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back."""
async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client:
result = await client.call_tool("provision", {"name": "orders"})
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
)
async def test_the_auto_loop_without_a_callback_raises_mcp_error() -> None:
"""The page's `!!! check`: no `elicitation_callback` means the SDK's stand-in answers with an error."""
async with Client(tutorial001.server) as client:
with pytest.raises(RuntimeError) as exc:
with pytest.raises(MCPError) as exc:
await client.call_tool("provision", {"name": "orders"})
assert str(exc.value) == (
"Server returned InputRequiredResult; pass allow_input_required=True to receive it "
"and retry call_tool(..., input_responses=..., request_state=result.request_state)."
)
assert exc.value.error.code == INVALID_REQUEST
assert exc.value.error.message == "Elicitation not supported"
async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None:
@ -73,7 +81,7 @@ async def test_retry_with_input_responses_and_request_state_completes_the_call()
async def test_the_manual_loop_drives_the_call_to_completion() -> None:
"""tutorial002: `while isinstance(result, InputRequiredResult)` is the whole client API, and it terminates."""
"""tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop."""
async with Client(tutorial001.server) as client:
result = await tutorial002.provision(client, "billing")
assert result == snapshot(
@ -91,7 +99,7 @@ async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None:
"""The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`."""
async with Client(tutorial001.server, mode="legacy") as client:
with pytest.raises(MCPError) as exc:
await client.call_tool("provision", {"name": "orders"}, allow_input_required=True)
await client.call_tool("provision", {"name": "orders"})
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"

View file

@ -1588,7 +1588,7 @@ async def test_tool_returning_input_required_result_reaches_client_unchanged():
with anyio.fail_after(5):
async with Client(mcp, 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, InputRequiredResult)
assert result.request_state == "round-1"
@ -1624,11 +1624,11 @@ async def test_tool_reads_input_responses_and_request_state_from_context_on_retr
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.call_tool("greet", allow_input_required=True)
r1 = await client.session.call_tool("greet", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.call_tool(
r2 = await client.session.call_tool(
"greet",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,

View file

@ -444,5 +444,6 @@ def test_input_required_result_dumps_its_discriminating_tag():
def test_input_required_result_requires_at_least_one_of_input_requests_or_request_state():
with pytest.raises(ValidationError):
InputRequiredResult()
assert InputRequiredResult(input_requests={}).request_state is None
with pytest.raises(ValidationError):
InputRequiredResult(input_requests={})
assert InputRequiredResult(request_state="s").input_requests is None