Extend resolver DI to sampling and roots requests (#3049)

This commit is contained in:
Max 2026-07-06 18:25:57 +01:00 • committed by GitHub
commit d287c9868f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1134 additions and 200 deletions

View file

@ -78,6 +78,8 @@ When a client connects it declares its `capabilities`, the mirror image of the s
| `list_roots_callback=` | `"roots": {"listChanged": true}` | | `list_roots_callback=` | `"roots": {"listChanged": true}` |
| none of them | `{}` | | none of them | `{}` |
Sampling sub-capabilities are the one refinement: pass `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` alongside `sampling_callback` when your sampler handles the `tools` / `tool_choice` parameters. Servers must see `sampling.tools` declared before they can send them.
`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability. `logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.
The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does: The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:

View file

@ -134,6 +134,18 @@ That's the right default for a precondition: no answer, no order. When declining
to bind to. A question built from such volatile data makes every recorded answer look stale, to bind to. A question built from such volatile data makes every recorded answer look stale,
so the server re-asks it on every round until the client's round limit ends the call. so the server re-asks it on every round until the client's round limit ends the call.
## Ask the client, not the user
Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`:
```python title="server.py" hl_lines="11-16 22"
--8<-- "docs_src/dependencies/tutorial004.py"
```
* The framework routes these exactly like `Elicit`: inside the multi-round-trip `tools/call` on **2026-07-28**, over the standalone server->client request on **2025-11-25**. An undeclared capability refuses the call with a `-32021` protocol error (`sampling`, `roots`, form-mode `elicitation`; `sampling.tools` when the request carries `tools` or `tool_choice`).
* Everything the info box above says about questions applies unchanged: a `Sample` request is matched to its recorded result by its exact rendering, so build it deterministically from the tool's arguments and earlier answers; the client then pays for the LLM call once per tool call, not once per round. The recorded result rides `request_state` for the rest of the call, so a very large completion makes every remaining round-trip heavier.
* The standalone sampling and roots *features* are deprecated at 2026-07-28 (SEP-2577). New servers that need the client's model ask through this carrier; servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them.
## Recap ## Recap
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. * `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
@ -141,5 +153,6 @@ That's the right default for a precondition: no answer, no order. When declining
* 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 round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question. * 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 round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question.
* Bad graphs fail at registration with `InvalidSignature`, not mid-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. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
* Return `Sample(...)` or `ListRoots()` to ask the client for an LLM completion or the roots list; the plain result is injected.
The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page. The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page.

View file

@ -18,6 +18,9 @@ What it can do while it runs:
* Ask the user for more input with **[Elicitation](elicitation.md)**, and * Ask the user for more input with **[Elicitation](elicitation.md)**, and
**[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28 **[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28
pattern that carries it. pattern that carries it.
* Ask the client for an LLM completion or its workspace folders with
**[Sampling and roots](sampling-and-roots.md)**, deprecated but still
served.
* Report **[Progress](progress.md)** on something slow. * Report **[Progress](progress.md)** on something slow.
* Write logs (to standard error, for whoever operates the server) with * Write logs (to standard error, for whoever operates the server) with
**[Logging](logging.md)**. **[Logging](logging.md)**.

View file

@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
## The server side ## The server side
On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type:
```python title="server.py" hl_lines="44-47" ```python title="server.py" hl_lines="44-47"
--8<-- "docs_src/mrtr/tutorial001.py" --8<-- "docs_src/mrtr/tutorial001.py"

View file

@ -0,0 +1,46 @@
# Sampling and roots
A handler can ask the connected client for two more things: a completion from the client's own model (**sampling**), and the client's workspace folders (**roots**).
Both still work, on every protocol version the SDK speaks. But read the warning before you design around them:
!!! warning "Deprecated by the 2026-07-28 specification"
Sampling and roots are deprecated as of `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). They remain fully functional and stay in the specification for at least twelve months before becoming eligible for removal, but new implementations should not build on them. The suggested migrations: integrate directly with your LLM provider's API instead of sampling, and pass directories via tool parameters, resource URIs, or server configuration instead of roots. The SDK-wide list is in **[Deprecated features](../deprecated.md)**.
## Sampling: borrow the client's model
A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**:
```python title="server.py" hl_lines="11-16 20"
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
```
* `Sample(messages, max_tokens=...)` mirrors the `sampling/createMessage` parameters. The injected value is the client's `CreateMessageResult`; pass `tools` or `tool_choice` and it becomes a `CreateMessageResultWithTools` instead.
* The client must have declared the `sampling` capability (`sampling.tools` if you pass `tools` or `tool_choice`). If it didn't, the call fails with a `-32021` protocol error instead of sending a request the client cannot handle. A pre-2026 session with no back-channel fails with its usual no-back-channel error, since there is nothing to send on.
* At `2026-07-28` the request is delivered inside the multi-round-trip flow (**[Multi-round-trip requests](multi-round-trip.md)**); on `2025-11-25` it is a standalone request to the client. The code is the same either way, but mind the multi-round-trip rule: the request must render identically across retry rounds, so build it only from the tool's arguments and other stable data.
* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares.
## Roots: where should this go?
Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`:
```python title="server.py" hl_lines="11-12 16"
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
```
* The injected `ListRootsResult` carries a list of `Root`s: a `file://` URI and an optional display name.
* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request.
On the other side of the wire, the client answers both requests with the callbacks it already has: `sampling_callback` and `list_roots_callback`, covered in **[Client callbacks](../client/callbacks.md)**.
## On 2025-era connections
`ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn.
## Recap
* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency.
* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent.
* Both features are deprecated at `2026-07-28`: fully functional for now, wrong for new designs. Prefer provider APIs over sampling and explicit parameters over roots.
Reporting how far along a slow tool is: **[Progress](progress.md)**.

View file

@ -697,6 +697,24 @@ and raises `RuntimeError` if the resource requests input.
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
### Resolver-routed requests require the client capability on every protocol version
A v1 server could send elicitation, sampling, and roots requests to clients
that never declared the matching capability; only tools-bearing sampling was
checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`)
enforce the spec's egress rule: an undeclared capability (form-mode `elicitation`,
`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools`
or `tool_choice`) fails the call with a `-32021`
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
request the client cannot handle. This applies on 2025-11-25 sessions with a
live back-channel too; a session with no back-channel keeps failing with its
no-back-channel error. To migrate, declare the capability: the SDK client
declares `elicitation`, `sampling`, and `roots` when the matching callback is
set, and `sampling.tools` needs an explicit
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
`ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous
behavior, including the pre-existing tools check on `create_message`.
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error ### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now

View file

@ -0,0 +1,26 @@
from typing import Annotated
from mcp_types import CreateMessageResult, SamplingMessage, TextContent
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
mcp = MCPServer("Bookshop")
def suggest_title(genre: str) -> Sample:
prompt = f"Suggest one {genre} book title. Answer with the title only."
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
max_tokens=50,
)
@mcp.tool()
async def recommend_book(
genre: str,
suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)],
) -> str:
"""Recommend a book in the given genre."""
title = suggestion.content.text if suggestion.content.type == "text" else "the classics"
return f"Today's {genre} pick: {title}"

View file

View file

@ -0,0 +1,22 @@
from typing import Annotated
from mcp_types import CreateMessageResult, SamplingMessage, TextContent
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
mcp = MCPServer("Bookshop")
def draft_blurb(title: str) -> Sample:
prompt = f"Write a one-sentence blurb for the book {title!r}."
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
max_tokens=60,
)
@mcp.tool()
async def blurb(title: str, draft: Annotated[CreateMessageResult, Resolve(draft_blurb)]) -> str:
"""Draft a blurb for a book."""
return draft.content.text if draft.content.type == "text" else "No blurb."

View file

@ -0,0 +1,20 @@
from typing import Annotated
from mcp_types import ListRootsResult
from mcp.server import MCPServer
from mcp.server.mcpserver import ListRoots, Resolve
mcp = MCPServer("Bookshop")
def workspace_roots() -> ListRoots:
return ListRoots()
@mcp.tool()
async def catalog_folder(roots: Annotated[ListRootsResult, Resolve(workspace_roots)]) -> str:
"""Pick the folder the catalog export should go to."""
if not roots.roots:
return "No workspace folders shared."
return str(roots.roots[0].uri)

View file

@ -36,6 +36,7 @@ nav:
- Lifespan: handlers/lifespan.md - Lifespan: handlers/lifespan.md
- Elicitation: handlers/elicitation.md - Elicitation: handlers/elicitation.md
- Multi-round-trip requests: handlers/multi-round-trip.md - Multi-round-trip requests: handlers/multi-round-trip.md
- Sampling and roots: handlers/sampling-and-roots.md
- Progress: handlers/progress.md - Progress: handlers/progress.md
- Logging: handlers/logging.md - Logging: handlers/logging.md
- Subscriptions: handlers/subscriptions.md - Subscriptions: handlers/subscriptions.md

View file

@ -303,6 +303,9 @@ class Client:
sampling_callback: SamplingFnT | None = None sampling_callback: SamplingFnT | None = None
"""Callback for handling sampling requests.""" """Callback for handling sampling requests."""
sampling_capabilities: types.SamplingCapability | None = None
"""Sampling sub-capabilities (e.g. tools) declared alongside `sampling_callback`; no effect without it."""
list_roots_callback: ListRootsFnT | None = None list_roots_callback: ListRootsFnT | None = None
"""Callback for handling list roots requests.""" """Callback for handling list roots requests."""
@ -418,6 +421,7 @@ class Client:
dispatcher=dispatcher, dispatcher=dispatcher,
read_timeout_seconds=self.read_timeout_seconds, read_timeout_seconds=self.read_timeout_seconds,
sampling_callback=self.sampling_callback, sampling_callback=self.sampling_callback,
sampling_capabilities=self.sampling_capabilities,
list_roots_callback=self.list_roots_callback, list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback, logging_callback=self.logging_callback,
message_handler=message_handler, message_handler=message_handler,

View file

@ -19,7 +19,9 @@ from .resolve import (
DeclinedElicitation, DeclinedElicitation,
Elicit, Elicit,
ElicitationResult, ElicitationResult,
ListRoots,
Resolve, Resolve,
Sample,
) )
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
from .server import MCPServer, require_client_extension from .server import MCPServer, require_client_extension
@ -33,6 +35,8 @@ __all__ = [
"Icon", "Icon",
"Resolve", "Resolve",
"Elicit", "Elicit",
"Sample",
"ListRoots",
"ElicitationResult", "ElicitationResult",
"AcceptedElicitation", "AcceptedElicitation",
"DeclinedElicitation", "DeclinedElicitation",

View file

@ -3,17 +3,14 @@
A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the
resolver `fn` before the tool body, instead of from the LLM-supplied arguments. resolver `fn` before the tool body, instead of from the LLM-supplied arguments.
Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies, Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies,
take tool arguments by name, and take the `Context`. A resolver may return take tool arguments by name, and take the `Context`. A resolver may return a
`Elicit[T]` to ask the client; the framework runs the elicitation and injects the request marker (`Elicit[T]` to ask the user, `Sample` to sample the client's
answer. LLM, `ListRoots` to fetch its roots); the framework injects the response.
The framework picks the elicitation transport from the negotiated protocol. At The transport follows the negotiated protocol: >= 2026-07-28 batches the requests
>= 2026-07-28 it returns an `InputRequiredResult` carrying the batched questions into an `InputRequiredResult` and resumes when the client retries with
and resumes when the client retries with `input_responses`/`request_state` `input_responses`/`request_state`; <= 2025-11-25 sends each standalone server-to-client
(independent resolvers are asked in one round; a resolver depending on another's request mid-call. Only *asked* outcomes ride `request_state`, so each question is asked once. Resolver
answer is asked in a later round). At <= 2025-11-25 it issues a synchronous
`elicitation/create` request mid-call. Only *elicited* outcomes are carried in
`request_state` across rounds (so the user is asked each question once). Resolver
bodies may re-run on every round; a recorded outcome is consulted only when the bodies may re-run on every round; a recorded outcome is consulted only when the
body asks its question again, so a resolver's own computation always wins over body asks its question again, so a resolver's own computation always wins over
anything the client echoes back in `request_state`. anything the client echoes back in `request_state`.
@ -24,6 +21,8 @@ Whether the consumer receives the unwrapped model or the full
- `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call. - `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call.
- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the - `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the
full outcome; the consumer branches on accept/decline/cancel. full outcome; the consumer branches on accept/decline/cancel.
`Sample` and `ListRoots` have no decline arm; their consumers annotate the result type directly.
""" """
from __future__ import annotations from __future__ import annotations
@ -42,16 +41,30 @@ import anyio.to_thread
from mcp_types import ( from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY, MISSING_REQUIRED_CLIENT_CAPABILITY,
ClientCapabilities, ClientCapabilities,
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ElicitationCapability, ElicitationCapability,
ElicitRequest, ElicitRequest,
ElicitRequestFormParams, ElicitRequestFormParams,
ElicitResult, ElicitResult,
FormElicitationCapability, FormElicitationCapability,
IncludeContext,
InputRequest, InputRequest,
InputRequests, InputRequests,
InputRequiredResult, InputRequiredResult,
InputResponses, InputResponses,
ListRootsRequest,
ListRootsResult,
MissingRequiredClientCapabilityErrorData, MissingRequiredClientCapabilityErrorData,
ModelPreferences,
RootsCapability,
SamplingCapability,
SamplingMessage,
SamplingToolsCapability,
Tool,
ToolChoice,
) )
from mcp_types.version import is_version_at_least from mcp_types.version import is_version_at_least
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
@ -67,8 +80,10 @@ from mcp.server.elicitation import (
from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
from mcp.server.request_state import compact_json from mcp.server.request_state import compact_json
from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools
from mcp.shared._callable_inspection import is_async_callable from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.message import ServerMessageMetadata
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -103,6 +118,53 @@ class Elicit(Generic[T]):
self.schema = schema self.schema = schema
class Sample:
"""A resolver's request to sample the client's LLM via `sampling/createMessage`.
The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` or
`tool_choice` are given, which also requires the client's `sampling.tools`); requires the
`sampling` capability. On >= 2026-07-28 the request must render identically across retry
rounds, and the sampled result rides `request_state` on every later round. `include_context`
other than "none" is deprecated in the draft spec.
"""
def __init__(
self,
messages: list[SamplingMessage],
*,
max_tokens: int,
system_prompt: str | None = None,
include_context: IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: ModelPreferences | None = None,
tools: list[Tool] | None = None,
tool_choice: ToolChoice | None = None,
) -> None:
validate_tool_use_result_messages(messages)
self.params = CreateMessageRequestParams(
messages=messages,
max_tokens=max_tokens,
system_prompt=system_prompt,
include_context=include_context,
temperature=temperature,
stop_sequences=stop_sequences,
metadata=metadata,
model_preferences=model_preferences,
tools=tools,
tool_choice=tool_choice,
)
class ListRoots:
"""A resolver's request for the client's roots via `roots/list`; the framework injects the `ListRootsResult`."""
_Marker = Elicit[Any] | Sample | ListRoots
"""The request markers a resolver may return."""
class _ParamPlan: class _ParamPlan:
"""How to fill one resolver parameter, decided once at registration.""" """How to fill one resolver parameter, decided once at registration."""
@ -221,19 +283,23 @@ def _contains_resolve(annotation: Any) -> bool:
def _check_elicit_return(return_annotation: Any, name: str) -> None: def _check_elicit_return(return_annotation: Any, name: str) -> None:
"""Validate the `Elicit[...]` arms of a resolver's return annotation. """Validate the request-marker arms of a resolver's return annotation.
Raises: Raises:
InvalidSignature: If the annotation has more than one `Elicit[...]` arm; InvalidSignature: If the annotation has more than one marker arm.
a resolver asks one question - a second arm means it should be split.
""" """
# A bare `Elicit[T]` is itself a candidate; a union contributes its members.
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,) candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
# Typing dedupes equal union members, so two arms here are genuinely distinct. # Typing dedupes equal union members, so two arms here are genuinely distinct.
arms = [c for c in candidates if get_origin(c) is Elicit] arms: list[Any] = [
c
for c in candidates
# Origin guard for 3.10: `dict[str, Any]` passes `isinstance(c, type)` there and would crash `issubclass`.
if get_origin(c) is Elicit
or (get_origin(c) is None and isinstance(c, type) and issubclass(c, Elicit | Sample | ListRoots))
]
if len(arms) > 1: if len(arms) > 1:
raise InvalidSignature( raise InvalidSignature(
f"Resolver {name!r} return annotation has multiple Elicit arms; " f"Resolver {name!r} return annotation has multiple Elicit/Sample/ListRoots arms; "
"a resolver asks one question - split it into separate resolvers" "a resolver asks one question - split it into separate resolvers"
) )
@ -360,9 +426,9 @@ class _Pending(Exception):
class _Resolution: class _Resolution:
"""Per-`tools/call` resolution state, shared across the DAG walk. """Per-`tools/call` resolution state, shared across the DAG walk.
`input_required` selects the transport: at >= 2026-07-28 elicitations are `input_required` selects the transport: at >= 2026-07-28 requests are
batched into `pending` and surfaced as an `InputRequiredResult`; at older batched into `pending` and surfaced as an `InputRequiredResult`; at older
revisions each `Elicit` is answered synchronously via `ctx.elicit`. revisions each marker is answered synchronously over the back-channel.
""" """
def __init__( def __init__(
@ -384,10 +450,9 @@ class _Resolution:
self.asked = decoded.asked self.asked = decoded.asked
# In-call dedup keyed by resolver identity (distinguishes two instances of # In-call dedup keyed by resolver identity (distinguishes two instances of
# the same bound method); `persist` holds the wire-shaped record of each # the same bound method); `persist` holds the wire-shaped record of each
# elicited outcome, keyed by its wire key - exactly what the next round's # asked outcome, keyed by its wire key - exactly what the next round's `request_state`
# `request_state` carries. Entries are the client's own (validated) wire # carries: the client's own validated content (elicitation) or the validated result's
# data, never re-derived from a model, so encode-restore is the identity. # dump (sample/roots). Pure resolvers are cheap to re-run each round and are not persisted.
# Pure resolvers are cheap to re-run each round and are not persisted.
self.cache: dict[Hashable, ElicitationResult[Any]] = {} self.cache: dict[Hashable, ElicitationResult[Any]] = {}
self.persist: dict[str, _StateEntry] = {} self.persist: dict[str, _StateEntry] = {}
self.pending: InputRequests = {} self.pending: InputRequests = {}
@ -490,8 +555,8 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
else: else:
result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) result = await anyio.to_thread.run_sync(lambda: fn(**kwargs))
if _is_elicit(result): if _is_marker(result):
outcome = await _elicit(result, wire_key, res) outcome = await _fulfil(result, wire_key, res)
else: else:
# A resolver may return any type (not just `BaseModel`), so accept it as the # A resolver may return any type (not just `BaseModel`), so accept it as the
# outcome without validating against the schema bound. Plain outcomes are not # outcome without validating against the schema bound. Plain outcomes are not
@ -502,18 +567,29 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
return outcome return outcome
async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> ElicitationResult[Any]: async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationResult[Any]:
"""Turn a resolver's `Elicit` into an outcome via the negotiated transport.""" """Turn a resolver's request marker into an outcome via the negotiated transport."""
if not res.input_required: if not res.input_required:
return await res.context.elicit(elicit.message, elicit.schema) # Gate wherever the request could actually be sent; otherwise the send path
# itself reports the failure.
if res.context.session.can_send_request:
_require_capability(res.context, marker, key)
if isinstance(marker, Elicit):
return await res.context.elicit(marker.message, marker.schema)
result = await res.context.session.send_request(
_render_request(marker),
_result_type(marker),
metadata=ServerMessageMetadata(related_request_id=res.context.request_id),
)
return _accepted(result)
request = _elicit_request(elicit) request = _render_request(marker)
q = _request_digest(request) q = _request_digest(request)
# A recorded outcome from a prior round is consulted only here, after the body # A recorded outcome from a prior round is consulted only here, after the body
# decided to ask, so a `request_state` entry can never stand in for a resolver's # decided to ask, so a `request_state` entry can never stand in for a resolver's
# own computation. A recorded outcome wins over a re-sent answer. # own computation. A recorded outcome wins over a re-sent answer.
outcome = _restore_outcome(res, key, elicit.schema, q) outcome = _restore_outcome(res, key, marker, q)
if outcome is not None: if outcome is not None:
return outcome return outcome
@ -524,16 +600,25 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key) logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key)
answer = None answer = None
if answer is None: if answer is None:
_require_form_elicitation(res.context, key) _require_capability(res.context, marker, key)
res.pending[key] = request res.pending[key] = request
raise _Pending raise _Pending
if not isinstance(marker, Elicit):
# A no-tool-use answer to a tools request parses as the plain result; validate against the marker's model.
wire = answer.model_dump(mode="json", by_alias=True, exclude_none=True)
try:
result = _result_type(marker).model_validate(wire)
except ValidationError as e:
raise ToolError(f"Resolver {key!r} received a response of the wrong kind") from e
res.persist[key] = _StateEntry(action="accept", data=wire, q=q)
return _accepted(result)
if not isinstance(answer, ElicitResult): if not isinstance(answer, ElicitResult):
raise ToolError(f"Resolver {key!r} received a non-elicitation response") raise ToolError(f"Resolver {key!r} received a non-elicitation response")
if answer.action == "accept": if answer.action == "accept":
if answer.content is None: if answer.content is None:
raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content") raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content")
try: try:
data = elicit.schema.model_validate(answer.content) data = marker.schema.model_validate(answer.content)
except ValidationError as e: except ValidationError as e:
raise ToolError( raise ToolError(
f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema" f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema"
@ -555,9 +640,8 @@ def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any:
raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}") raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}")
def _is_elicit(value: Any) -> TypeGuard[Elicit[Any]]: def _is_marker(value: Any) -> TypeGuard[_Marker]:
"""Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`.""" return isinstance(value, Elicit | Sample | ListRoots)
return isinstance(value, Elicit)
def _accepted(data: Any) -> AcceptedElicitation[Any]: def _accepted(data: Any) -> AcceptedElicitation[Any]:
@ -578,35 +662,65 @@ def _uses_input_required(protocol_version: str | None) -> bool:
return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION) return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION)
def _require_form_elicitation(context: Context[Any, Any], key: str) -> None: def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -> None:
"""Assert the client declared form elicitation before queueing a question for it. """Assert the client declared the capability `marker`'s request needs.
The spec forbids sending an `input_requests` entry the client has not declared a A bare `elicitation: {}` (the only shape before modes existed) counts as form support; url-only does not.
capability for. A bare `elicitation: {}` declaration (the only shape before modes
existed) counts as form support; an explicit url-only declaration does not.
Raises: Raises:
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a
`requiredCapabilities` payload when form elicitation is not declared. `requiredCapabilities` payload when the capability is not declared.
""" """
capabilities = context.client_capabilities capabilities = context.client_capabilities
elicitation = capabilities.elicitation if capabilities is not None else None if isinstance(marker, Elicit):
if elicitation is not None and (elicitation.form is not None or elicitation.url is None): elicitation = capabilities.elicitation if capabilities is not None else None
return if elicitation is not None and (elicitation.form is not None or elicitation.url is None):
data = MissingRequiredClientCapabilityErrorData( return
required_capabilities=ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability())) required = ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability()))
) name = "form elicitation"
elif isinstance(marker, Sample):
sampling = capabilities.sampling if capabilities is not None else None
wants_tools = wants_sampling_tools(marker.params.tools, marker.params.tool_choice)
if sampling is not None and (not wants_tools or sampling.tools is not None):
return
required = ClientCapabilities(
sampling=SamplingCapability(tools=SamplingToolsCapability() if wants_tools else None)
)
name = "sampling.tools" if wants_tools else "sampling"
else:
if capabilities is not None and capabilities.roots is not None:
return
required = ClientCapabilities(roots=RootsCapability())
name = "roots"
data = MissingRequiredClientCapabilityErrorData(required_capabilities=required)
raise MCPError( raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY, code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=f"Client did not declare the form elicitation capability required by resolver {key!r}", message=f"Client did not declare the {name} capability required by resolver {key!r}",
data=data.model_dump(by_alias=True, mode="json", exclude_none=True), data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
) )
def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest: def _render_request(marker: _Marker) -> InputRequest:
"""Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`.""" """Render a marker as its wire request - the same shape on both transports."""
json_schema = render_elicitation_schema(elicit.schema) if isinstance(marker, Elicit):
return ElicitRequest(params=ElicitRequestFormParams(message=elicit.message, requested_schema=json_schema)) json_schema = render_elicitation_schema(marker.schema)
return ElicitRequest(params=ElicitRequestFormParams(message=marker.message, requested_schema=json_schema))
if isinstance(marker, Sample):
return CreateMessageRequest(params=marker.params)
return ListRootsRequest()
def _result_type(
marker: Sample | ListRoots,
) -> type[CreateMessageResult] | type[CreateMessageResultWithTools] | type[ListRootsResult]:
"""The result model a `Sample`/`ListRoots` response must validate against."""
if isinstance(marker, ListRoots):
return ListRootsResult
return (
CreateMessageResultWithTools
if wants_sampling_tools(marker.params.tools, marker.params.tool_choice)
else CreateMessageResult
)
class _StateEntry(BaseModel): class _StateEntry(BaseModel):
@ -660,34 +774,33 @@ def _decode_state(request_state: str | None) -> _State:
def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str: def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str:
"""Encode recorded outcomes and asked-question digests for the next round. """Encode recorded outcomes and asked-question digests for the next round.
Outcome entries already hold the client's wire-shaped data exactly as it was Outcome entries are already wire-shaped, so encoding is pure wrapping.
sent (and validated), so encoding is pure wrapping: encode-restore is the
identity.
""" """
state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked)) state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked))
return compact_json(state.model_dump(mode="json")) return compact_json(state.model_dump(mode="json"))
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]: def _outcome_from_state(entry: _StateEntry, marker: _Marker) -> ElicitationResult[Any]:
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry. """Rebuild an outcome from a decoded `request_state` entry.
Raises: Raises:
ValidationError: If an accepted entry's data does not validate against ValidationError: If the entry does not fit the live marker.
`schema` (the live `Elicit.schema` of the question being asked).
""" """
if entry.action == "decline": if isinstance(marker, Elicit):
return DeclinedElicitation() if entry.action == "decline":
if entry.action == "cancel": return DeclinedElicitation()
return CancelledElicitation() if entry.action == "cancel":
return _accepted(schema.model_validate(entry.data)) return CancelledElicitation()
return _accepted(marker.schema.model_validate(entry.data))
return _accepted(_result_type(marker).model_validate(entry.data))
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None: def _restore_outcome(res: _Resolution, key: str, marker: _Marker, q: str) -> ElicitationResult[Any] | None:
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent. """Restore `key`'s recorded outcome from a prior round, or `None` when absent.
An entry pinned to a question digest other than `q`, or whose accepted An entry pinned to a question digest other than `q`, or that fails
data fails validation against the live `schema`, is dropped as if no validation against the live marker, is dropped as if no progress was
progress was recorded, so the question is asked again. recorded, so the question is asked again.
Carries the original decoded entry forward unchanged in `res.persist`: if a Carries the original decoded entry forward unchanged in `res.persist`: if a
later resolver is still pending, the next round's `request_state` is built from later resolver is still pending, the next round's `request_state` is built from
@ -701,7 +814,7 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str
del res.state[key] del res.state[key]
return None return None
try: try:
outcome = _outcome_from_state(entry, schema) outcome = _outcome_from_state(entry, marker)
except ValidationError: except ValidationError:
del res.state[key] del res.state[key]
return None return None
@ -712,6 +825,8 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str
__all__ = [ __all__ = [
"Resolve", "Resolve",
"Elicit", "Elicit",
"Sample",
"ListRoots",
"ElicitationResult", "ElicitationResult",
"AcceptedElicitation", "AcceptedElicitation",
"DeclinedElicitation", "DeclinedElicitation",

View file

@ -14,7 +14,7 @@ from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated from typing_extensions import deprecated
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import ServerMessageMetadata from mcp.shared.message import ServerMessageMetadata
@ -45,6 +45,11 @@ class ServerSession:
"""The client's `initialize` request params; `None` when no client info was supplied.""" """The client's `initialize` request params; `None` when no client info was supplied."""
return self._connection.client_params return self._connection.client_params
@property
def can_send_request(self) -> bool:
"""Whether this request's channel can currently deliver a server-initiated request."""
return self._request_outbound.can_send_request
@property @property
def protocol_version(self) -> str: def protocol_version(self) -> str:
"""The protocol version this connection speaks. """The protocol version this connection speaks.
@ -141,10 +146,10 @@ class ServerSession:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
model_preferences: types.ModelPreferences | None = None, model_preferences: types.ModelPreferences | None = None,
tools: None = None, tools: None = None,
tool_choice: types.ToolChoice | None = None, tool_choice: None = None,
related_request_id: types.RequestId | None = None, related_request_id: types.RequestId | None = None,
) -> types.CreateMessageResult: ) -> types.CreateMessageResult:
"""Overload: Without tools, returns single content.""" """Overload: Without tools or tool_choice, returns single content."""
... ...
@overload @overload
@ -167,6 +172,26 @@ class ServerSession:
"""Overload: With tools, returns array-capable content.""" """Overload: With tools, returns array-capable content."""
... ...
@overload
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def create_message(
self,
messages: list[types.SamplingMessage],
*,
max_tokens: int,
system_prompt: str | None = None,
include_context: types.IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: types.ModelPreferences | None = None,
tools: list[types.Tool] | None = None,
tool_choice: types.ToolChoice,
related_request_id: types.RequestId | None = None,
) -> types.CreateMessageResultWithTools:
"""Overload: With tool_choice, returns array-capable content."""
...
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def create_message( async def create_message(
self, self,
@ -231,7 +256,7 @@ class ServerSession:
) )
metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) metadata_obj = ServerMessageMetadata(related_request_id=related_request_id)
if tools is not None: if wants_sampling_tools(tools, tool_choice):
return await self.send_request( return await self.send_request(
request=request, request=request,
result_type=types.CreateMessageResultWithTools, result_type=types.CreateMessageResultWithTools,

View file

@ -26,6 +26,11 @@ def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> b
return True return True
def wants_sampling_tools(tools: list[Tool] | None, tool_choice: ToolChoice | None) -> bool:
"""Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer."""
return tools is not None or tool_choice is not None
def validate_sampling_tools( def validate_sampling_tools(
client_caps: ClientCapabilities | None, client_caps: ClientCapabilities | None,
tools: list[Tool] | None, tools: list[Tool] | None,
@ -41,7 +46,7 @@ def validate_sampling_tools(
Raises: Raises:
MCPError: If tools/tool_choice are provided but client doesn't support them MCPError: If tools/tool_choice are provided but client doesn't support them
""" """
if tools is not None or tool_choice is not None: if wants_sampling_tools(tools, tool_choice):
if not check_sampling_tools_capability(client_caps): if not check_sampling_tools_capability(client_caps):
raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability") raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability")

View file

@ -98,7 +98,7 @@ class ClientPeer:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
model_preferences: ModelPreferences | None = None, model_preferences: ModelPreferences | None = None,
tools: None = None, tools: None = None,
tool_choice: ToolChoice | None = None, tool_choice: None = None,
meta: Meta | None = None, meta: Meta | None = None,
opts: CallOptions | None = None, opts: CallOptions | None = None,
) -> CreateMessageResult: ... ) -> CreateMessageResult: ...
@ -120,6 +120,24 @@ class ClientPeer:
meta: Meta | None = None, meta: Meta | None = None,
opts: CallOptions | None = None, opts: CallOptions | None = None,
) -> CreateMessageResultWithTools: ... ) -> CreateMessageResultWithTools: ...
@overload
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def sample(
self,
messages: list[SamplingMessage],
*,
max_tokens: int,
system_prompt: str | None = None,
include_context: IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: ModelPreferences | None = None,
tools: list[Tool] | None = None,
tool_choice: ToolChoice,
meta: Meta | None = None,
opts: CallOptions | None = None,
) -> CreateMessageResultWithTools: ...
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def sample( async def sample(
self, self,
@ -157,7 +175,7 @@ class ClientPeer:
tool_choice=tool_choice, tool_choice=tool_choice,
) )
result = await self.send_raw_request("sampling/createMessage", dump_params(params, meta), opts) result = await self.send_raw_request("sampling/createMessage", dump_params(params, meta), opts)
if tools is not None: if tools is not None or tool_choice is not None:
return CreateMessageResultWithTools.model_validate(result, by_name=False) return CreateMessageResultWithTools.model_validate(result, by_name=False)
return CreateMessageResult.model_validate(result, by_name=False) return CreateMessageResult.model_validate(result, by_name=False)

View file

@ -4,9 +4,9 @@ from typing import Literal
import pytest import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
from mcp_types import ElicitRequestParams, ElicitResult, TextContent from mcp_types import CreateMessageRequestParams, CreateMessageResult, ElicitRequestParams, ElicitResult, TextContent
from docs_src.dependencies import tutorial001, tutorial002, tutorial003 from docs_src.dependencies import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client from mcp import Client
from mcp.client import ClientRequestContext from mcp.client import ClientRequestContext
@ -138,3 +138,21 @@ async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["
assert result.content[0].text == ( assert result.content[0].text == (
"Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline" "Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline"
) )
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_resolver_can_sample_the_clients_llm(mode: Literal["legacy", "auto"]) -> None:
"""tutorial004: `suggest_title` runs through the client's sampling callback on both eras."""
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Dune"), model="m")
async with Client(tutorial004.mcp, mode=mode, sampling_callback=sampler) as client:
result = await client.call_tool("recommend_book", {"genre": "sci-fi"})
assert result.content == [TextContent(type="text", text="Today's sci-fi pick: Dune")]
assert prompts == ["Suggest one sci-fi book title. Answer with the title only."]

View file

@ -0,0 +1,62 @@
"""`docs/handlers/sampling-and-roots.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
CreateMessageRequestParams,
CreateMessageResult,
ListRootsResult,
Root,
TextContent,
)
from pydantic import FileUrl
from docs_src.sampling_and_roots import tutorial001, tutorial002
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.shared.exceptions import MCPError
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_sampling_dependency_receives_the_clients_completion(mode: Literal["legacy", "auto"]) -> None:
"""tutorial001: `draft_blurb` runs through the client's model on both protocol versions."""
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(
role="assistant", content=TextContent(type="text", text="A desert planet holds the key."), model="m"
)
async with Client(tutorial001.mcp, mode=mode, sampling_callback=sampler) as client:
result = await client.call_tool("blurb", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="A desert planet holds the key.")]
assert prompts == ["Write a one-sentence blurb for the book 'Dune'."]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_roots_dependency_receives_the_clients_folders(mode: Literal["legacy", "auto"]) -> None:
"""tutorial002: `workspace_roots` fetches the client's roots list."""
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace/catalog"), name="catalog")])
async with Client(tutorial002.mcp, mode=mode, list_roots_callback=client_roots) as client:
result = await client.call_tool("catalog_folder", {})
assert result.content == [TextContent(type="text", text="file:///workspace/catalog")]
async def test_an_undeclared_capability_fails_before_a_request_is_sent() -> None:
"""The page's gate claim: no `sampling` capability means a -32021 protocol error."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("blurb", {"title": "Dune"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY

View file

@ -7,22 +7,40 @@ from typing import Annotated, Any, Literal, TypeVar, cast
import anyio import anyio
import pytest import pytest
from inline_snapshot import snapshot
from mcp_types import ( from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY, MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolResult, CallToolResult,
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult, CreateMessageResult,
CreateMessageResultWithTools,
ElicitRequest,
ElicitRequestFormParams, ElicitRequestFormParams,
ElicitRequestParams, ElicitRequestParams,
ElicitResult, ElicitResult,
InputRequiredResult, InputRequiredResult,
InputResponses, InputResponses,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
ListRootsResult,
Root,
SamplingCapability,
SamplingMessage,
SamplingToolsCapability,
TextContent, TextContent,
ToolChoice,
) )
from pydantic import BaseModel, Field, ValidationError, create_model from mcp_types import (
Tool as SamplingTool,
)
from pydantic import BaseModel, Field, FileUrl, ValidationError, create_model
from typing_extensions import TypeAliasType from typing_extensions import TypeAliasType
from mcp import Client, InputRequiredRoundsExceededError from mcp import Client, InputRequiredRoundsExceededError
from mcp.client import ClientRequestContext from mcp.client import ClientRequestContext
from mcp.client._memory import InMemoryTransport
from mcp.server.context import ServerRequestContext from mcp.server.context import ServerRequestContext
from mcp.server.mcpserver import ( from mcp.server.mcpserver import (
AcceptedElicitation, AcceptedElicitation,
@ -32,18 +50,20 @@ from mcp.server.mcpserver import (
DeclinedElicitation, DeclinedElicitation,
Elicit, Elicit,
ElicitationResult, ElicitationResult,
ListRoots,
MCPServer, MCPServer,
RequestStateBoundary, RequestStateBoundary,
RequestStateSecurity, RequestStateSecurity,
Resolve, Resolve,
Sample,
) )
from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.exceptions import InvalidSignature
from mcp.server.mcpserver.resolve import ( from mcp.server.mcpserver.resolve import (
_check_elicit_return, _check_elicit_return,
_decode_state, _decode_state,
_elicit_request,
_encode_state, _encode_state,
_outcome_from_state, _outcome_from_state,
_render_request,
_request_digest, _request_digest,
_resolver_key, _resolver_key,
_state_key, _state_key,
@ -54,11 +74,12 @@ from mcp.server.mcpserver.resolve import (
) )
from mcp.server.mcpserver.tools.base import Tool from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
def _question_digest(elicit: Elicit[Any]) -> str: def _question_digest(elicit: Elicit[Any]) -> str:
"""The digest `_elicit` pins: the rendered request the client would be shown.""" """The digest `_fulfil` pins: the rendered request the client would be shown."""
return _request_digest(_elicit_request(elicit)) return _request_digest(_render_request(elicit))
class Login(BaseModel): class Login(BaseModel):
@ -177,42 +198,6 @@ def _wire_key(fn: Callable[..., Any]) -> str:
return f"{fn.__module__}:{fn.__qualname__}" return f"{fn.__module__}:{fn.__qualname__}"
@pytest.mark.anyio
async def test_resolver_returns_value_directly_without_eliciting():
mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
username = (ctx.headers or {}).get("x-github-user")
if username: # pragma: no cover - no headers on in-memory transport
return Login(username=username)
return Login(username="from-resolver")
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
return login.username
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "whoami", {}) == "from-resolver"
@pytest.mark.anyio
async def test_resolver_elicits_and_injects_unwrapped_model_on_accept():
mcp = MCPServer(name="Accept", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
return login.username
async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client:
assert await _text(client, "whoami", {}) == "octocat"
@pytest.mark.anyio @pytest.mark.anyio
async def test_consumer_receives_result_union_and_branches(): async def test_consumer_receives_result_union_and_branches():
mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral()) mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral())
@ -232,43 +217,6 @@ async def test_consumer_receives_result_union_and_branches():
assert await _text(client, "whoami", {}) == "hi octocat" assert await _text(client, "whoami", {}) == "hi octocat"
@pytest.mark.anyio
async def test_decline_reaches_union_consumer_without_aborting():
mcp = MCPServer(name="UnionDecline", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(
login: Annotated[AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation, Resolve(login)],
) -> str:
if isinstance(login, DeclinedElicitation):
return "declined gracefully"
raise NotImplementedError
async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client:
assert await _text(client, "whoami", {}) == "declined gracefully"
@pytest.mark.anyio
async def test_decline_aborts_when_consumer_wants_unwrapped():
mcp = MCPServer(name="UnwrappedDecline", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
raise NotImplementedError # pragma: no cover - never reached
async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client:
result = await client.call_tool("whoami", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "decline" in result.content[0].text
@pytest.mark.anyio @pytest.mark.anyio
async def test_nested_resolver_sees_dependency_and_tool_args(): async def test_nested_resolver_sees_dependency_and_tool_args():
mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral()) mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral())
@ -348,22 +296,6 @@ async def test_sync_resolver():
assert await _text(client, "whoami", {}) == "sync-user" assert await _text(client, "whoami", {}) == "sync-user"
def test_resolved_params_absent_from_input_schema():
async def login(ctx: Context) -> Login:
return Login(username="x") # pragma: no cover - only the schema is inspected
async def tool(
repo: Annotated[str, Field(description="repo name")],
login: Annotated[Login, Resolve(login)],
) -> str:
return repo # pragma: no cover - only the schema is inspected
built = Tool.from_function(tool)
properties = built.parameters["properties"]
assert "repo" in properties
assert "login" not in properties
def test_cycle_detection_raises_at_registration(): def test_cycle_detection_raises_at_registration():
async def a(dep: Login) -> Login: async def a(dep: Login) -> Login:
return dep # pragma: no cover return dep # pragma: no cover
@ -425,7 +357,7 @@ def test_multiple_elicit_arms_raise_at_registration():
async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str: async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
return login.username # pragma: no cover return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="multiple Elicit arms"): with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"):
Tool.from_function(tool) Tool.from_function(tool)
@ -938,15 +870,16 @@ def test_state_round_trips_accept_decline_cancel():
assert decoded == entries # encode-restore is the identity on the stored entries assert decoded == entries # encode-restore is the identity on the stored entries
assert state.asked == {"e": "asked-digest"} assert state.asked == {"e": "asked-digest"}
accepted = _outcome_from_state(decoded["a"], Login) ask = Elicit("q", Login)
accepted = _outcome_from_state(decoded["a"], ask)
assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat")
# Decline/cancel entries carry no data; the schema is not consulted for them. # Decline/cancel entries carry no data; the schema is not consulted for them.
assert isinstance(_outcome_from_state(decoded["b"], Login), DeclinedElicitation) assert isinstance(_outcome_from_state(decoded["b"], ask), DeclinedElicitation)
assert isinstance(_outcome_from_state(decoded["c"], Login), CancelledElicitation) assert isinstance(_outcome_from_state(decoded["c"], ask), CancelledElicitation)
# An accepted restore always validates against the question's live schema - # An accepted restore always validates against the question's live schema -
# data that doesn't fit is rejected, never passed through raw. # data that doesn't fit is rejected, never passed through raw.
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
_outcome_from_state(decoded["d"], Login) _outcome_from_state(decoded["d"], ask)
def test_check_elicit_return_allows_one_arm_and_rejects_two(): def test_check_elicit_return_allows_one_arm_and_rejects_two():
@ -955,7 +888,7 @@ def test_check_elicit_return_allows_one_arm_and_rejects_two():
_check_elicit_return(Login, "r") # no Elicit arm _check_elicit_return(Login, "r") # no Elicit arm
_check_elicit_return(None, "r") # unannotated _check_elicit_return(None, "r") # unannotated
# A resolver asks one question: two distinct Elicit arms mean it should be split. # A resolver asks one question: two distinct Elicit arms mean it should be split.
with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"): with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit/Sample/ListRoots arms"):
_check_elicit_return(Elicit[Login] | Elicit[Confirm], "r") _check_elicit_return(Elicit[Login] | Elicit[Confirm], "r")
@ -1118,28 +1051,6 @@ async def test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["le
assert "no content" in result.content[0].text assert "no content" in result.content[0].text
@pytest.mark.anyio
async def test_eliciting_tool_without_client_capability_is_a_protocol_error():
# The server must not send an `input_requests` entry the client has not declared
# capability for: with no `elicitation` declared (no callback), the call fails as
# a -32021 protocol error, not a CallToolResult execution failure.
mcp = MCPServer(name="NoElicitationCapability", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("tool", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert "elicitation" in exc_info.value.error.data["requiredCapabilities"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_independent_nested_deps_batch_into_one_round(): async def test_independent_nested_deps_batch_into_one_round():
mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral()) mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral())
@ -2365,3 +2276,593 @@ async def test_resolver_elicitation_seals_and_completes_on_a_fully_default_serve
assert isinstance(final, CallToolResult) assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent) assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "went:True" assert final.content[0].text == "went:True"
# --- Sample / ListRoots markers ---
async def _sample_never( # pragma: no cover - declares the capability; never invoked
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
raise AssertionError("should not be called")
async def _roots_never(context: ClientRequestContext) -> ListRootsResult: # pragma: no cover - see _sample_never
raise AssertionError("should not be called")
def _sample_capital(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="Capital of France?"))],
max_tokens=16,
)
@pytest.mark.anyio
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_sample_resolver_injects_result(mode: Literal["legacy", "auto"]):
# The marker form is the 2026-blessed carrier: no SEP-2577 deprecation warning on either mode.
mcp = MCPServer(name="Sampler", request_state_security=RequestStateSecurity.ephemeral())
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(mcp, mode=mode, sampling_callback=sampler) as client:
assert await _text(client, "capital", {}) == "Paris"
assert prompts == ["Capital of France?"]
@pytest.mark.anyio
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_list_roots_resolver_injects_result(mode: Literal["legacy", "auto"]):
mcp = MCPServer(name="Rooted", request_state_security=RequestStateSecurity.ephemeral())
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace"))])
def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
return str(len(roots.roots))
async with Client(mcp, mode=mode, list_roots_callback=client_roots) as client:
assert await _text(client, "workspace", {}) == "1"
@pytest.mark.anyio
async def test_mixed_kinds_batch_into_one_round():
mcp = MCPServer(name="Mixed", request_state_security=RequestStateSecurity.ephemeral())
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
async def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def combo(
login: Annotated[Login, Resolve(ask_name)],
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
roots: Annotated[ListRootsResult, Resolve(fetch_roots)],
) -> str:
assert isinstance(answer.content, TextContent)
return f"{login.username}/{answer.content.text}/{len(roots.roots)}"
async with Client(
mcp, elicitation_callback=_never, sampling_callback=_sample_never, list_roots_callback=_roots_never
) as client:
first = await client.session.call_tool("combo", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
kinds = sorted(type(req).__name__ for req in first.input_requests.values())
assert kinds == ["CreateMessageRequest", "ElicitRequest", "ListRootsRequest"]
responses: InputResponses = {}
for key, req in first.input_requests.items():
if isinstance(req, ElicitRequest):
responses[key] = ElicitResult(action="accept", content={"username": "octocat"})
elif isinstance(req, CreateMessageRequest):
responses[key] = CreateMessageResult(
role="assistant", content=TextContent(type="text", text="hey"), model="m"
)
else:
responses[key] = ListRootsResult(roots=[])
final = await client.session.call_tool(
"combo", {}, input_responses=responses, request_state=first.request_state, allow_input_required=True
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat/hey/0"
@pytest.mark.anyio
async def test_sampling_tool_without_client_capability_is_a_protocol_error():
mcp = MCPServer(name="NoSamplingCapability", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("capital", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert "sampling" in exc_info.value.error.data["requiredCapabilities"]
@pytest.mark.anyio
async def test_roots_tool_without_client_capability_is_a_protocol_error():
mcp = MCPServer(name="NoRootsCapability", request_state_security=RequestStateSecurity.ephemeral())
def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("workspace", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert "roots" in exc_info.value.error.data["requiredCapabilities"]
@pytest.mark.anyio
async def test_legacy_eliciting_tool_without_capability_is_a_protocol_error():
# Same egress gate as the input_requests leg; the session stays usable after the refusal.
mcp = MCPServer(name="LegacyGate", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
@mcp.tool()
def plain() -> str:
return "ok"
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("tool", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert await _text(client, "plain", {}) == "ok"
def _ask_with_tools(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
max_tokens=16,
tools=[SamplingTool(name="calc", input_schema={"type": "object"})],
)
def _ask_with_tool_choice(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
max_tokens=16,
tool_choice=ToolChoice(mode="none"),
)
@pytest.mark.anyio
@pytest.mark.parametrize("ask", [_ask_with_tools, _ask_with_tool_choice])
async def test_sample_tools_require_the_tools_subcapability(ask: Callable[[Context], Sample]):
mcp = MCPServer(name="NoToolsSubcapability", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(ask)]) -> str:
return "unreachable" # pragma: no cover
# The callback declares base `sampling` but not `sampling.tools`.
async with Client(mcp, sampling_callback=_sample_never) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("calc", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert exc_info.value.error.data["requiredCapabilities"] == {"sampling": {"tools": {}}}
@pytest.mark.anyio
async def test_sample_with_tools_round_trips_with_declared_subcapability():
mcp = MCPServer(name="ToolsSampling", request_state_security=RequestStateSecurity.ephemeral())
async def sampler(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResultWithTools:
assert params.tools is not None and params.tools[0].name == "calc"
return CreateMessageResultWithTools(role="assistant", content=[TextContent(type="text", text="4")], model="m")
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
assert isinstance(answer.content, list) and isinstance(answer.content[0], TextContent)
return answer.content[0].text
async with Client(
mcp,
sampling_callback=sampler,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
assert await _text(client, "calc", {}) == "4"
@pytest.mark.anyio
async def test_no_tool_use_answer_to_a_tools_request_is_accepted():
# The answer parses off the wire as plain CreateMessageResult but must inject as CreateMessageResultWithTools.
mcp = MCPServer(name="NoToolUse", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
assert isinstance(answer, CreateMessageResultWithTools)
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(
mcp,
sampling_callback=_sample_never,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
first = await client.session.call_tool("calc", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(key,) = first.input_requests
final = await client.session.call_tool(
"calc",
{},
input_responses={
key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert not final.is_error
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "4"
@pytest.mark.anyio
async def test_sample_outcome_persists_across_rounds():
# The confirm arm depends on the sample, forcing extra rounds that restore the result instead of re-sampling.
mcp = MCPServer(name="Chain", request_state_security=RequestStateSecurity.ephemeral())
samples = 0
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
nonlocal samples
samples += 1
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
async def confirm(
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], ctx: Context
) -> Elicit[Confirm]:
return Elicit("Accept the model's answer?", Confirm)
@mcp.tool()
async def tool(
ok: Annotated[Confirm, Resolve(confirm)],
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
) -> str:
assert isinstance(answer.content, TextContent)
return f"{answer.content.text}:{ok.ok}"
async with Client(mcp, sampling_callback=sampler, elicitation_callback=_accept({"ok": True})) as client:
assert await _text(client, "tool", {}) == "Paris:True"
assert samples == 1
@pytest.mark.anyio
async def test_wrong_kind_response_for_sample_raises():
mcp = MCPServer(name="WrongKind", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp, sampling_callback=_sample_never) as client:
first = await client.session.call_tool("capital", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(key,) = first.input_requests
final = await client.session.call_tool(
"capital",
{},
input_responses={key: ElicitResult(action="accept", content={"x": "y"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert final.is_error
assert isinstance(final.content[0], TextContent)
assert "wrong kind" in final.content[0].text
def test_mixed_marker_arms_raise_at_registration():
async def ambiguous(ctx: Context) -> Sample | Elicit[Login]:
raise NotImplementedError # pragma: no cover
async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"):
Tool.from_function(tool)
def test_marker_union_with_generic_alias_member_registers():
# dict[str, Any] passes isinstance(c, type) on Python 3.10; the arm filter must not feed it to issubclass.
async def maybe_ask(ctx: Context) -> Sample | dict[str, Any]:
raise NotImplementedError # pragma: no cover
async def tool(answer: Annotated[CreateMessageResult, Resolve(maybe_ask)]) -> str:
return "ok" # pragma: no cover
Tool.from_function(tool)
def test_decline_entry_for_a_sample_marker_is_invalid():
# Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation.
with pytest.raises(ValidationError):
_outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None)))
@pytest.mark.anyio
async def test_bare_initialized_session_is_still_gated():
# notifications/initialized alone commits the handshake: a live back-channel, no declared capabilities.
mcp = MCPServer(name="BareInit", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
async with InMemoryTransport(mcp) as (read, write):
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
await write.send(
SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "tool", "arguments": {}})
)
)
with anyio.fail_after(5):
message = await read.receive()
assert isinstance(message, SessionMessage)
assert isinstance(message.message, JSONRPCError)
assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_tool_choice_only_sample_validates_as_tools_mode(mode: Literal["legacy", "auto"]):
# Gate and answer model share one predicate: tool_choice alone is tools-mode,
# so a single-content answer still validates (WithTools accepts both shapes).
mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral())
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
assert params.tool_choice is not None and params.tools is None
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str:
assert isinstance(answer, CreateMessageResultWithTools)
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(
mcp,
mode=mode,
sampling_callback=sampler,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
assert await _text(client, "calc", {}) == "4"
# --- Feature walk: interaction-level tests (public API only, one behaviour per test) ---
@pytest.mark.anyio
async def test_a_resolver_fills_its_parameter_without_any_client_interaction():
"""A resolver that computes its value server-side injects it with no client
callbacks involved. SDK-defined resolver contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
return 42 if title == "Dune" else 0
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
return f"{title}: {price}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune: 42")]
@pytest.mark.anyio
async def test_a_resolver_may_depend_on_another_resolvers_value():
"""A resolver declares its own Resolve dependency and receives that resolver's
value before the tool body runs. SDK-defined resolver contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def base_price(title: str) -> int:
return 10 if title == "Dune" else 1
def with_tax(price: Annotated[int, Resolve(base_price)]) -> int:
return price * 2
@mcp.tool()
async def quote(title: str, total: Annotated[int, Resolve(with_tax)]) -> str:
return f"{title} costs {total}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune costs 20")]
@pytest.mark.anyio
async def test_a_client_supplied_value_for_a_resolved_parameter_is_discarded():
"""A value the client sends under a resolved parameter's name never reaches the
tool; the resolver's value wins. SDK-defined: resolved parameters are server-side only."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
return 42
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
return f"{title}: {price}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune", "price": 999})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune: 42")]
@pytest.mark.anyio
async def test_resolved_parameters_are_absent_from_the_advertised_tool_schema():
"""tools/list advertises only the model-facing parameters; resolved ones are
invisible to the client. SDK-defined: resolvers are server-side dependency injection."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
raise NotImplementedError # pragma: no cover - only the schema is inspected
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
raise NotImplementedError # pragma: no cover - only the schema is inspected
async with Client(mcp) as client:
(advertised,) = (await client.list_tools()).tools
assert advertised.input_schema == snapshot(
{
"type": "object",
"properties": {"title": {"title": "Title", "type": "string"}},
"required": ["title"],
"title": "quoteArguments",
}
)
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_elicit_marker_asks_the_client_and_injects_the_accepted_answer(mode: Literal["legacy", "auto"]):
"""A resolver returning Elicit puts its question to the client's elicitation
callback and the tool receives the validated model, identically over the 2025
back-channel and the 2026 multi-round-trip flow. SDK-defined injection contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
return ElicitResult(action="accept", content={"username": "octocat"})
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
# The parametrize is only a real era matrix while the arms negotiate different revisions.
assert client.protocol_version == ("2025-11-25" if mode == "legacy" else "2026-07-28")
result = await client.call_tool("whoami", {})
assert not result.is_error
assert result.content == [TextContent(type="text", text="octocat")]
assert asked == ["GitHub username?"]
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_declined_elicitation_fails_the_call_for_a_plain_model_consumer(mode: Literal["legacy", "auto"]):
"""Declining the question aborts a tool whose consumer asked for the unwrapped
model, on either protocol era. Decline semantics are spec-defined; the abort is
the SDK's contract for plain-model consumers."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
raise NotImplementedError # pragma: no cover - the decline aborts before the body
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
result = await client.call_tool("whoami", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == snapshot(
"Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline"
)
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_declined_elicitation_reaches_a_consumer_that_asked_for_the_outcome_union(
mode: Literal["legacy", "auto"],
):
"""Annotating the outcome union hands the tool the decline to branch on instead
of aborting the call. SDK-defined consumer-annotation contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(ask)]) -> str:
if isinstance(login, AcceptedElicitation):
return login.data.username # pragma: no cover - declined in this test
return "anonymous"
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
result = await client.call_tool("whoami", {})
assert not result.is_error
assert result.content == [TextContent(type="text", text="anonymous")]
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_undeclared_capability_refuses_the_call_instead_of_asking(mode: Literal["legacy", "auto"]):
"""A client that never declared the elicitation capability is refused with the
missing-capability protocol error rather than sent a question it cannot handle.
The egress rule is spec-mandated; the SDK applies it on both eras."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
raise NotImplementedError # pragma: no cover - the gate refuses before the body
async with Client(mcp, mode=mode) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("whoami", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}}

Some files were not shown because too many files have changed in this diff Show more