Add resolver dependency injection for MCPServer tools (#2969)

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-06-29 12:51:46 +02:00 • committed by GitHub
commit f664db8952
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1844 additions and 15 deletions

View file

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

View file

@ -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**.

View 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**.

View file

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

View file

View 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)."

View 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}."

View 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}."

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

View file

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

View file

@ -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).

View file

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

View file

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

View 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).

View file

View 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)

View 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)

View file

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

View file

@ -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):

View file

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