Add resolver dependency injection for MCPServer tools
Summary
Adds server-side dependency injection for @mcp.tool() functions. A tool parameter annotated Annotated[T, Resolve(fn)] is filled by running the resolver fn before the tool body, instead of by the calling LLM. Resolvers form a dependency graph: a resolver may declare its own Resolve(...) dependencies, read the Context (including the new Context.headers), and receive the tool's own arguments by name. A resolver may return Elicit[T] to ask the client; the SDK runs the elicitation and injects the answer. Each resolver runs at most once per tools/call.
from typing import Annotated
from pydantic import BaseModel
from mcp.server.mcpserver import Elicit, MCPServer, Resolve
mcp = MCPServer(name="github")
class Login(BaseModel):
username: str
class Confirm(BaseModel):
ok: bool
async def login() -> Login | Elicit[Login]:
if username := saved_login(): # already known: no question
return Login(username=username)
return Elicit("GitHub username?", Login) # must ask
async def confirm(repo: str, login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
return Elicit(f"Star {repo} as {login.username}?", Confirm)
@mcp.tool()
async def star_repo(
repo: str,
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
"""Star a GitHub repo."""
return f"starred {repo} as {login.username}" if confirm.ok else "cancelled"
Design
- Mechanism reuses the existing
Context-injection seam: resolvers are detected at registration (Tool.from_function), excluded from the tool's JSON input schema viaskip_names, and supplied at call time througharguments_to_pass_directly- so they bypass argument validation exactly likeContextdoes today. A client that sends a value for a resolved parameter anyway is ignored: the resolver's value is the only one the tool can receive. - Headers come from the
Context, not a StarletteRequest: a new transport-agnosticContext.headersproperty (Noneon stdio, or when the transport's request object carries no headers). Headers are client-supplied input - fine for a locale or a feature flag; an identity still comes from the authorization layer, never from a header the caller can set. The docs say this explicitly. - Exactly-once is per-call resolver memoization (keyed by function identity); cross-call idempotency is intentionally out of scope.
- Decline/cancel handling is driven by the consumer's annotation. Annotating the unwrapped model (
Annotated[Login, Resolve(login)]) injects the model on accept and aborts the call with an error result on decline/cancel. AnnotatingElicitationResult[Login](or the explicit union) lets the consumermatchon accept/decline/cancel instead:
@mcp.tool()
async def whoami(
login: Annotated[ElicitationResult[Login], Resolve(login)],
) -> str:
match login:
case AcceptedElicitation(data=data):
return f"hi {data.username}"
case _:
return "no username provided"
- The resolver dependency graph is analyzed once at registration: unclassifiable resolver parameters and cyclic dependencies raise
InvalidSignaturethere.
Docs & examples
- New tutorial page Dependencies (
docs/tutorial/dependencies.md), with complete runnable snippets underdocs_src/dependencies/proven bytests/docs_src/test_dependencies.py(8 tests). The elicitation tutorial gained an "Ask before the tool runs" section for the eliciting form. - New story
examples/stories/refund_desk/: a back-office refund server where the amount is resolver-computed from the order record and never appears in the input schema, so the model cannot supply it. Runs in the stories matrix (uv run python -m stories.refund_desk.client). docs/migration.mddocuments the one behavioral change (below).
Notes
- One behavioral change, documented in the migration guide:
ElicitationResultis now a subscriptableTypeAliasType(soElicitationResult[T]works in annotations) instead of a plain union, which makes a runtimeisinstance(result, ElicitationResult)raiseTypeError- check against the member classes (AcceptedElicitationetc.) instead. Everything else is additive. New public symbols (Resolve,Elicit, and the re-exported elicitation result types) live inmcp.server.mcpserver, not top-levelmcp. - Resolvers and their referenced types must be resolvable by
typing.get_type_hints, matching the existing limitation for tool parameter types (locally-scoped types underfrom __future__ import annotationsare not resolvable - this is pre-existing, not introduced here). - Scoped to tools; resources/prompts use a different injection path and can follow.
Tests
tests/server/mcpserver/test_resolve.py (26 tests, 100% coverage of resolve.py): direct value vs Elicit, accept/decline for both unwrapped and union consumers, nested resolvers, exactly-once memoization (including bound-method resolvers), by-name tool-arg injection incl. aliased fields, schema exclusion, sync resolvers, non-BaseModel resolver returns, single-validation-pass semantics, and registration-time cycle/unresolvable-param errors. Plus the docs-snippet tests and the refund_desk story legs above.