chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
# Elicitation
A tool that is halfway through its job and missing one answer doesn't have to fail.
**Elicitation** lets it ask. In the middle of a tool call the user gets a question, and their answer comes back into the same function call.
There are two modes:
* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form.
* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol.
And there are two ways to ask. The one to reach for is a **resolver**: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, `await ctx.elicit(...)`, is a request from the *server* to the *client*, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver.
## Ask with a resolver
A question that gates the whole tool - *are you sure? which of the three matching accounts?* - can be lifted out of the tool body into a **resolver**, and the framework asks it for you.
A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask:
```python title="server.py" hl_lines="24-30 35-36"
--8<-- "docs_src/elicitation/tutorial004.py"
```
* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client.
* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel.
* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`.
Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel.
A resolver works on **every** connection. For a client on a legacy connection the SDK sends it the question directly; on a **2026-07-28** connection the SDK *returns* the question from the call, and the client's next attempt carries the answer. Your resolver never knows the difference; what happens underneath is **[Multi-round-trip requests](multi-round-trip.md)**.
Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** page.
## Ask from inside the tool
A tool can also stop in the middle of its own body and ask.
!!! warning
`ctx.elicit()` and `ctx.elicit_url()` are requests from the *server* to the *client* - a
channel that only exists for a client on a legacy connection (spec version **2025-11-25**
or earlier). On a **2026-07-28** connection there are no server-initiated requests, so
these calls fail. A resolver works on both. **[Protocol versions](../protocol-versions.md)**
has the whole story.
`await ctx.elicit()` takes a message and a Pydantic model:
```python title="server.py" hl_lines="9-11 20-23 25"
--8<-- "docs_src/elicitation/tutorial001.py"
```
* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own page: **[The Context](context.md)**.
* `AlternativeDate` is the **schema** of the answer you want.
* The tool is `async def`. It has to be: it stops in the middle and waits for a person.
* On any other date the tool returns straight away. It only asks when it has to.
* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind.
### What the client receives
The client gets your message and, next to it, a JSON Schema generated from the model:
```json
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
```
That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery **[Tools](../servers/tools.md)** describes for a tool's arguments.
!!! warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
```
You are interrupting a person mid-task. If the answer needs nesting, it should have been an
argument to the tool.
### The three answers
`result.action` tells you what the user did, and there are exactly three possibilities:
* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated.
* `"decline"`: they said no.
* `"cancel"`: they dismissed the question without choosing.
`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all.
A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally.
!!! tip
The answer is validated against your model before your code sees it. A client that sends
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
schema-mismatch error, your `if` never runs.
## Send the user to a URL
Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere:
```python title="server.py" hl_lines="10-14 23"
--8<-- "docs_src/elicitation/tutorial002.py"
```
* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server.
* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side.
* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP.
Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess.
## The client side
Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`:
```python title="client.py" hl_lines="7-8 19"
--8<-- "docs_src/elicitation/tutorial003.py"
```
* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch.
* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`.
* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test.
* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **[Client callbacks](../client/callbacks.md)**.
!!! info
Elicitation is a request from the *server* to the *client*, and those only exist on a
classic-handshake session, which is why this client passes `mode="legacy"`.
On a **2026-07-28** connection a tool asks by *returning* the question from the call
instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**.
### Try it
Start the `ctx.elicit` form-mode `server.py` (the `book_table` one) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day.
The callback prints the question it was sent:
```text
No tables for 2 on 2025-12-25. Would you like to try another date?
```
It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking:
```text
Booked a table for 2 on 2025-12-27.
```
Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions.
!!! check
Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day
again. The whole call fails with a protocol error:
```text
Elicitation not supported
```
A client that registered no callback never declared the `elicitation` capability, so there is
nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every
elicitation needs a sensible answer to "what if I can't ask?".
## Recap
* A parameter annotated `Annotated[T, Resolve(fn)]` is filled by a resolver, which returns `Elicit(...)` when it has to ask. It works on every connection.
* The schema is a flat Pydantic model: primitive fields only, validated on the way back.
* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept.
* `await ctx.elicit(message, schema=Model)` asks from inside the tool body, and `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). Both are server-to-client requests: they need the client on a legacy connection.
* 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](multi-round-trip.md)**.
Everything underneath that return (the retry loop, protecting `requestState`, driving it yourself) is **[Multi-round-trip requests](multi-round-trip.md)**.