Add resolver dependency injection for MCPServer tools

Merged
KL
4mo ago
+1844-15

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 via skip_names, and supplied at call time through arguments_to_pass_directly - so they bypass argument validation exactly like Context does 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 Starlette Request: a new transport-agnostic Context.headers property (None on 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. Annotating ElicitationResult[Login] (or the explicit union) lets the consumer match on 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 InvalidSignature there.

Docs & examples

  • New tutorial page Dependencies (docs/tutorial/dependencies.md), with complete runnable snippets under docs_src/dependencies/ proven by tests/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.md documents the one behavioral change (below).

Notes

  • One behavioral change, documented in the migration guide: ElicitationResult is now a subscriptable TypeAliasType (so ElicitationResult[T] works in annotations) instead of a plain union, which makes a runtime isinstance(result, ElicitationResult) raise TypeError - check against the member classes (AcceptedElicitation etc.) instead. Everything else is additive. New public symbols (Resolve, Elicit, and the re-exported elicitation result types) live in mcp.server.mcpserver, not top-level mcp.
  • Resolvers and their referenced types must be resolvable by typing.get_type_hints, matching the existing limitation for tool parameter types (locally-scoped types under from __future__ import annotations are 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.

AI Disclaimer