Add resolver dependency injection for MCPServer tools (#2969)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
4b519782f1
commit
f664db8952
29 changed files with 1844 additions and 15 deletions
|
|
@ -1624,6 +1624,20 @@ app = server.streamable_http_app(
|
|||
|
||||
The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`.
|
||||
|
||||
### `ElicitationResult` is now a subscriptable generic alias
|
||||
|
||||
`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](tutorial/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`.
|
||||
|
||||
The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead:
|
||||
|
||||
```python
|
||||
result = await ctx.elicit("Proceed?", Confirm)
|
||||
if isinstance(result, AcceptedElicitation):
|
||||
... # result.data is a Confirm
|
||||
```
|
||||
|
||||
Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected.
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ The injected object is small. Besides `request_id`:
|
|||
* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**.
|
||||
* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**.
|
||||
* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it.
|
||||
* `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity.
|
||||
* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**).
|
||||
|
||||
Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why.
|
||||
|
|
@ -123,4 +124,4 @@ The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, a
|
|||
* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed.
|
||||
* Progress reporting and elicitation also start at `Context`; each has its own chapter.
|
||||
|
||||
Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**.
|
||||
Next: parameters the model never sees, filled by your own functions, in **Dependencies**.
|
||||
|
|
|
|||
127
docs/tutorial/dependencies.md
Normal file
127
docs/tutorial/dependencies.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Dependencies
|
||||
|
||||
A tool's arguments come from the model. Some values never should: a price looked up from your records, a confirmation only a person can give, anything the model could get wrong by inventing it.
|
||||
|
||||
**Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs.
|
||||
|
||||
## Declare one
|
||||
|
||||
Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`:
|
||||
|
||||
```python title="server.py" hl_lines="18-19 23"
|
||||
--8<-- "docs_src/dependencies/tutorial001.py"
|
||||
```
|
||||
|
||||
* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument.
|
||||
* Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see.
|
||||
* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble.
|
||||
|
||||
!!! info
|
||||
If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what
|
||||
it needs, the framework supplies it, and the wiring lives in the type annotation.
|
||||
|
||||
### Invisible to the model
|
||||
|
||||
Here is the input schema `tools/list` reports for `reserve_book`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"title": "Title", "type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"title": "reserve_bookArguments"
|
||||
}
|
||||
```
|
||||
|
||||
One property. Like the `Context` in **The Context**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive.
|
||||
|
||||
That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong.
|
||||
|
||||
### Try it
|
||||
|
||||
Run the server with the MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
The form for `reserve_book` has a single `title` field. `stock` is nowhere on it. Call it with `Dune`:
|
||||
|
||||
```text
|
||||
Reserved 'Dune' (6 copies left).
|
||||
```
|
||||
|
||||
The tool body never looked anything up: `check_stock` ran first, and the `Stock` it returned arrived as an argument. Try `Neuromancer` and the same resolver hands the tool a zero.
|
||||
|
||||
!!! tip
|
||||
You could just call `check_stock(title)` in the tool body. Declare it as a dependency when the
|
||||
value deserves more than a helper call: every tool that needs stock declares the same parameter,
|
||||
and the SDK runs the resolver at most once per call, no matter how many declare it. The next
|
||||
sections add the rest: resolvers that depend on each other, and resolvers that ask the user.
|
||||
|
||||
## Dependencies of dependencies
|
||||
|
||||
A resolver can declare its own dependencies, with the same annotation:
|
||||
|
||||
```python title="server.py" hl_lines="22 29-30"
|
||||
--8<-- "docs_src/dependencies/tutorial002.py"
|
||||
```
|
||||
|
||||
* `estimate_delivery` depends on `check_stock`. The SDK runs the graph in order: stock first, then the estimate, then the tool.
|
||||
* Both `stock` and `delivery` ultimately need `check_stock`, but it runs **once per call**. One inventory lookup, two consumers.
|
||||
* There is nothing to register. The graph *is* the annotations.
|
||||
|
||||
!!! check
|
||||
Don't take once-per-call on faith. Put a `print` in `check_stock` and call `order_book` from the
|
||||
Inspector: one line per call. Two consumers, one lookup.
|
||||
|
||||
The SDK analyses the graph when the tool is registered, not when it is called. A parameter it can't classify - not a `Context`, not a `Resolve(...)`, not a tool argument's name - and a cycle of resolvers both raise `InvalidSignature` at startup. Your server fails before a client ever connects, with the offending parameter or resolver named in the error.
|
||||
|
||||
A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, the tool's own arguments by name, or the `Context` - `ctx.headers`, the lifespan object, all of it.
|
||||
|
||||
!!! warning
|
||||
On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**,
|
||||
like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller
|
||||
is comes from your authorization layer (**Authorization**), not from a header anyone can set.
|
||||
|
||||
!!! tip
|
||||
*Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource
|
||||
that should outlive a request - a database pool, an HTTP client - belongs in **Lifespan**, and
|
||||
a resolver can reach it through `ctx.request_context.lifespan_context`.
|
||||
|
||||
## Ask when you must
|
||||
|
||||
A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **Elicitation** machinery, run for you:
|
||||
|
||||
```python title="server.py" hl_lines="26-32 39"
|
||||
--8<-- "docs_src/dependencies/tutorial003.py"
|
||||
```
|
||||
|
||||
* In stock: `confirm_backorder` returns a `Backorder` directly. **No question, no round-trip.** The user is only interrupted when their answer matters.
|
||||
* Out of stock: the SDK sends the elicitation, validates the answer against `Backorder`, and injects it. Your resolver never touches the protocol.
|
||||
* The tool reads `backorder.confirm` like any other argument. Answering **no** is still an answer: the elicitation is accepted with `confirm=False`, the tool runs, and no order is placed. Asking became a precondition, not plumbing in the tool body.
|
||||
|
||||
And if the user won't answer at all - declines the question, or cancels it?
|
||||
|
||||
!!! check
|
||||
Run `order_book` for `Neuromancer` and decline the question. With the annotation written as
|
||||
`Annotated[Backorder, Resolve(...)]` the tool body never runs; the call fails with an error
|
||||
result the model can read:
|
||||
|
||||
```text
|
||||
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
|
||||
```
|
||||
|
||||
That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **Elicitation** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation.
|
||||
|
||||
## Recap
|
||||
|
||||
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
|
||||
* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here.
|
||||
* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per call.
|
||||
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
|
||||
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
|
||||
|
||||
Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**.
|
||||
|
|
@ -79,6 +79,24 @@ A refusal is not an error. The tool decides what declining means (here, no booki
|
|||
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
|
||||
`ValidationError`, your `if` never runs.
|
||||
|
||||
## Ask before the tool runs
|
||||
|
||||
The booking tool above weaves the question into its own body. When the question is really a *precondition* - confirm before deleting, authenticate before acting - you can lift it out of the tool into a **resolver** and let the framework ask 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.
|
||||
|
||||
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** chapter.
|
||||
|
||||
## 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:
|
||||
|
|
|
|||
0
docs_src/dependencies/__init__.py
Normal file
0
docs_src/dependencies/__init__.py
Normal file
27
docs_src/dependencies/tutorial001.py
Normal file
27
docs_src/dependencies/tutorial001.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Resolve
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
INVENTORY = {"Dune": 7, "Neuromancer": 0}
|
||||
|
||||
|
||||
class Stock(BaseModel):
|
||||
title: str
|
||||
copies: int
|
||||
|
||||
|
||||
async def check_stock(title: str) -> Stock:
|
||||
return Stock(title=title, copies=INVENTORY.get(title, 0))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str:
|
||||
"""Reserve a copy of a book."""
|
||||
if stock.copies == 0:
|
||||
return f"{title!r} is out of stock."
|
||||
return f"Reserved {title!r} ({stock.copies - 1} copies left)."
|
||||
35
docs_src/dependencies/tutorial002.py
Normal file
35
docs_src/dependencies/tutorial002.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Resolve
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
INVENTORY = {"Dune": 7, "Neuromancer": 0}
|
||||
|
||||
|
||||
class Stock(BaseModel):
|
||||
title: str
|
||||
copies: int
|
||||
|
||||
|
||||
async def check_stock(title: str) -> Stock:
|
||||
return Stock(title=title, copies=INVENTORY.get(title, 0))
|
||||
|
||||
|
||||
async def estimate_delivery(stock: Annotated[Stock, Resolve(check_stock)]) -> str:
|
||||
return "tomorrow" if stock.copies > 0 else "in 2-3 weeks"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def order_book(
|
||||
title: str,
|
||||
stock: Annotated[Stock, Resolve(check_stock)],
|
||||
delivery: Annotated[str, Resolve(estimate_delivery)],
|
||||
) -> str:
|
||||
"""Order a book from the shop."""
|
||||
if stock.copies == 0:
|
||||
return f"{title!r} is on backorder; it would arrive {delivery}."
|
||||
return f"Ordered {title!r}; it arrives {delivery}."
|
||||
46
docs_src/dependencies/tutorial003.py
Normal file
46
docs_src/dependencies/tutorial003.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Elicit, Resolve
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
INVENTORY = {"Dune": 7, "Neuromancer": 0}
|
||||
|
||||
|
||||
class Stock(BaseModel):
|
||||
title: str
|
||||
copies: int
|
||||
|
||||
|
||||
class Backorder(BaseModel):
|
||||
confirm: bool = Field(description="Order anyway and wait?")
|
||||
|
||||
|
||||
async def check_stock(title: str) -> Stock:
|
||||
return Stock(title=title, copies=INVENTORY.get(title, 0))
|
||||
|
||||
|
||||
async def confirm_backorder(
|
||||
title: str,
|
||||
stock: Annotated[Stock, Resolve(check_stock)],
|
||||
) -> Backorder | Elicit[Backorder]:
|
||||
if stock.copies > 0:
|
||||
return Backorder(confirm=True) # in stock: nothing to ask
|
||||
return Elicit(f"{title!r} is out of stock (2-3 weeks). Order anyway?", Backorder)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def order_book(
|
||||
title: str,
|
||||
stock: Annotated[Stock, Resolve(check_stock)],
|
||||
backorder: Annotated[Backorder, Resolve(confirm_backorder)],
|
||||
) -> str:
|
||||
"""Order a book from the shop."""
|
||||
if not backorder.confirm:
|
||||
return "No order placed."
|
||||
if stock.copies == 0:
|
||||
return f"Backordered {title!r}; it ships in 2-3 weeks."
|
||||
return f"Ordered {title!r}."
|
||||
47
docs_src/elicitation/tutorial004.py
Normal file
47
docs_src/elicitation/tutorial004.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
DeclinedElicitation,
|
||||
Elicit,
|
||||
ElicitationResult,
|
||||
Resolve,
|
||||
)
|
||||
|
||||
mcp = MCPServer("Files")
|
||||
|
||||
_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}
|
||||
|
||||
|
||||
class Confirm(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
|
||||
"""Resolver: ask for confirmation only when the folder is not empty."""
|
||||
file_count = len(_FOLDERS.get(path, []))
|
||||
if file_count == 0:
|
||||
return Confirm(ok=True) # nothing to confirm, no round-trip to the client
|
||||
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_folder(
|
||||
path: str,
|
||||
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
|
||||
) -> str:
|
||||
"""Delete a folder, asking for confirmation when it is not empty."""
|
||||
match confirm:
|
||||
case AcceptedElicitation(data=Confirm(ok=True)):
|
||||
_FOLDERS.pop(path, None)
|
||||
return f"deleted {path}"
|
||||
case AcceptedElicitation():
|
||||
return "kept the folder"
|
||||
case DeclinedElicitation():
|
||||
return "declined: folder not deleted"
|
||||
case CancelledElicitation():
|
||||
return "cancelled: folder not deleted"
|
||||
|
|
@ -130,6 +130,7 @@ opens with a banner saying what replaces it.
|
|||
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
|
||||
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current |
|
||||
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
|
||||
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
|
||||
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
|
||||
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
|
||||
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |
|
||||
|
|
|
|||
|
|
@ -69,4 +69,5 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv
|
|||
|
||||
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
|
||||
(planned — the 2026-era carrier), `error_handling/`
|
||||
(`UrlElicitationRequiredError`).
|
||||
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
|
||||
mechanism today).
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ era = "modern"
|
|||
era = "legacy"
|
||||
status = "legacy"
|
||||
|
||||
[story.refund_desk]
|
||||
# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once
|
||||
# the SDK carries resolver elicitation over the 2026 input_required round-trip.
|
||||
era = "legacy"
|
||||
lowlevel = false
|
||||
|
||||
[story.sampling]
|
||||
era = "legacy"
|
||||
status = "deprecated"
|
||||
|
|
|
|||
|
|
@ -51,4 +51,5 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
|
|||
## See also
|
||||
|
||||
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
|
||||
mechanism replaces on the 2026 protocol.
|
||||
mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the
|
||||
MCPServer tier: the questions a tool can declare instead of pushing by hand.
|
||||
|
|
|
|||
67
examples/stories/refund_desk/README.md
Normal file
67
examples/stories/refund_desk/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# refund-desk
|
||||
|
||||
Resolver dependency injection: a tool parameter annotated `Annotated[T,
|
||||
Resolve(fn)]` is filled by running the resolver `fn` before the tool body,
|
||||
instead of from the LLM-supplied arguments. Here `refund_order(order_id,
|
||||
reason)` refunds what the order record says — `cents` is resolver-computed and
|
||||
does not appear in the input schema at all, so the model cannot supply or
|
||||
inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` →
|
||||
`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human,
|
||||
and run at most once per call. A resolver's own plain parameters are filled
|
||||
from the tool's arguments by name — `load_order(order_id)` receives the
|
||||
`order_id` the model passed to `refund_order`.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.refund_desk.client
|
||||
|
||||
# HTTP — the client self-hosts the server on a free port, runs, then tears it
|
||||
# down (--legacy: resolver elicitation rides the push request today; the
|
||||
# manifest pins this era, so bare --http runs the same leg)
|
||||
uv run python -m stories.refund_desk.client --http --legacy
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `server.py` `refund_order` — the signature is the whole story: `order_id` and
|
||||
`reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers
|
||||
and never reach the input schema. `client.py` asserts `properties` and
|
||||
`required` are exactly `{order_id, reason}`.
|
||||
- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order
|
||||
returns `Scope(full=True)` directly; only a multi-line order returns
|
||||
`Elicit(...)`. The ORD-7001 call completes with zero elicitations.
|
||||
- `server.py` `_scoped` — the elicited SKU is human-typed free text; it is
|
||||
validated against the order (`ToolError` on a miss) before any amount is
|
||||
computed.
|
||||
- The decline contrast: `refund_amount` takes `scope` **unwrapped**, so
|
||||
declining the scope question aborts the whole `cents` chain with an error
|
||||
containing the framework's
|
||||
`Resolver for parameter 'scope' could not resolve: elicitation was decline`
|
||||
(the client sees it behind the usual `Error executing tool refund_order:`
|
||||
prefix); `restock` keeps the `ElicitationResult` union, so declining restock
|
||||
still refunds — just with `restocked: false`.
|
||||
- `client.py` — the scope counter proves memoization from outside: one call
|
||||
consumes `refund_scope` from two resolvers but the question fires once.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Decline order.** A declined unwrapped dependency aborts resolution in
|
||||
tool-signature order — `cents` resolves before `restock`, so `ask_restock`
|
||||
never runs. Don't rely on a later resolver's side effects after an earlier
|
||||
consumer can abort.
|
||||
- **Memoization scope.** Each resolver runs at most once per `tools/call`,
|
||||
keyed by function identity; nothing is cached across calls or connections.
|
||||
- **Validate elicited values.** Elicited answers are human-typed; check them
|
||||
against your records (as `_scoped` does) before acting on them.
|
||||
|
||||
## Spec
|
||||
|
||||
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
|
||||
|
||||
## See also
|
||||
|
||||
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
|
||||
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
|
||||
SDK wires them together).
|
||||
0
examples/stories/refund_desk/__init__.py
Normal file
0
examples/stories/refund_desk/__init__.py
Normal file
103
examples/stories/refund_desk/client.py
Normal file
103
examples/stories/refund_desk/client.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer."""
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.client import Client, ClientRequestContext
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
# Scripted answers + per-topic counters; topics in `declines` are refused.
|
||||
counts = {"scope": 0, "restock": 0}
|
||||
answers: dict[str, dict[str, str | int | float | bool | list[str] | None]] = {
|
||||
"scope": {"full": True},
|
||||
"restock": {"restock": True},
|
||||
}
|
||||
declines: set[str] = set()
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
topic = "scope" if "full" in params.requested_schema["properties"] else "restock"
|
||||
counts[topic] += 1
|
||||
if topic in declines:
|
||||
return types.ElicitResult(action="decline")
|
||||
return types.ElicitResult(action="accept", content=answers[topic])
|
||||
|
||||
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
|
||||
# The model-facing contract is order_id + reason only; cents and restock are resolver-filled.
|
||||
listed = await client.list_tools()
|
||||
(tool,) = listed.tools
|
||||
assert set(tool.input_schema["properties"]) == {"order_id", "reason"}, tool.input_schema
|
||||
assert set(tool.input_schema.get("required", ())) == {"order_id", "reason"}, tool.input_schema
|
||||
|
||||
# One digital line: scope auto-fills (full), restock auto-fills (False) — zero round-trips.
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7001", "reason": "download corrupted"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7001",
|
||||
"refunded_cents": 1500,
|
||||
"restocked": False,
|
||||
"reason": "download corrupted",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 0, "restock": 0}, counts
|
||||
|
||||
# Full refund of a three-line order. The scope question fires exactly ONCE even though
|
||||
# both refund_amount and ask_restock consume it — memoized within the call.
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7002",
|
||||
"refunded_cents": 4800,
|
||||
"restocked": True,
|
||||
"reason": "arrived broken",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 1, "restock": 1}, counts
|
||||
|
||||
# Declining restock still refunds: the tool keeps the ElicitationResult union for
|
||||
# `restock`, sees the decline, and just skips the restock. The scope counter moves
|
||||
# again — the memo cache is per tools/call, not per connection.
|
||||
declines.add("restock")
|
||||
answers["scope"] = {"full": False, "sku": "canvas-tote"}
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7002",
|
||||
"refunded_cents": 2400,
|
||||
"restocked": False,
|
||||
"reason": "wrong colour",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 2, "restock": 2}, counts
|
||||
declines.clear()
|
||||
|
||||
# An elicited SKU is human-typed: the server validates it against the order before
|
||||
# any money is computed.
|
||||
answers["scope"] = {"full": False, "sku": "mystery-hat"}
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "lost parcel"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "order has no item 'mystery-hat'" in result.content[0].text, result.content[0].text
|
||||
|
||||
# Declining scope aborts the whole call: refund_amount and ask_restock both consume scope
|
||||
# unwrapped, so whichever resolves first (`cents`, in signature order) aborts, and
|
||||
# ask_restock never runs under any order.
|
||||
declines.add("scope")
|
||||
restock_before = counts["restock"]
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "changed mind"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "Resolver for parameter 'scope' could not resolve: elicitation was decline" in result.content[0].text, (
|
||||
result.content[0].text
|
||||
)
|
||||
assert counts["restock"] == restock_before, counts
|
||||
declines.clear()
|
||||
|
||||
# A ToolError raised inside a resolver surfaces exactly like one from the tool body.
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-9999", "reason": "typo"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "unknown order 'ORD-9999'" in result.content[0].text, result.content[0].text
|
||||
|
||||
# Full elicitation trajectory: scope fired in legs 2-5 (memoized within each call),
|
||||
# restock only in the two calls that reached it.
|
||||
assert counts == {"scope": 4, "restock": 2}, counts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
125
examples/stories/refund_desk/server.py
Normal file
125
examples/stories/refund_desk/server.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Resolver DI: the refund amount is computed by resolvers from the order record — `cents` never appears in the
|
||||
tool's input schema, so the model cannot supply or inflate it."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import (
|
||||
AcceptedElicitation,
|
||||
Elicit,
|
||||
ElicitationResult,
|
||||
MCPServer,
|
||||
Resolve,
|
||||
)
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line:
|
||||
sku: str
|
||||
cents: int
|
||||
physical: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Order:
|
||||
order_id: str
|
||||
lines: tuple[Line, ...]
|
||||
|
||||
|
||||
ORDERS: dict[str, Order] = {
|
||||
"ORD-7001": Order("ORD-7001", (Line("ebook-fieldnotes", 1500, physical=False),)),
|
||||
"ORD-7002": Order(
|
||||
"ORD-7002",
|
||||
(
|
||||
Line("enamel-mug", 1800, physical=True),
|
||||
Line("canvas-tote", 2400, physical=True),
|
||||
Line("sticker-pack", 600, physical=False),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class Scope(BaseModel):
|
||||
"""Which items to refund: the whole order, or a single SKU."""
|
||||
|
||||
full: bool
|
||||
sku: str = ""
|
||||
|
||||
|
||||
class RestockChoice(BaseModel):
|
||||
restock: bool
|
||||
|
||||
|
||||
class Receipt(BaseModel):
|
||||
order_id: str
|
||||
refunded_cents: int
|
||||
restocked: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def load_order(order_id: str) -> Order:
|
||||
order = ORDERS.get(order_id)
|
||||
if order is None:
|
||||
raise ToolError(f"unknown order {order_id!r}")
|
||||
return order
|
||||
|
||||
|
||||
def refund_scope(order_id: str, order: Annotated[Order, Resolve(load_order)]) -> Scope | Elicit[Scope]:
|
||||
if len(order.lines) == 1:
|
||||
return Scope(full=True)
|
||||
skus = ", ".join(line.sku for line in order.lines)
|
||||
return Elicit(f"{order_id} has several items ({skus}). Refund the whole order, or one SKU?", Scope)
|
||||
|
||||
|
||||
def _scoped(order: Order, scope: Scope) -> tuple[Line, ...]:
|
||||
"""The lines a scope covers. The SKU was typed by a human — validate it against the order."""
|
||||
if scope.full:
|
||||
return order.lines
|
||||
lines = tuple(line for line in order.lines if line.sku == scope.sku)
|
||||
if not lines:
|
||||
raise ToolError(f"order has no item {scope.sku!r}")
|
||||
return lines
|
||||
|
||||
|
||||
def refund_amount(
|
||||
order: Annotated[Order, Resolve(load_order)],
|
||||
scope: Annotated[Scope, Resolve(refund_scope)],
|
||||
) -> int:
|
||||
return sum(line.cents for line in _scoped(order, scope))
|
||||
|
||||
|
||||
def ask_restock(
|
||||
order: Annotated[Order, Resolve(load_order)],
|
||||
scope: Annotated[Scope, Resolve(refund_scope)],
|
||||
) -> RestockChoice | Elicit[RestockChoice]:
|
||||
physical = [line.sku for line in _scoped(order, scope) if line.physical]
|
||||
if not physical:
|
||||
return RestockChoice(restock=False)
|
||||
return Elicit(f"The refund includes physical items ({', '.join(physical)}). Return them to stock?", RestockChoice)
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("refund-desk")
|
||||
|
||||
@mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.")
|
||||
def refund_order(
|
||||
order_id: str,
|
||||
reason: str,
|
||||
cents: Annotated[int, Resolve(refund_amount)],
|
||||
restock: Annotated[ElicitationResult[RestockChoice], Resolve(ask_restock)],
|
||||
) -> Receipt:
|
||||
# `restock` keeps the full elicitation outcome: a declined restock still refunds. A plain
|
||||
# (non-Elicit) resolver return arrives wrapped as an accepted outcome, so the fast path
|
||||
# lands in the same `AcceptedElicitation` branch.
|
||||
restocked = isinstance(restock, AcceptedElicitation) and restock.data.restock
|
||||
return Receipt(order_id=order_id, refunded_cents=cents, restocked=restocked, reason=reason)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
|
|
@ -21,6 +21,7 @@ nav:
|
|||
- Resources: tutorial/resources.md
|
||||
- Prompts: tutorial/prompts.md
|
||||
- The Context: tutorial/context.md
|
||||
- Dependencies: tutorial/dependencies.md
|
||||
- Handling errors: tutorial/handling-errors.md
|
||||
- Lifespan: tutorial/lifespan.md
|
||||
- Media: tutorial/media.md
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from mcp_types.v2025_11_25 import PrimitiveSchemaDefinition
|
|||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
|
||||
from pydantic_core import core_schema
|
||||
from typing_extensions import TypeAliasType
|
||||
|
||||
from mcp.server.session import ServerSession
|
||||
|
||||
|
|
@ -36,7 +37,11 @@ class CancelledElicitation(BaseModel):
|
|||
action: Literal["cancel"] = "cancel"
|
||||
|
||||
|
||||
ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation
|
||||
ElicitationResult = TypeAliasType(
|
||||
"ElicitationResult",
|
||||
AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation,
|
||||
type_params=(ElicitSchemaModelT,),
|
||||
)
|
||||
|
||||
|
||||
class AcceptedUrlElicitation(BaseModel):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ from mcp_types import Icon
|
|||
from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding
|
||||
|
||||
from .context import Context
|
||||
from .resolve import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
DeclinedElicitation,
|
||||
Elicit,
|
||||
ElicitationResult,
|
||||
Resolve,
|
||||
)
|
||||
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
|
||||
from .server import MCPServer, require_client_extension
|
||||
from .utilities.types import Audio, Image
|
||||
|
|
@ -15,6 +23,12 @@ __all__ = [
|
|||
"Image",
|
||||
"Audio",
|
||||
"Icon",
|
||||
"Resolve",
|
||||
"Elicit",
|
||||
"ElicitationResult",
|
||||
"AcceptedElicitation",
|
||||
"DeclinedElicitation",
|
||||
"CancelledElicitation",
|
||||
"Extension",
|
||||
"ToolBinding",
|
||||
"ResourceBinding",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue