Skip to content

Commit 2819ee3

Browse files
committed
Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322)
Client.call_tool/get_prompt/read_resource now resolve InputRequiredResult automatically by dispatching the embedded sampling/elicitation/roots input_requests to the same callbacks that already serve legacy server-to-client RPCs, then retrying with the collected input_responses + echoed request_state until a terminal result arrives. allow_input_required is removed from Client; manual control is via client.session.<method>(..., allow_input_required=True). - New client/_input_required.py: pure method-agnostic driver. max_rounds=10 default; state-only legs back off 50ms doubling to 250ms cap (counter resets on any leg with input_requests). request_state passed through byte-exact. InputRequiredRoundsExceededError on cap; MCPError when a callback returns ErrorData. - ClientSession: extracted _dispatch_input_request from the _on_request match so the legacy RPC path and the driver share one dispatch table. get_prompt/read_resource gain the allow_input_required overload set. - Client: input_required_max_rounds field; shared _drive_input_required helper; call_tool/get_prompt/read_resource collapse to a single signature returning the bare result type. - examples/stories/mrtr promoted from deferred stub to runnable. - Conformance fixture sep-2322-client-request-state rewritten to drive the same five wire-shape checks via the auto-loop. - docs/advanced/multi-round-trip.md + docs_src/mrtr/tutorial003.py updated.
1 parent 5b2713d commit 2819ee3

23 files changed

Lines changed: 1243 additions & 198 deletions

File tree

.github/actions/conformance/client.py

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
2323
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
2424
elicitation-sep1034-client-defaults - Elicitation with default accept callback
25-
sep-2322-client-request-state - Drive the manual MRTR retry surface
25+
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
2626
auth/client-credentials-jwt - Client credentials with private_key_jwt
2727
auth/client-credentials-basic - Client credentials with client_secret_basic
2828
auth/* - Authorization code flow (default for auth scenarios)
@@ -374,46 +374,28 @@ async def run_elicitation_defaults(server_url: str) -> None:
374374

375375
@register("sep-2322-client-request-state")
376376
async def run_mrtr_client(server_url: str) -> None:
377-
"""Drive the manual MRTR retry surface against the SEP-2322 client mock.
378-
379-
The mock speaks the modern lifecycle (server/discover, no initialize) and
380-
inspects the wire params of each tools/call round, so this exercises the
381-
explicit allow_input_required=True path rather than an auto-loop: round 1
382-
receives an InputRequiredResult, the fixture fulfils the elicitation
383-
locally, then round 2 retries with input_responses + the echoed
384-
request_state. Passing request_state straight off the typed result -- a
385-
str when the server sent one, None when it didn't -- lets the
386-
serializer's exclude_none drop the key in the no-state case without a
387-
branch here. The unrelated call between rounds proves MRTR params don't
388-
leak across tools, and the no-result-type call must parse as a complete
389-
CallToolResult with no retry.
377+
"""Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop.
378+
379+
The mock inspects raw `tools/call` params, so registering an
380+
`elicitation_callback` and letting the driver run is enough to satisfy
381+
all five wire-shape checks: the driver echoes `request_state` byte-exact
382+
and omits it when the server sent none, every retry mints a fresh
383+
JSON-RPC id, the unrelated call between auto-loops carries no MRTR
384+
params, and the no-`resultType` response parses as a terminal
385+
`CallToolResult` so the driver never retries it.
390386
"""
391-
async with Client(server_url, mode=client_mode()) as client:
392-
await client.list_tools()
393-
confirm = {"confirm": types.ElicitResult(action="accept", content={"confirmed": True})}
394387

395-
r1 = await client.call_tool("test_mrtr_echo_state", {}, allow_input_required=True)
396-
assert isinstance(r1, types.InputRequiredResult)
397-
398-
await client.call_tool("test_mrtr_unrelated", {})
388+
async def confirm(
389+
context: ClientRequestContext, params: types.ElicitRequestParams
390+
) -> types.ElicitResult | types.ErrorData:
391+
return types.ElicitResult(action="accept", content={"confirmed": True})
399392

400-
await client.call_tool(
401-
"test_mrtr_echo_state",
402-
{},
403-
input_responses=confirm,
404-
request_state=r1.request_state,
405-
allow_input_required=True,
406-
)
393+
async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client:
394+
await client.list_tools()
407395

408-
r2 = await client.call_tool("test_mrtr_no_state", {}, allow_input_required=True)
409-
assert isinstance(r2, types.InputRequiredResult)
410-
await client.call_tool(
411-
"test_mrtr_no_state",
412-
{},
413-
input_responses=confirm,
414-
request_state=r2.request_state,
415-
allow_input_required=True,
416-
)
396+
await client.call_tool("test_mrtr_echo_state", {})
397+
await client.call_tool("test_mrtr_unrelated", {})
398+
await client.call_tool("test_mrtr_no_state", {})
417399

418400
result = await client.call_tool("test_mrtr_no_result_type", {})
419401
assert isinstance(result, types.CallToolResult)

docs/advanced/multi-round-trip.md

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,40 +33,37 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
3333

3434
## The client side
3535

36-
`call_tool` will not hand you an `InputRequiredResult` unless you opt in.
36+
`Client` runs the loop for you.
3737

38-
!!! check
39-
Call a tool that needs input without opting in and `call_tool` raises:
38+
Register the callbacks the server might ask for (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) and call the tool. When an `InputRequiredResult` arrives, `Client` dispatches each entry in `input_requests` to the matching callback, retries with the answers and the echoed `request_state`, and keeps going until a `CallToolResult` comes back:
4039

41-
```text
42-
Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state).
43-
```
40+
```python title="client.py" hl_lines="12 13"
41+
--8<-- "docs_src/mrtr/tutorial003.py"
42+
```
4443

45-
That is deliberate. Most call sites expect a result or an exception, not a third thing in the
46-
middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return
47-
a plain `CallToolResult`.
44+
* That `elicitation_callback` is the same one a pre-2026 server's back-channel `elicitation/create` would have hit. One callback serves both eras.
45+
* `call_tool` returns a plain `CallToolResult`. The intermediate rounds are invisible to the caller.
46+
* `get_prompt` and `read_resource` drive the same loop.
4847

49-
Pass `allow_input_required=True` and the result reaches you intact:
48+
!!! check
49+
Leave the callback off and the loop fails on the first round: the SDK's stand-in callback
50+
answers every elicitation with an error, and `call_tool` raises `MCPError` with the message
51+
*"Elicitation not supported"*.
5052

51-
```python
52-
result.result_type # 'input_required'
53-
result.request_state # 'provision-v1'
54-
result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))}
55-
```
53+
The loop is bounded. `Client(..., input_required_max_rounds=10)` is the default cap; a server that keeps returning `InputRequiredResult` past it makes `call_tool` raise. If a round carries only `request_state` and no `input_requests`, `Client` sleeps briefly (50ms doubling to a 250ms ceiling) before retrying, so a server that is just saying *"not done yet"* isn't busy-polled.
5654

57-
### The retry loop
55+
### Driving the loop yourself
5856

59-
Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API:
57+
The auto-loop holds nothing between calls. If you need to see each round (to persist `request_state` across a process restart, to show the user what was asked, to bail early) drop to the underlying session, where `allow_input_required=True` hands you the union directly:
6058

61-
```python title="client.py" hl_lines="13-15 17-20"
59+
```python title="client.py" hl_lines="13 14 20"
6260
--8<-- "docs_src/mrtr/tutorial002.py"
6361
```
6462

65-
* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing.
63+
* `client.session.call_tool(..., allow_input_required=True)` widens the return type to `CallToolResult | InputRequiredResult`. The `isinstance` is what narrows it back.
64+
* `request_state` is now in your hands. Write it down between legs and the conversation can resume from a fresh process.
6665
* For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer.
6766
* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method.
68-
* `request_state=result.request_state`: copy it across. Never inspect it, never invent it.
69-
* When the server has everything it needs it returns a `CallToolResult` and the loop exits.
7067

7168
## A 2026-07-28 result
7269

@@ -88,9 +85,8 @@ Now you own the loop. There is no automatic driver yet; `while isinstance(result
8885

8986
* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client.
9087
* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads.
91-
* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`.
92-
* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type.
93-
* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet.
88+
* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it.
89+
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
9490
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
9591

9692
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**.

docs/migration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,13 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
395395

396396
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
397397

398-
### `call_tool` can return `InputRequiredResult` (opt-in)
398+
### `InputRequiredResult` handling differs between `Client` and `ClientSession`
399399

400-
For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`.
400+
For protocol 2026-07-28, `tools/call`, `prompts/get`, and `resources/read` may return an `InputRequiredResult` asking the client to supply additional input (sampling, elicitation, roots) and retry.
401+
402+
On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resolve this automatically: they dispatch each requested input to the matching callback (`sampling_callback`, `elicitation_callback`, `list_roots_callback`) and retry until a final result is returned, so the call still returns the bare `CallToolResult` / `GetPromptResult` / `ReadResourceResult`. The round limit is `Client(input_required_max_rounds=...)` (default 10). Earlier v2 prereleases exposed an `allow_input_required` parameter on these `Client` methods; that parameter has been removed. For manual control use `client.session.call_tool(..., allow_input_required=True)`. Note that `read_timeout_seconds` now bounds each underlying round, not the whole loop; wrap the call in `anyio.fail_after(...)` for a whole-loop bound.
403+
404+
On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag.
401405

402406
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243)
403407

docs_src/mrtr/tutorial002.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ def fulfil(request: InputRequest) -> InputResponse:
1010

1111

1212
async def provision(client: Client, name: str) -> CallToolResult:
13-
result = await client.call_tool("provision", {"name": name}, allow_input_required=True)
13+
result = await client.session.call_tool("provision", {"name": name}, allow_input_required=True)
1414
while isinstance(result, InputRequiredResult):
1515
responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()}
16-
result = await client.call_tool(
16+
result = await client.session.call_tool(
1717
"provision",
1818
{"name": name},
1919
input_responses=responses,

docs_src/mrtr/tutorial003.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from mcp_types import ElicitRequestParams, ElicitResult
2+
3+
from mcp import Client
4+
from mcp.client import ClientRequestContext
5+
6+
7+
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
8+
return ElicitResult(action="accept", content={"region": "eu-west-1"})
9+
10+
11+
async def main() -> None:
12+
async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=handle_elicitation) as client:
13+
result = await client.call_tool("provision", {"name": "orders"})
14+
print(result.content)

examples/stories/manifest.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ multi_connection = true
3434
# progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring
3535
xfail = ["http-asgi:modern"]
3636

37+
[story.mrtr]
38+
era = "modern"
39+
3740
[story.legacy_elicitation]
3841
era = "legacy"
3942
status = "legacy"
@@ -140,7 +143,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
140143

141144
[deferred]
142145
caching = "client honouring + per-result override unlanded"
143-
mrtr = "#2898 — InputRequiredResult runtime"
144146
subscriptions = "#2901 — Client.listen / ServerEventBus"
145147
tasks = "extensions capability map + tasks runtime"
146148
apps = "#2896 — extensions capability map"

examples/stories/mrtr/README.md

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,54 @@
11
# mrtr
22

3-
Multi-round tool results: a 2026-era tool call returns
4-
`resultType: "input_required"` with a `requestState` HMAC instead of pushing an
5-
`elicitation/create` request. The client fulfils the input and resubmits, and
6-
the server resumes from the carried state. The story will show both the
7-
auto-fulfil helper and a manual resubmit loop.
8-
9-
**Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)).
10-
The lowlevel registration surface is in this base —
11-
[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967)
12-
(`ae13ede`) widened the tool/prompt/resource handler return types to include
13-
`InputRequiredResult`. The runnable story is deliberately a follow-up PR to
14-
keep this one reviewable.
3+
Multi-round tool result: on the 2026-07-28 protocol a tool that needs user
4+
input mid-call **returns** `resultType: "input_required"` with embedded
5+
`inputRequests` and an opaque `requestState`, instead of pushing a
6+
server→client request. The client fulfils the embedded requests and retries the
7+
original `tools/call` carrying `inputResponses` and the echoed `requestState`.
8+
The story shows both the `Client` auto-loop (one `await call_tool`, callbacks
9+
fired transparently) and a manual `client.session` loop (the persistable form).
10+
11+
## Run it
12+
13+
```bash
14+
# HTTP — the client self-hosts the server on a free port, runs, then tears it
15+
# down (the InputRequiredResult round-trip is 2026-era only)
16+
uv run python -m stories.mrtr.client --http
17+
# same, against the lowlevel-API server variant
18+
uv run python -m stories.mrtr.client --http --server server_lowlevel
19+
```
20+
21+
## What to look at
22+
23+
- `client.py` `main` — the auto-loop is invisible at the call site:
24+
`Client(target, mode=mode, elicitation_callback=on_elicit)` then
25+
`await client.call_tool("deploy", ...)`. The same `on_elicit` callback the
26+
legacy push path uses is dispatched for each embedded `inputRequests` entry.
27+
- `client.py` manual block — `client.session.call_tool(...,
28+
allow_input_required=True)` returns the raw `InputRequiredResult` so
29+
`request_state` can be persisted between rounds; the retry is just another
30+
`tools/call` with `input_responses=` / `request_state=`.
31+
- `server.py` `deploy``ctx.input_responses` / `ctx.request_state` read the
32+
retry payload; the first round returns
33+
`InputRequiredResult(input_requests={...}, request_state=...)`, the second
34+
returns the final string.
35+
- `server_lowlevel.py` — same wire contract via `params.input_responses` /
36+
`params.request_state` and a hand-built `InputRequiredResult`.
37+
38+
## Caveats
39+
40+
- **Loop bound.** The auto-loop gives up after `input_required_max_rounds`
41+
(default 10) with `InputRequiredRoundsExceededError`; raise it on the
42+
`Client` ctor or drop to the manual loop.
43+
- **`requestState` integrity is the server's job.** The client echoes it
44+
byte-exact and never inspects it; the server MUST treat it as
45+
attacker-controlled. The SDK ships no signing helper yet.
1546

1647
## Spec
1748

18-
[Multi-round tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
19-
20-
## Working example elsewhere
21-
22-
The TypeScript SDK ships a runnable `mrtr` story:
23-
[typescript-sdk/examples/mrtr](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/mrtr).
49+
[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
2450

2551
## See also
2652

27-
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that
28-
this mechanism replaces on the 2026 protocol. The TypeScript SDK ships a single
29-
dual-era `elicitation/` story covering both eras in one place; we re-merge
30-
`legacy_elicitation/` back into `elicitation/` once MRTR lands.
53+
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
54+
mechanism replaces on the 2026 protocol.

examples/stories/mrtr/__init__.py

Whitespace-only changes.

examples/stories/mrtr/client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop."""
2+
3+
import mcp_types as types
4+
5+
from mcp.client import Client, ClientRequestContext
6+
from stories._harness import Target, run_client
7+
8+
9+
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
10+
# The same callback serves legacy push-style elicitation/create requests AND embedded
11+
# InputRequiredResult.input_requests entries — the driver dispatches both here.
12+
assert isinstance(params, types.ElicitRequestFormParams)
13+
assert "confirm" in params.requested_schema["properties"]
14+
return types.ElicitResult(action="accept", content={"confirm": True})
15+
16+
17+
async def main(target: Target, *, mode: str = "auto") -> None:
18+
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
19+
# ── auto-loop: Client.call_tool dispatches input_requests to on_elicit and retries
20+
# internally; the caller just sees the final CallToolResult.
21+
deployed = await client.call_tool("deploy", {"env": "production"})
22+
assert isinstance(deployed.content[0], types.TextContent)
23+
assert deployed.content[0].text == "deployed to production", deployed
24+
25+
# ── manual loop: drop to client.session for the raw InputRequiredResult so the
26+
# request_state can be persisted between rounds (e.g. across a process restart).
27+
first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True)
28+
assert isinstance(first, types.InputRequiredResult)
29+
assert first.input_requests is not None and "confirm" in first.input_requests
30+
assert first.request_state == "awaiting-confirm"
31+
# Decline this time so the path diverges from the auto-loop run above.
32+
responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")}
33+
second = await client.session.call_tool(
34+
"deploy",
35+
{"env": "staging"},
36+
input_responses=responses,
37+
request_state=first.request_state,
38+
allow_input_required=True,
39+
)
40+
assert isinstance(second, types.CallToolResult)
41+
assert isinstance(second.content[0], types.TextContent)
42+
assert second.content[0].text == "deployment to staging cancelled", second
43+
44+
45+
if __name__ == "__main__":
46+
run_client(main)

0 commit comments

Comments
 (0)