Skip to content

Commit f664db8

Browse files
Kludexmaxisbey
andauthored
Add resolver dependency injection for MCPServer tools (#2969)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 4b51978 commit f664db8

29 files changed

Lines changed: 1844 additions & 15 deletions

docs/migration.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,20 @@ app = server.streamable_http_app(
16241624

16251625
The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`.
16261626

1627+
### `ElicitationResult` is now a subscriptable generic alias
1628+
1629+
`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`.
1630+
1631+
The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead:
1632+
1633+
```python
1634+
result = await ctx.elicit("Proceed?", Confirm)
1635+
if isinstance(result, AcceptedElicitation):
1636+
... # result.data is a Confirm
1637+
```
1638+
1639+
Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected.
1640+
16271641
## Need Help?
16281642

16291643
If you encounter issues during migration:

docs/tutorial/context.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ The injected object is small. Besides `request_id`:
6363
* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**.
6464
* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**.
6565
* `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.
66+
* `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.
6667
* `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**).
6768

6869
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
123124
* `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.
124125
* Progress reporting and elicitation also start at `Context`; each has its own chapter.
125126

126-
Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**.
127+
Next: parameters the model never sees, filled by your own functions, in **Dependencies**.

docs/tutorial/dependencies.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Dependencies
2+
3+
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.
4+
5+
**Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs.
6+
7+
## Declare one
8+
9+
Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`:
10+
11+
```python title="server.py" hl_lines="18-19 23"
12+
--8<-- "docs_src/dependencies/tutorial001.py"
13+
```
14+
15+
* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument.
16+
* 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.
17+
* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble.
18+
19+
!!! info
20+
If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what
21+
it needs, the framework supplies it, and the wiring lives in the type annotation.
22+
23+
### Invisible to the model
24+
25+
Here is the input schema `tools/list` reports for `reserve_book`:
26+
27+
```json
28+
{
29+
"type": "object",
30+
"properties": {
31+
"title": {"title": "Title", "type": "string"}
32+
},
33+
"required": ["title"],
34+
"title": "reserve_bookArguments"
35+
}
36+
```
37+
38+
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.
39+
40+
That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong.
41+
42+
### Try it
43+
44+
Run the server with the MCP Inspector:
45+
46+
```console
47+
uv run mcp dev server.py
48+
```
49+
50+
The form for `reserve_book` has a single `title` field. `stock` is nowhere on it. Call it with `Dune`:
51+
52+
```text
53+
Reserved 'Dune' (6 copies left).
54+
```
55+
56+
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.
57+
58+
!!! tip
59+
You could just call `check_stock(title)` in the tool body. Declare it as a dependency when the
60+
value deserves more than a helper call: every tool that needs stock declares the same parameter,
61+
and the SDK runs the resolver at most once per call, no matter how many declare it. The next
62+
sections add the rest: resolvers that depend on each other, and resolvers that ask the user.
63+
64+
## Dependencies of dependencies
65+
66+
A resolver can declare its own dependencies, with the same annotation:
67+
68+
```python title="server.py" hl_lines="22 29-30"
69+
--8<-- "docs_src/dependencies/tutorial002.py"
70+
```
71+
72+
* `estimate_delivery` depends on `check_stock`. The SDK runs the graph in order: stock first, then the estimate, then the tool.
73+
* Both `stock` and `delivery` ultimately need `check_stock`, but it runs **once per call**. One inventory lookup, two consumers.
74+
* There is nothing to register. The graph *is* the annotations.
75+
76+
!!! check
77+
Don't take once-per-call on faith. Put a `print` in `check_stock` and call `order_book` from the
78+
Inspector: one line per call. Two consumers, one lookup.
79+
80+
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.
81+
82+
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.
83+
84+
!!! warning
85+
On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**,
86+
like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller
87+
is comes from your authorization layer (**Authorization**), not from a header anyone can set.
88+
89+
!!! tip
90+
*Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource
91+
that should outlive a request - a database pool, an HTTP client - belongs in **Lifespan**, and
92+
a resolver can reach it through `ctx.request_context.lifespan_context`.
93+
94+
## Ask when you must
95+
96+
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:
97+
98+
```python title="server.py" hl_lines="26-32 39"
99+
--8<-- "docs_src/dependencies/tutorial003.py"
100+
```
101+
102+
* In stock: `confirm_backorder` returns a `Backorder` directly. **No question, no round-trip.** The user is only interrupted when their answer matters.
103+
* Out of stock: the SDK sends the elicitation, validates the answer against `Backorder`, and injects it. Your resolver never touches the protocol.
104+
* 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.
105+
106+
And if the user won't answer at all - declines the question, or cancels it?
107+
108+
!!! check
109+
Run `order_book` for `Neuromancer` and decline the question. With the annotation written as
110+
`Annotated[Backorder, Resolve(...)]` the tool body never runs; the call fails with an error
111+
result the model can read:
112+
113+
```text
114+
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
115+
```
116+
117+
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.
118+
119+
## Recap
120+
121+
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
122+
* 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.
123+
* 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.
124+
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
125+
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
126+
127+
Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**.

docs/tutorial/elicitation.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ A refusal is not an error. The tool decides what declining means (here, no booki
7979
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
8080
`ValidationError`, your `if` never runs.
8181

82+
## Ask before the tool runs
83+
84+
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.
85+
86+
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:
87+
88+
```python title="server.py" hl_lines="24-30 35-36"
89+
--8<-- "docs_src/elicitation/tutorial004.py"
90+
```
91+
92+
* `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.
93+
* `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.
94+
* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`.
95+
96+
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.
97+
98+
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.
99+
82100
## Send the user to a URL
83101

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

docs_src/dependencies/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import Resolve
7+
8+
mcp = MCPServer("Bookshop")
9+
10+
INVENTORY = {"Dune": 7, "Neuromancer": 0}
11+
12+
13+
class Stock(BaseModel):
14+
title: str
15+
copies: int
16+
17+
18+
async def check_stock(title: str) -> Stock:
19+
return Stock(title=title, copies=INVENTORY.get(title, 0))
20+
21+
22+
@mcp.tool()
23+
async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str:
24+
"""Reserve a copy of a book."""
25+
if stock.copies == 0:
26+
return f"{title!r} is out of stock."
27+
return f"Reserved {title!r} ({stock.copies - 1} copies left)."
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import Resolve
7+
8+
mcp = MCPServer("Bookshop")
9+
10+
INVENTORY = {"Dune": 7, "Neuromancer": 0}
11+
12+
13+
class Stock(BaseModel):
14+
title: str
15+
copies: int
16+
17+
18+
async def check_stock(title: str) -> Stock:
19+
return Stock(title=title, copies=INVENTORY.get(title, 0))
20+
21+
22+
async def estimate_delivery(stock: Annotated[Stock, Resolve(check_stock)]) -> str:
23+
return "tomorrow" if stock.copies > 0 else "in 2-3 weeks"
24+
25+
26+
@mcp.tool()
27+
async def order_book(
28+
title: str,
29+
stock: Annotated[Stock, Resolve(check_stock)],
30+
delivery: Annotated[str, Resolve(estimate_delivery)],
31+
) -> str:
32+
"""Order a book from the shop."""
33+
if stock.copies == 0:
34+
return f"{title!r} is on backorder; it would arrive {delivery}."
35+
return f"Ordered {title!r}; it arrives {delivery}."
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel, Field
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import Elicit, Resolve
7+
8+
mcp = MCPServer("Bookshop")
9+
10+
INVENTORY = {"Dune": 7, "Neuromancer": 0}
11+
12+
13+
class Stock(BaseModel):
14+
title: str
15+
copies: int
16+
17+
18+
class Backorder(BaseModel):
19+
confirm: bool = Field(description="Order anyway and wait?")
20+
21+
22+
async def check_stock(title: str) -> Stock:
23+
return Stock(title=title, copies=INVENTORY.get(title, 0))
24+
25+
26+
async def confirm_backorder(
27+
title: str,
28+
stock: Annotated[Stock, Resolve(check_stock)],
29+
) -> Backorder | Elicit[Backorder]:
30+
if stock.copies > 0:
31+
return Backorder(confirm=True) # in stock: nothing to ask
32+
return Elicit(f"{title!r} is out of stock (2-3 weeks). Order anyway?", Backorder)
33+
34+
35+
@mcp.tool()
36+
async def order_book(
37+
title: str,
38+
stock: Annotated[Stock, Resolve(check_stock)],
39+
backorder: Annotated[Backorder, Resolve(confirm_backorder)],
40+
) -> str:
41+
"""Order a book from the shop."""
42+
if not backorder.confirm:
43+
return "No order placed."
44+
if stock.copies == 0:
45+
return f"Backordered {title!r}; it ships in 2-3 weeks."
46+
return f"Ordered {title!r}."
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import (
7+
AcceptedElicitation,
8+
CancelledElicitation,
9+
DeclinedElicitation,
10+
Elicit,
11+
ElicitationResult,
12+
Resolve,
13+
)
14+
15+
mcp = MCPServer("Files")
16+
17+
_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}
18+
19+
20+
class Confirm(BaseModel):
21+
ok: bool
22+
23+
24+
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
25+
"""Resolver: ask for confirmation only when the folder is not empty."""
26+
file_count = len(_FOLDERS.get(path, []))
27+
if file_count == 0:
28+
return Confirm(ok=True) # nothing to confirm, no round-trip to the client
29+
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
30+
31+
32+
@mcp.tool()
33+
async def delete_folder(
34+
path: str,
35+
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
36+
) -> str:
37+
"""Delete a folder, asking for confirmation when it is not empty."""
38+
match confirm:
39+
case AcceptedElicitation(data=Confirm(ok=True)):
40+
_FOLDERS.pop(path, None)
41+
return f"deleted {path}"
42+
case AcceptedElicitation():
43+
return "kept the folder"
44+
case DeclinedElicitation():
45+
return "declined: folder not deleted"
46+
case CancelledElicitation():
47+
return "cancelled: folder not deleted"

examples/stories/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ opens with a banner saying what replaces it.
130130
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
131131
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current |
132132
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
133+
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
133134
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
134135
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
135136
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |

0 commit comments

Comments
 (0)