Skip to content

Add resolver dependency injection for MCPServer tools#2969

Open
Kludex wants to merge 17 commits into
mainfrom
resolver-dependency-injection
Open

Add resolver dependency injection for MCPServer tools#2969
Kludex wants to merge 17 commits into
mainfrom
resolver-dependency-injection

Conversation

@Kludex

@Kludex Kludex commented Jun 25, 2026

Copy link
Copy Markdown
Member

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 Context, Elicit, MCPServer, Resolve

mcp = MCPServer(name="github")


class Login(BaseModel):
    username: str


class Confirm(BaseModel):
    ok: bool


async def login(ctx: Context) -> Login | Elicit[Login]:
    if username := (ctx.headers or {}).get("x-github-user"):
        return Login(username=username)        # resolved from context, no question
    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.
  • Headers come from the Context, not a Starlette Request: a new transport-agnostic Context.headers property (None on stdio).
  • 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 the elicitation result union lets the consumer match on accept/decline/cancel instead:
@mcp.tool()
async def whoami(
    login: Annotated[AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation, 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.

Notes

  • This is purely additive - no breaking changes. 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 (11 tests, 100% coverage of resolve.py): direct value vs Elicit, accept/decline for both unwrapped and union consumers, nested resolvers, exactly-once memoization, by-name tool-arg injection, schema exclusion, sync resolvers, and registration-time cycle/unresolvable-param errors.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Kludex added 2 commits June 25, 2026 15:44
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`.

The injected type follows the consumer's annotation: the unwrapped model
aborts the call on decline/cancel, while the elicitation result union lets
the consumer branch on the outcome. Resolved parameters are omitted from the
tool's input schema; unclassifiable resolver parameters and cyclic resolver
dependencies raise at registration time.
The headers property's request-present branch and the schema-inspection
helpers in the resolver tests were not exercised, breaking the 100% coverage
gate. Add direct Context.headers tests and mark the never-run helper bodies.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/migration.md">

<violation number="1" location="docs/migration.md:1408">
P2: Example uses DeclinedElicitation/CancelledElicitation without importing them, so the migration snippet is not runnable.</violation>
</file>

<file name="src/mcp/server/mcpserver/tools/base.py">

<violation number="1" location="src/mcp/server/mcpserver/tools/base.py:83">
P2: Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.</violation>

<violation number="2" location="src/mcp/server/mcpserver/tools/base.py:95">
P2: Resolver by-name classification allows argument names that cannot be injected at runtime. This defers a resolvable-signature error into a runtime failure.</violation>
</file>

<file name="src/mcp/server/mcpserver/resolve.py">

<violation number="1" location="src/mcp/server/mcpserver/resolve.py:97">
P3: `find_resolved_parameters` iterates `hints.items()` which includes `'return'` from `get_type_hints`. Should filter to actual parameters (e.g., using `inspect.signature`).</violation>

<violation number="2" location="src/mcp/server/mcpserver/resolve.py:110">
P1: `_wants_union` misclassifies `AcceptedElicitation[T]` annotations, so tools requesting a specific accepted-result member get unwrapped model data instead of the elicitation result object.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/mcp/server/mcpserver/resolve.py
Comment thread docs/migration.md Outdated
Comment thread src/mcp/server/mcpserver/tools/base.py Outdated
if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

resolved_params = find_resolved_parameters(fn)

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/mcpserver/tools/base.py, line 83:

<comment>Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.</comment>

<file context>
@@ -67,13 +80,23 @@ def from_function(
         if context_kwarg is None:  # pragma: no branch
             context_kwarg = find_context_parameter(fn)
 
+        resolved_params = find_resolved_parameters(fn)
+
+        skip_names = [context_kwarg] if context_kwarg is not None else []
</file context>
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an issue. find_resolved_parameters resolves hints via _type_hints, which catches the failure and returns {} (no raw NameError). func_metadata is then called and raises a proper InvalidSignature for unresolvable annotations, so the existing error path is preserved. Verified.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment was wrong here: find_resolved_parameters already swallows unresolved hints and func_metadata still raises InvalidSignature, so there’s no raw NameError leak in this path.

Comment thread src/mcp/server/mcpserver/resolve.py Outdated
find_resolved_parameters called typing.get_type_hints on the callable
directly, which raises for a callable instance (an object with __call__),
breaking tool registration for callable objects. Resolve hints off __call__
and tolerate unresolvable hints, mirroring find_context_parameter.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/mcpserver/tools/base.py">

<violation number="1" location="src/mcp/server/mcpserver/tools/base.py:83">
P2: Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.</violation>
</file>

<file name="src/mcp/server/mcpserver/resolve.py">

<violation number="1" location="src/mcp/server/mcpserver/resolve.py:110">
P1: `_wants_union` misclassifies `AcceptedElicitation[T]` annotations, so tools requesting a specific accepted-result member get unwrapped model data instead of the elicitation result object.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/mcp/server/mcpserver/resolve.py
Comment thread src/mcp/server/mcpserver/resolve.py
Kludex added 3 commits June 25, 2026 17:20
After merging main, LATEST_PROTOCOL_VERSION is 2026-07-28, which defines no
server-to-client requests, so elicitation/create is unavailable at the
default negotiated version. Pin these tests to mode='legacy' (negotiates
2025-11-25) where elicitation is supported, matching test_elicitation.py.
…esolver naming

- tools/base.py: build tool_arg_names as 'alias or field_name' to match the
  runtime kwarg keys, so a by-name resolver param on an aliased field resolves
  instead of raising KeyError at call time.
- resolve.py: iterate inspect.signature params (not get_type_hints items, which
  include 'return') so a Resolve marker on a return annotation is ignored; add
  _resolver_name so callable-object resolvers raise InvalidSignature instead of
  AttributeError in error messages.
- migration.md: import DeclinedElicitation/CancelledElicitation used in the
  branching example so the snippet is runnable.

Add regression tests for each.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs in this implementation, but this PR introduces a new public API surface (Resolve/Elicit dependency injection) and modifies the tool-call execution path, so it warrants a maintainer's review of the design and API choices.

Extended reasoning...

Overview

This PR adds a resolver dependency-injection framework for @mcp.tool() functions: a new ~280-line resolve.py module (resolver detection, DAG analysis with cycle detection, per-call memoized execution, elicitation handling), new public exports from mcp.server.mcpserver (Resolve, Elicit, ElicitationResult and the elicitation result types), a new transport-agnostic Context.headers property, integration into Tool.from_function/Tool.run, and a small refactor of FuncMetadata argument validation. It includes 11 new tests for the resolver module plus two for Context.headers, and a migration-guide section.

Security risks

The feature itself is server-side and additive, but it does introduce two things worth a human's eye: Context.headers exposes raw transport headers to tool/resolver code (the documented pattern derives identity from a header like x-github-user, which is only safe behind a trusted proxy/auth layer), and resolvers bypass the tool's JSON-schema argument validation by design (like Context injection today). Neither is an injection or auth-bypass bug in the SDK, but the API encourages patterns whose security depends on deployment context.

Level of scrutiny

High. This is a new public API with non-trivial design decisions (decline/cancel semantics driven by the consumer's annotation, function-identity memoization, by-name tool-arg injection including alias handling, sync resolvers running in a thread) and it touches the tool-call execution path used by every MCPServer tool. API-surface additions to an SDK are the kind of decision maintainers should explicitly own, regardless of implementation correctness.

Other factors

The implementation is well-tested (the new test_resolve.py covers accept/decline, nesting, memoization, schema exclusion, registration-time errors, aliasing, and callable-object resolvers), and the most recent commits already address the earlier cubic reviewer findings (by-name aliasing, return-annotation handling, callable-resolver naming). My review and the bug-hunting pass found no concrete bugs; the deferral is purely about scope and API design ownership, not correctness concerns.

…nd-method memoization

bughunter findings on #2969:
- Resolvers may return any type, not just BaseModel. Wrapping the return in
  AcceptedElicitation(data=...) validated it against the schema bound, so e.g.
  Annotated[str, Resolve(get_token)] failed every call with a cryptic
  ValidationError. Use model_construct to wrap the value without validation
  (the Elicit[T] path still validates via ctx.elicit).
- _is_context_annotation now unwraps unions, so a resolver param typed
  Context | None is accepted, matching find_context_parameter on tools.
- Memoize resolvers by the callable itself (hash/eq) instead of id(fn), so a
  bound-method resolver referenced as auth.login in two places runs at most
  once and participates in cycle detection. Fresh bound-method objects share
  identity by (__func__, __self__).

Add regression tests for each.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/mcpserver/tools/base.py">

<violation number="1" location="src/mcp/server/mcpserver/tools/base.py:83">
P2: Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.</violation>
</file>

<file name="src/mcp/server/mcpserver/resolve.py">

<violation number="1" location="src/mcp/server/mcpserver/resolve.py:110">
P1: `_wants_union` misclassifies `AcceptedElicitation[T]` annotations, so tools requesting a specific accepted-result member get unwrapped model data instead of the elicitation result object.</violation>

<violation number="2" location="src/mcp/server/mcpserver/resolve.py:210">
P2: Context detection is over-broad: any generic containing `Context` is treated as a context parameter, not just union/optional context annotations.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment on lines +210 to +211
candidates = get_args(annotation) if get_origin(annotation) is not None else (annotation,)
return any(isinstance(c, type) and issubclass(c, Context) for c in candidates)

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Context detection is over-broad: any generic containing Context is treated as a context parameter, not just union/optional context annotations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/mcpserver/resolve.py, line 210:

<comment>Context detection is over-broad: any generic containing `Context` is treated as a context parameter, not just union/optional context annotations.</comment>

<file context>
@@ -193,12 +207,13 @@ def _resolve_marker(annotation: Any) -> tuple[Resolve | None, bool]:
     if get_origin(annotation) is Annotated:
         annotation = get_args(annotation)[0]
-    return isinstance(annotation, type) and issubclass(annotation, Context)
+    candidates = get_args(annotation) if get_origin(annotation) is not None else (annotation,)
+    return any(isinstance(c, type) and issubclass(c, Context) for c in candidates)
 
</file context>
Suggested change
candidates = get_args(annotation) if get_origin(annotation) is not None else (annotation,)
return any(isinstance(c, type) and issubclass(c, Context) for c in candidates)
origin = get_origin(annotation)
is_union = origin is typing.Union or (
origin is not None and origin.__module__ == "types" and origin.__name__ == "UnionType"
)
candidates = get_args(annotation) if is_union else (annotation,)
return any(isinstance(c, type) and issubclass(c, Context) for c in candidates)
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional - this mirrors find_context_parameter exactly, which also matches any generic containing Context (e.g. it returns a param for list[Context] too). Restricting resolvers to unions-only would re-introduce the tool/resolver asymmetry that the earlier review asked me to fix. If the broad match is wrong it should be fixed in both places at once; keeping them in sync here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment was too broad here: this resolver check intentionally mirrors find_context_parameter, so narrowing it to unions-only would reintroduce the tool/resolver asymmetry. If the broad match is wrong, both paths should be changed together.

Comment thread src/mcp/server/mcpserver/resolve.py Outdated
@Kludex

Kludex commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Addressed the three bughunter findings in aac86dc:

  1. Non-BaseModel resolver returns — wrapping the return in AcceptedElicitation(data=...) validated it against the BaseModel bound, so Annotated[str, Resolve(get_token)] failed every call. Now wrapped with model_construct (no validation); the Elicit[T] path still validates via ctx.elicit.
  2. Context | None on resolvers_is_context_annotation now unwraps unions, matching find_context_parameter on tools.
  3. Bound-method memoization — resolvers are keyed by the callable (hash/eq by (__func__, __self__)) instead of id(fn), so auth.login referenced twice runs at most once and participates in cycle detection.

Each has a regression test. 100% coverage, CI green.

Comment thread src/mcp/server/mcpserver/tools/base.py
Review follow-ups on #2969:
- Tool.run validated arguments twice when resolvers were present (once to feed
  resolvers, once in call_fn_with_arg_validation). A field with default_factory
  or a stateful validator could hand a by-name resolver a different value than
  the tool body. Validate once and pass it through via a new pre_validated
  argument so both observe the same value.
- Key the resolver cache/plans by (id(__func__), id(__self__)) for bound methods
  and id(fn) otherwise, instead of the callable's equality, so two distinct
  callables that compare equal can no longer share a plan/cache entry while
  bound-method memoization still works.

Add regression tests.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/mcpserver/tools/base.py">

<violation number="1" location="src/mcp/server/mcpserver/tools/base.py:83">
P2: Tool registration can leak raw NameError for unresolved type hints. This breaks the existing InvalidSignature error path and returns less actionable failures.</violation>
</file>

<file name="src/mcp/server/mcpserver/resolve.py">

<violation number="1" location="src/mcp/server/mcpserver/resolve.py:210">
P2: Context detection is over-broad: any generic containing `Context` is treated as a context parameter, not just union/optional context annotations.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/mcp/server/mcpserver/resolve.py
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier double-validation concern is resolved in 37c038c (single validation pass threaded through as pre_validated, with a regression test) and I found no new issues — but this introduces a new public DI API (Resolve/Elicit), new resolver-graph machinery, and changes to the tool call path, so it warrants a maintainer's review of the API design rather than auto-approval.

Extended reasoning...

Overview

This PR adds resolver dependency injection for MCPServer tools: a new ~290-line resolve.py module (Resolve/Elicit markers, resolver DAG analysis, per-call memoized resolution, elicitation integration), new public exports from mcp.server.mcpserver, a transport-agnostic Context.headers property, changes to Tool.from_function/Tool.run, a new validate_arguments/pre_validated seam in func_metadata, plus extensive tests (19 in test_resolve.py) and migration docs.

Security risks

Resolvers run server-defined callables only — the calling client cannot inject or select resolvers, and resolved parameters are stripped from the input schema so clients cannot supply them. Context.headers exposes request headers to tool/resolver code, which is intentional and transport-scoped (None on stdio); no auth or crypto code is touched. The AcceptedElicitation.model_construct path skips validation by design for non-elicited resolver returns, which is internal data, not client-supplied. I see no concrete injection or bypass risk, but the elicitation-driven flow does change how user-confirmation steps are wired, which deserves a human eye.

Level of scrutiny

High. This is a feature-level addition with new public API surface and design decisions (annotation-driven unwrap-vs-union semantics, resolver identity/memoization keys, by-name argument injection, registration-time cycle detection) that a maintainer should deliberately sign off on — it is not a mechanical or pattern-following change. It also modifies the hot path of every tool call (Tool.run, call_fn_with_arg_validation), even though the non-resolver path is behaviorally unchanged.

Other factors

The author has been responsive: all cubic findings were either fixed (with regression tests) or rebutted with verification, and my prior inline finding about double argument validation was fixed in 37c038c by threading a single validated pass through pre_validated — I checked the current code and the fix looks correct. Test coverage of the new module is thorough (accept/decline/cancel, nesting, memoization, aliasing, sync resolvers, registration-time errors). The remaining considerations are design-level (public API shape, scoping to tools only, default-mode interaction with the 2026-07-28 elicitation changes), which is exactly what human review is for.

Review follow-ups on #2969:
- _resolver_key now keys any bound method (pure-python or built-in) by its
  underlying function/name plus __self__ identity, so a built-in bound method
  (no __func__, fresh object each access) referenced twice still memoizes to
  one call.
- call_fn_with_arg_validation copies the validated args before merging the
  injected kwargs, so a caller-provided pre_validated dict is never mutated.

Add regression tests.
Comment thread src/mcp/server/mcpserver/resolve.py
Kludex added 3 commits June 26, 2026 09:49
…form works

Codex review caught that the documented Annotated[ElicitationResult[Login],
Resolve(login)] form silently dropped the resolver: ElicitationResult was a
collapsed union alias (not subscriptable), so under 'from __future__ import
annotations' get_type_hints raised, _type_hints swallowed it, and the parameter
stayed client-supplied with the resolver never running.

Redefine ElicitationResult via TypeAliasType so ElicitationResult[T] is
genuinely subscriptable, and teach _wants_union to unwrap the alias. Update the
migration doc to use the clean ElicitationResult[T] form. Add a regression test
exercising the postponed-annotations path.
Comment thread src/mcp/server/mcpserver/resolve.py
Replace the GitHub star example with a delete_folder tool: the confirm_delete
resolver lists the folder by reading the tool's own path argument and only
elicits when the folder is non-empty (an empty folder resolves to ok=True with
no round-trip). delete_folder annotates ElicitationResult[Confirm] and handles
every outcome - accept-and-delete, accept-but-keep, decline, and cancel.

Add end-to-end tests covering all five paths; the cancel path now exercises
elicit_with_validation's cancel branch (pragma removed).
Comment thread src/mcp/server/mcpserver/resolve.py
@Kludex

Kludex commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Reviewer's guide

This is the base of a two-PR stack. It adds the Resolve/Elicit resolver dependency-injection API for @mcp.tool(); #2986 (stacked on this branch) makes it work over the 2026-07-28 input_required transport. Review this one first.

Where to look

  • src/mcp/server/mcpserver/resolve.py (new, ~310 lines) — the whole feature: the Resolve/Elicit markers, registration-time DAG analysis (build_resolver_plans, cycle detection), and the runtime resolver walk (resolve_arguments).
  • tools/base.py — how Tool.from_function detects Resolve params (excludes them from the JSON schema via skip_names, like Context) and Tool.run injects them via arguments_to_pass_directly.
  • func_metadata.pyvalidate_arguments extracted so resolvers and the tool body share one validation pass.
  • context.py — new Context.headers (transport-agnostic).

Design decisions worth a second look

  1. Mechanism reuses the Context-injection seam exactly: detect at registration → skip_names → supply post-validation. No new lowlevel surface.
  2. Annotation drives unwrap-vs-branch. Annotated[Login, Resolve(login)] injects the unwrapped model and aborts on decline/cancel; Annotated[ElicitationResult[Login], Resolve(login)] injects the full outcome so the tool can match accept/decline/cancel. The injected type follows the consumer's annotation — no extra API.
  3. ElicitationResult made subscriptable (TypeAliasType) so the documented ElicitationResult[T] form works under from __future__ import annotations.
  4. Exactly-once is per-call resolver memoization (keyed by callable identity, bound-method-aware).

Not in scope (deliberate): resolvers on resources/prompts (tools only); cross-call idempotency.

Reviewed by cubic + Claude + a bughunter pass; all findings fixed. CI green, 100% coverage. The example in docs/migration.md (a delete_folder confirmation flow) is runnable.

Kludex added 2 commits June 26, 2026 17:04
- A `Resolve(...)` marker nested in a union (e.g. Annotated[T, Resolve(f)] | None)
  was silently dropped, leaving the param in the LLM schema and never running the
  resolver. Raise InvalidSignature at registration instead.
- The bare `ElicitationResult` alias (no [T]) now opts into the result union like
  the subscripted form, rather than being treated as wanting the unwrapped model.

Add regression tests for both.
Comment thread docs/migration.md
Comment on lines +1476 to +1480
### Resolver dependency injection for tools (`Resolve` / `Elicit`)

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 `ctx.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. A resolver only elicits when it needs to - it can also resolve a value directly and skip the question. Each resolver runs at most once per `tools/call`.

The injected type follows the consumer's annotation. Annotating the unwrapped model (`Annotated[Confirm, Resolve(confirm)]`) injects the model on accept and aborts the call with an error result on decline or cancel. To branch on the outcome instead - so the tool can react to decline and cancel - annotate `ElicitationResult[Confirm]` (or an explicit `AcceptedElicitation[Confirm] | DeclinedElicitation | CancelledElicitation` union):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This PR adds new public API (Resolve, Elicit, the ElicitationResult/result-type re-exports on mcp.server.mcpserver, and Context.headers), but the only docs change is the new section in the v1→v2 migration guide. Per AGENTS.md's documentation guideline, the relevant topic pages should be updated in the same PR — docs/tutorial/tools.md and docs/tutorial/elicitation.md for resolver dependency injection, and docs/tutorial/context.md for ctx.headers — none of which currently mention the feature.

Extended reasoning...

What the issue is

AGENTS.md's Documentation section states: "When a change affects public API or user-visible behaviour, update the relevant page(s) under docs/ in the same PR. Docs are organised by topic (tutorial/, client/, run/, advanced/) — find the page covering the feature you touched rather than adding a new one." This PR introduces substantial new user-facing API — Resolve, Elicit, and the ElicitationResult/AcceptedElicitation/DeclinedElicitation/CancelledElicitation re-exports on mcp.server.mcpserver, plus the new Context.headers property — yet the only documentation change is the "Resolver dependency injection for tools" section appended to docs/migration.md.

Where the gap shows up

The repo already has topic pages covering exactly the touched areas, and none of them are updated:

  • docs/tutorial/tools.md — covers tool parameters, schemas, and injection; says nothing about Resolve or how resolved parameters are excluded from the input schema.
  • docs/tutorial/elicitation.md — covers elicitation flows via ctx.elicit; says nothing about Elicit[T], resolver-driven elicitation, or annotating the result union to branch on accept/decline/cancel.
  • docs/tutorial/context.md — enumerates the Context object's surface and does not mention the new ctx.headers property at all.

A grep across docs/ confirms Resolve(, Elicit, and ctx.headers appear nowhere except docs/migration.md.

Why this matters

AGENTS.md positions docs/migration.md as the place to document breaking changes, while this PR is explicitly purely additive. A user looking for "how do I inject server-resolved values into a tool" or "how do I read request headers from the Context" will go to the tools/elicitation/context tutorials — not to a v1→v2 migration guide's "New Features" appendix — and will find nothing. Comparable additive features in the same v2 cycle do have topic-page coverage (e.g. lowlevel streamable_http_app in docs/advanced/low-level-server.md, report_progress in docs/tutorial/context.md and progress.md, InputRequiredResult in docs/advanced/multi-round-trip.md), so documenting a feature only in the migration guide is the exception, not the established pattern.

Concrete walkthrough

  1. A v2 user (not migrating from v1) wants their tool to confirm a destructive action with the user before running. They open docs/tutorial/elicitation.md.
  2. That page only describes await ctx.elicit(...) inside the tool body; it never mentions that a resolver returning Elicit[Confirm] can do the same thing declaratively, with memoization and schema exclusion handled by the framework.
  3. Likewise, a user wanting to read an x-github-user header opens docs/tutorial/context.md, which lists the Context's properties — request_id, ctx.elicit, etc. — but not headers, so they may conclude the SDK doesn't expose headers and reach for a Starlette Request instead.
  4. The feature exists and is well-documented — but only in docs/migration.md, which neither of these users has a reason to open.

Mitigating context (why this is a nit, not a blocker)

The migration.md section itself is thorough and the example is runnable (and tested in test_resolve.py). There is some recent precedent of feature PRs touching only migration.md (e.g. #2990), and this PR is the base of a stacked pair (#2986), so tutorial coverage could plausibly land as a follow-up. This is a documentation-completeness/convention issue, not a code defect.

How to fix

Add a resolver-dependency-injection section (or cross-link) to docs/tutorial/tools.md and/or docs/tutorial/elicitation.md, and add ctx.headers to the Context surface described in docs/tutorial/context.md — either in this PR or as an explicit follow-up.

Comment on lines +40 to +44
ElicitationResult = TypeAliasType(
"ElicitationResult",
AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation,
type_params=(ElicitSchemaModelT,),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changing ElicitationResult from a PEP 604 class union to a TypeAliasType is a runtime behavior change for an existing public symbol: isinstance(result, ElicitationResult) previously returned True/False but now raises TypeError, since a TypeAliasType is not a valid second argument to isinstance(). The change itself is justified (it makes ElicitationResult[T] subscriptable), but consider adding a one-line note to docs/migration.md that isinstance checks should target the member classes (AcceptedElicitation/DeclinedElicitation/CancelledElicitation) directly.

Extended reasoning...

What changed

Before this PR, ElicitationResult in src/mcp/server/elicitation.py was a plain PEP 604 union: AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation. Because pydantic returns the class itself when a generic model is subscripted with its own bare TypeVar, AcceptedElicitation[ElicitSchemaModelT] evaluated to the AcceptedElicitation class, so the alias was a types.UnionType of three plain classes — a perfectly valid second argument to isinstance(). This PR rewraps it in typing_extensions.TypeAliasType (lines 40–44) so that ElicitationResult[T] is subscriptable, which the new Resolve/Elicit docs and _wants_union machinery rely on.

The behavior change

A TypeAliasType is not a type, a tuple of types, or a union, so isinstance(x, ElicitationResult) now raises TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union instead of returning a boolean. ElicitationResult is an existing importable symbol from mcp.server.elicitation, is the declared return type of Context.elicit() and elicit_with_validation(), and this PR additionally re-exports it from mcp.server.mcpserver — so user code that branched on an elicitation outcome with isinstance(result, ElicitationResult) breaks at runtime after upgrading.

Step-by-step proof

  1. Pre-PR: ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation. Evaluating AcceptedElicitation[ElicitSchemaModelT] returns the AcceptedElicitation class (pydantic's behavior for a bare-TypeVar subscription), so the whole expression is a types.UnionType of three classes.
  2. isinstance(DeclinedElicitation(), ElicitationResult)True (verified empirically against the old definition).
  3. Post-PR: ElicitationResult is TypeAliasType("ElicitationResult", ..., type_params=(ElicitSchemaModelT,)).
  4. The same call now raises TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union (also verified empirically).

Why nothing in the PR catches it

No SDK code, tests, examples, or docs ever used isinstance against the full alias (the documented idioms branch on result.action or match/isinstance against the individual member classes), so the test suite stays green and nothing flags the change. The new migration.md section only documents the Resolve/Elicit feature; AGENTS.md asks for behavior/breaking changes — even softened ones — to be noted in docs/migration.md, and this one isn't.

Impact and severity

The blast radius is small: the old isinstance support was an accident of pydantic's bare-TypeVar subscription rather than a designed API, and the documented patterns are unaffected. The TypeAliasType change itself is intentional and necessary — subscriptability and isinstance-ability cannot both be had with a plain union, and the PR's own ElicitationResult[Confirm] examples depend on it. Hence this is a nit, not a blocker.

Suggested fix

Add a brief note to docs/migration.md (e.g. under the new Resolve/Elicit section): ElicitationResult is now a TypeAliasType so it can be subscripted as ElicitationResult[T]; it can no longer be used as the second argument to isinstance() — check result.action or isinstance against AcceptedElicitation/DeclinedElicitation/CancelledElicitation directly. Optionally, the same hint could go in the alias's docstring.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 6b10702 - a migration-guide note that ElicitationResult is now a subscriptable alias, so isinstance checks should target the member classes (AcceptedElicitation/DeclinedElicitation/CancelledElicitation) directly.

…tx.headers in context

Add a tested docs_src/elicitation/tutorial004.py (the delete_folder resolver flow)
and an 'Ask before the tool runs' section to docs/tutorial/elicitation.md, plus a
ctx.headers bullet to docs/tutorial/context.md. Tests prove the resolver asks only
for a non-empty folder, hides the resolved param from the schema, and branches on
every outcome.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant