Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e58ff02
Add resolver dependency injection for MCPServer tools
Kludex Jun 25, 2026
e110093
Cover Context.headers and resolver schema-only paths
Kludex Jun 25, 2026
cafe8f3
Resolve type hints for callable-object tools in resolver detection
Kludex Jun 25, 2026
3f59ea3
Merge remote-tracking branch 'origin/main' into worktree-synthetic-si…
Kludex Jun 25, 2026
9e9282a
Pin elicitation resolver tests to legacy mode for 2026-07-28 default
Kludex Jun 25, 2026
c3ea531
Address cubic review: by-name aliasing, return-annotation, callable-r…
Kludex Jun 25, 2026
aac86dc
Fix resolver edge cases: non-BaseModel returns, optional Context, bou…
Kludex Jun 25, 2026
37c038c
Validate resolver tool args once; key resolvers by method identity
Kludex Jun 25, 2026
58238b1
Memoize built-in bound-method resolvers; stop mutating pre_validated
Kludex Jun 25, 2026
b7b8967
Make ElicitationResult subscriptable so the documented Resolve union …
Kludex Jun 26, 2026
163721f
Merge remote-tracking branch 'origin/main' into worktree-synthetic-si…
Kludex Jun 26, 2026
b0424da
Update test_resolve imports to mcp_types after the mcp-types package …
Kludex Jun 26, 2026
8f677c9
Switch resolver docs/example to a delete-folder confirmation flow
Kludex Jun 26, 2026
d22ce97
Merge remote-tracking branch 'origin/main' into resolver-dependency-i…
Kludex Jun 26, 2026
800d253
Reject union-wrapped Resolve; honor the bare ElicitationResult alias
Kludex Jun 26, 2026
6b10702
Note the ElicitationResult isinstance behavior change in the migratio…
Kludex Jun 26, 2026
f2106f5
Document resolver dependency injection in the elicitation tutorial; c…
Kludex Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,65 @@ app = server.streamable_http_app(

The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`.

### 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):
Comment thread
claude[bot] marked this conversation as resolved.

```python
from typing import Annotated

from pydantic import BaseModel

from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
MCPServer,
Resolve,
)

mcp = MCPServer(name="files")


class Confirm(BaseModel):
ok: bool


async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
file_count = len(list_files(path))
if file_count == 0:
return Confirm(ok=True) # empty folder: nothing to confirm, no question
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)


@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
"""Delete a folder, asking for confirmation when it is not empty."""
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
delete(path)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation():
return "cancelled: folder not deleted"
```

The `confirm_delete` resolver reads the tool's own `path` argument by name, lists the folder, and only elicits when the folder is non-empty - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. Because `delete_folder` annotates the result union, it handles every outcome: the user accepting and confirming, accepting but declining to delete (`ok=False`), declining the elicitation, or cancelling it.

Resolved parameters are omitted from the tool's input schema, so the client never supplies them. Resolver parameters that cannot be classified, and cyclic resolver dependencies, raise at registration time.

`ElicitationResult` is now a subscriptable generic alias (so `ElicitationResult[T]` works in annotations) instead of a plain union. A runtime `isinstance(result, ElicitationResult)` therefore raises `TypeError`; check against the member classes directly - `isinstance(result, AcceptedElicitation)` (or `DeclinedElicitation` / `CancelledElicitation`).

## Need Help?

If you encounter issues during migration:
Expand Down
1 change: 1 addition & 0 deletions docs/tutorial/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-...")`.
* `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.
Expand Down
16 changes: 16 additions & 0 deletions docs/tutorial/elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ 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.

## 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:
Expand Down
47 changes: 47 additions & 0 deletions docs_src/elicitation/tutorial004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)

mcp = MCPServer("Files")

_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}


class Confirm(BaseModel):
ok: bool


async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
"""Resolver: ask for confirmation only when the folder is not empty."""
file_count = len(_FOLDERS.get(path, []))
if file_count == 0:
return Confirm(ok=True) # nothing to confirm, no round-trip to the client
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)


@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
"""Delete a folder, asking for confirmation when it is not empty."""
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
_FOLDERS.pop(path, None)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation():
return "cancelled: folder not deleted"
7 changes: 6 additions & 1 deletion src/mcp/server/elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pydantic import BaseModel, ValidationError
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
from typing_extensions import TypeAliasType

from mcp.server.session import ServerSession

Expand All @@ -36,7 +37,11 @@ class CancelledElicitation(BaseModel):
action: Literal["cancel"] = "cancel"


ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation
ElicitationResult = TypeAliasType(
"ElicitationResult",
AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation,
type_params=(ElicitSchemaModelT,),
)
Comment thread
claude[bot] marked this conversation as resolved.


class AcceptedUrlElicitation(BaseModel):
Expand Down
22 changes: 21 additions & 1 deletion src/mcp/server/mcpserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,27 @@
from mcp_types import Icon

from .context import Context
from .resolve import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
from .server import MCPServer
from .utilities.types import Audio, Image

__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
__all__ = [
"MCPServer",
"Context",
"Image",
"Audio",
"Icon",
"Resolve",
"Elicit",
"ElicitationResult",
"AcceptedElicitation",
"DeclinedElicitation",
"CancelledElicitation",
]
20 changes: 18 additions & 2 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, Generic
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Generic, Protocol, cast

from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel
from pydantic import AnyUrl, BaseModel
Expand All @@ -22,6 +22,11 @@
from mcp.server.mcpserver.server import MCPServer


class _HasHeaders(Protocol):
@property
def headers(self) -> Mapping[str, str]: ...


class Context(BaseModel, Generic[LifespanContextT, RequestT]):
"""Context object providing access to MCP capabilities.

Expand Down Expand Up @@ -217,6 +222,17 @@ def client_id(self) -> str | None:
"""
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover

@property
def headers(self) -> Mapping[str, str] | None:
"""Request headers carried by this message, when the transport has them.

Populated by HTTP-based transports; `None` on stdio.
"""
request = self.request_context.request
if request is None:
return None
return cast("_HasHeaders", request).headers

@property
def request_id(self) -> str:
"""Get the unique ID for this request."""
Expand Down
Loading
Loading