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
3 changed files with 1844 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue