Skip to content

Commit f24a39f

Browse files
committed
Extend resolver DI to sampling and roots requests
Resolvers can now return Sample(...) or ListRoots() in addition to Elicit: on 2026-07-28 sessions the request batches into the multi-round-trip InputRequiredResult flow, on 2025-11-25 it goes over the standalone back-channel request. One rendering produces the identical wire request on both transports, and marker-routed legacy sends bypass the deprecated session wrappers so no SEP-2577 warning fires for the compatibility path. Sampling and roots results are persisted in request_state like elicited answers (the client pays for an LLM call once per tool call, not once per round), pinned to the exact rendered request. Because the response union cannot always discriminate the two sampling result shapes, an answer is validated against the marker's expected model rather than trusting the union member. The elicitation-only capability check generalizes to a per-kind gate applied before sending on either transport: sampling, roots, and elicitation - including sampling.tools when the request carries tools, reported in full in the -32021 requiredCapabilities payload. This also gates the previously unchecked 2025 elicitation leg (documented in the migration guide). Client gains sampling_capabilities so sampling sub-capabilities like tools support can be declared alongside sampling_callback.
1 parent 220d362 commit f24a39f

10 files changed

Lines changed: 676 additions & 72 deletions

File tree

docs/client/callbacks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ When a client connects it declares its `capabilities`, the mirror image of the s
7878
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
7979
| none of them | `{}` |
8080

81+
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 sending them.
82+
8183
`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.
8284

8385
The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:

docs/handlers/dependencies.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,25 @@ That's the right default for a precondition: no answer, no order. When declining
134134
to bind to. A question built from such volatile data makes every recorded answer look stale,
135135
so the server re-asks it on every round until the client's round limit ends the call.
136136

137+
## Ask the client, not the user
138+
139+
Elicitation is one of three questions a resolver can ask - the closed set the multi-round-trip flow allows. 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 `ListRootsResult`:
140+
141+
```python title="server.py" hl_lines="11-16 22"
142+
--8<-- "docs_src/dependencies/tutorial004.py"
143+
```
144+
145+
* 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** - and on either transport it refuses with a `-32021` protocol error when the client never declared the matching capability (`sampling`, `roots`, `elicitation`; `sampling.tools` when the request carries tools).
146+
* 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.
147+
* 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 instead, and servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them.
148+
137149
## Recap
138150

139151
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
140152
* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here.
141153
* 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.
142154
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
143155
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
156+
* Return `Sample(...)` or `ListRoots()` to ask the client - an LLM completion or the roots list, injected as the plain result.
144157

145158
The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page.

docs/handlers/multi-round-trip.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
1919

2020
## The server side
2121

22-
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:
22+
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:
2323

2424
```python title="server.py" hl_lines="44-47"
2525
--8<-- "docs_src/mrtr/tutorial001.py"

docs/migration.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ to receive the `InputRequiredResult` and forward it as its own result calls
4040
dependencies elicit via `Resolve(...)`: the resolver owns that tool's
4141
`request_state` channel, and a forwarded result's state would clobber it.
4242

43+
### Resolver-routed requests require the client capability on every protocol version
44+
45+
A v1 server could call `ctx.elicit()`, `create_message()`, or `list_roots()`
46+
against any client; nothing checked what the client had declared. In v2 the
47+
`Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`) enforce the spec's
48+
egress rule on both transports: if the client never declared the matching
49+
capability (`elicitation`, `sampling` — plus `sampling.tools` when the request
50+
carries tools — or `roots`), the call fails with a `-32021`
51+
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
52+
request the client cannot handle. This applies on 2025-11-25 sessions too, so a
53+
client that answered elicitations without declaring the capability now sees the
54+
error: declare the capability (the SDK client does this automatically when the
55+
matching callback is set) or drop the asking dependency. Direct `ctx.elicit()`
56+
and `ctx.session.*` calls outside resolvers are not gated.
57+
4358
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
4459

4560
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Annotated
2+
3+
from mcp_types import CreateMessageResult, SamplingMessage, TextContent
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import Resolve, Sample
7+
8+
mcp = MCPServer("Bookshop")
9+
10+
11+
def suggest_title(genre: str) -> Sample:
12+
prompt = f"Suggest one {genre} book title. Answer with the title only."
13+
return Sample(
14+
[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
15+
max_tokens=50,
16+
)
17+
18+
19+
@mcp.tool()
20+
async def recommend_book(
21+
genre: str,
22+
suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)],
23+
) -> str:
24+
"""Recommend a book in the given genre."""
25+
title = suggestion.content.text if suggestion.content.type == "text" else "the classics"
26+
return f"Today's {genre} pick: {title}"

src/mcp/client/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ async def main():
303303
sampling_callback: SamplingFnT | None = None
304304
"""Callback for handling sampling requests."""
305305

306+
sampling_capabilities: types.SamplingCapability | None = None
307+
"""Sampling sub-capabilities to declare alongside `sampling_callback` (e.g. tools support).
308+
309+
Only declared when `sampling_callback` is set; on its own it has no effect.
310+
"""
311+
306312
list_roots_callback: ListRootsFnT | None = None
307313
"""Callback for handling list roots requests."""
308314

@@ -418,6 +424,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
418424
dispatcher=dispatcher,
419425
read_timeout_seconds=self.read_timeout_seconds,
420426
sampling_callback=self.sampling_callback,
427+
sampling_capabilities=self.sampling_capabilities,
421428
list_roots_callback=self.list_roots_callback,
422429
logging_callback=self.logging_callback,
423430
message_handler=message_handler,

src/mcp/server/mcpserver/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
DeclinedElicitation,
2020
Elicit,
2121
ElicitationResult,
22+
ListRoots,
2223
Resolve,
24+
Sample,
2325
)
2426
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
2527
from .server import MCPServer, require_client_extension
@@ -33,6 +35,8 @@
3335
"Icon",
3436
"Resolve",
3537
"Elicit",
38+
"Sample",
39+
"ListRoots",
3640
"ElicitationResult",
3741
"AcceptedElicitation",
3842
"DeclinedElicitation",

0 commit comments

Comments
 (0)