Skip to content

Commit d287c98

Browse files
authored
Extend resolver DI to sampling and roots requests (#3049)
1 parent 53117cb commit d287c98

22 files changed

Lines changed: 1134 additions & 200 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 they can send 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 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`:
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**. 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`).
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; 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 for an LLM completion or the roots list; the plain result is injected.
144157

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

docs/handlers/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ What it can do while it runs:
1818
* Ask the user for more input with **[Elicitation](elicitation.md)**, and
1919
**[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28
2020
pattern that carries it.
21+
* Ask the client for an LLM completion or its workspace folders with
22+
**[Sampling and roots](sampling-and-roots.md)**, deprecated but still
23+
served.
2124
* Report **[Progress](progress.md)** on something slow.
2225
* Write logs (to standard error, for whoever operates the server) with
2326
**[Logging](logging.md)**.

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"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Sampling and roots
2+
3+
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**).
4+
5+
Both still work, on every protocol version the SDK speaks. But read the warning before you design around them:
6+
7+
!!! warning "Deprecated by the 2026-07-28 specification"
8+
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)**.
9+
10+
## Sampling: borrow the client's model
11+
12+
A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**:
13+
14+
```python title="server.py" hl_lines="11-16 20"
15+
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
16+
```
17+
18+
* `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.
19+
* 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.
20+
* 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.
21+
* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares.
22+
23+
## Roots: where should this go?
24+
25+
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()`:
26+
27+
```python title="server.py" hl_lines="11-12 16"
28+
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
29+
```
30+
31+
* The injected `ListRootsResult` carries a list of `Root`s: a `file://` URI and an optional display name.
32+
* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request.
33+
34+
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)**.
35+
36+
## On 2025-era connections
37+
38+
`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.
39+
40+
## Recap
41+
42+
* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency.
43+
* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent.
44+
* 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.
45+
46+
Reporting how far along a slow tool is: **[Progress](progress.md)**.

docs/migration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,24 @@ and raises `RuntimeError` if the resource requests input.
697697

698698
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
699699

700+
### Resolver-routed requests require the client capability on every protocol version
701+
702+
A v1 server could send elicitation, sampling, and roots requests to clients
703+
that never declared the matching capability; only tools-bearing sampling was
704+
checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`)
705+
enforce the spec's egress rule: an undeclared capability (form-mode `elicitation`,
706+
`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools`
707+
or `tool_choice`) fails the call with a `-32021`
708+
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
709+
request the client cannot handle. This applies on 2025-11-25 sessions with a
710+
live back-channel too; a session with no back-channel keeps failing with its
711+
no-back-channel error. To migrate, declare the capability: the SDK client
712+
declares `elicitation`, `sampling`, and `roots` when the matching callback is
713+
set, and `sampling.tools` needs an explicit
714+
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
715+
`ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous
716+
behavior, including the pre-existing tools check on `create_message`.
717+
700718
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
701719

702720
Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now
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}"

docs_src/sampling_and_roots/__init__.py

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 draft_blurb(title: str) -> Sample:
12+
prompt = f"Write a one-sentence blurb for the book {title!r}."
13+
return Sample(
14+
[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
15+
max_tokens=60,
16+
)
17+
18+
19+
@mcp.tool()
20+
async def blurb(title: str, draft: Annotated[CreateMessageResult, Resolve(draft_blurb)]) -> str:
21+
"""Draft a blurb for a book."""
22+
return draft.content.text if draft.content.type == "text" else "No blurb."
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Annotated
2+
3+
from mcp_types import ListRootsResult
4+
5+
from mcp.server import MCPServer
6+
from mcp.server.mcpserver import ListRoots, Resolve
7+
8+
mcp = MCPServer("Bookshop")
9+
10+
11+
def workspace_roots() -> ListRoots:
12+
return ListRoots()
13+
14+
15+
@mcp.tool()
16+
async def catalog_folder(roots: Annotated[ListRootsResult, Resolve(workspace_roots)]) -> str:
17+
"""Pick the folder the catalog export should go to."""
18+
if not roots.roots:
19+
return "No workspace folders shared."
20+
return str(roots.roots[0].uri)

0 commit comments

Comments
 (0)