Skip to content

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

server.py
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)."
  • 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:

{
  "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:

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:

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:

server.py
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}."
  • 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:

server.py
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}."
  • 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:

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.