Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998)
This commit is contained in:
parent
3945bdde11
commit
08b62308d4
5 changed files with 1258 additions and 202 deletions
|
|
@ -8,7 +8,7 @@ The table below names each deprecated feature, why it is going away, and the rep
|
|||
|
||||
| Deprecated | Why | What you do instead |
|
||||
|---|---|---|
|
||||
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths that matter as ordinary tool arguments or resource URIs. |
|
||||
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **Multi-round-trip requests**). |
|
||||
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). |
|
||||
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). |
|
||||
| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. |
|
||||
|
|
@ -17,7 +17,7 @@ The table below names each deprecated feature, why it is going away, and the rep
|
|||
Three things fall out of that table:
|
||||
|
||||
* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once.
|
||||
* Sampling and roots share a deeper problem: they are the two places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**.
|
||||
* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
|
||||
* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*.
|
||||
|
||||
## Deprecated is advisory
|
||||
|
|
@ -82,7 +82,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
|
|||
## Recap
|
||||
|
||||
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**.
|
||||
* The replacement column points you onward: **Multi-round-trip requests** for sampling, **Logging** for logging, **Progress** for progress. Roots needs no chapter (pass the paths as arguments) and `ping` needs nothing at all.
|
||||
* The replacement column points you onward: **Multi-round-trip requests** for sampling and roots, **Logging** for logging, **Progress** for progress. `ping` needs nothing at all.
|
||||
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
|
||||
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
|
||||
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
|
||||
|
|
|
|||
|
|
@ -33,40 +33,43 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
|
|||
|
||||
## The client side
|
||||
|
||||
`call_tool` will not hand you an `InputRequiredResult` unless you opt in.
|
||||
`Client` runs the loop for you.
|
||||
|
||||
!!! check
|
||||
Call a tool that needs input without opting in and `call_tool` raises:
|
||||
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:
|
||||
|
||||
```text
|
||||
Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state).
|
||||
```
|
||||
|
||||
That is deliberate. Most call sites expect a result or an exception, not a third thing in the
|
||||
middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return
|
||||
a plain `CallToolResult`.
|
||||
|
||||
Pass `allow_input_required=True` and the result reaches you intact:
|
||||
|
||||
```python
|
||||
result.result_type # 'input_required'
|
||||
result.request_state # 'provision-v1'
|
||||
result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))}
|
||||
```python title="client.py" hl_lines="12 13"
|
||||
--8<-- "docs_src/mrtr/tutorial003.py"
|
||||
```
|
||||
|
||||
### The retry loop
|
||||
* That `elicitation_callback` is the same one a pre-2026 server's back-channel `elicitation/create` would have hit. The same is true of `sampling_callback` for `sampling/createMessage` and `list_roots_callback` for `roots/list`: at 2026-07-28 the standalone server->client RPCs are gone, but the identical `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` payloads ride inside `input_requests` and dispatch to the same three callbacks. One set of callbacks serves both eras.
|
||||
* `call_tool` returns a plain `CallToolResult`. The intermediate rounds are invisible to the caller.
|
||||
* `get_prompt` and `read_resource` drive the same loop.
|
||||
|
||||
Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API:
|
||||
!!! check
|
||||
Leave the callback off and the loop fails on the first round: the SDK's stand-in callback
|
||||
answers every elicitation with an error, and `call_tool` raises `MCPError` with the message
|
||||
*"Elicitation not supported"*.
|
||||
|
||||
```python title="client.py" hl_lines="13-15 17-20"
|
||||
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.
|
||||
|
||||
### Driving the loop yourself
|
||||
|
||||
The auto-loop is enough for a single-process client. Own the loop instead when:
|
||||
|
||||
* Your client is **distributed**: the process that renders the question to the user is not the process that called `call_tool`, so a different worker issues the retry. `request_state` is the persistable token you carry across that boundary, through your own storage, and `input_responses` is what the other side sends back with it.
|
||||
* You want to **inspect** each round: log or audit every `input_requests` entry, refuse certain request kinds, or apply your own backoff between legs.
|
||||
* You want a **wall-clock** bound rather than a round-count bound: wrap your own loop in `anyio.fail_after(...)` instead of relying on `input_required_max_rounds`.
|
||||
|
||||
Drop to the underlying session, where `allow_input_required=True` hands you the union directly:
|
||||
|
||||
```python title="client.py" hl_lines="13 14 20"
|
||||
--8<-- "docs_src/mrtr/tutorial002.py"
|
||||
```
|
||||
|
||||
* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing.
|
||||
* `client.session.call_tool(..., allow_input_required=True)` widens the return type to `CallToolResult | InputRequiredResult`. The `isinstance` is what narrows it back.
|
||||
* `request_state` is now in your hands. Write it down between legs and the conversation can resume from a fresh process.
|
||||
* 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.
|
||||
* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method.
|
||||
* `request_state=result.request_state`: copy it across. Never inspect it, never invent it.
|
||||
* When the server has everything it needs it returns a `CallToolResult` and the loop exits.
|
||||
|
||||
## A 2026-07-28 result
|
||||
|
||||
|
|
@ -88,9 +91,8 @@ Now you own the loop. There is no automatic driver yet; `while isinstance(result
|
|||
|
||||
* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client.
|
||||
* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads.
|
||||
* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`.
|
||||
* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type.
|
||||
* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet.
|
||||
* `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.
|
||||
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
|
||||
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
|
||||
|
||||
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer
|
|||
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
|
||||
to answer one; every test behind this page does. **Protocol versions** has the whole story.
|
||||
|
||||
On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
|
||||
`InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same
|
||||
`elicitation_callback` and retries the call for you. That flow is **Multi-round-trip requests**.
|
||||
|
||||
## A callback is a capability
|
||||
|
||||
You never told the server that your client can answer elicitation requests. The SDK did.
|
||||
|
|
@ -109,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas
|
|||
|
||||
`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.
|
||||
|
||||
Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**.
|
||||
Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **Deprecated features**.
|
||||
|
||||
You still need the callbacks to talk to servers that haven't moved. The signatures:
|
||||
|
||||
|
|
|
|||
|
|
@ -395,9 +395,13 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
|
|||
|
||||
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
|
||||
|
||||
### `call_tool` can return `InputRequiredResult` (opt-in)
|
||||
### `InputRequiredResult` handling differs between `Client` and `ClientSession`
|
||||
|
||||
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=`.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243)
|
||||
|
||||
|
|
|
|||
|
|
@ -149,5 +149,6 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit
|
|||
* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept.
|
||||
* `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done.
|
||||
* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability.
|
||||
* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **Multi-round-trip requests**.
|
||||
|
||||
A tool that can ask is good. A tool that says how far along it is (**Progress**) is next.
|
||||
|
|
|
|||
Loading…
Reference in a new issue