Skip to content

Commit 5fbf766

Browse files
committed
Validate tool_choice-only sampling answers against the tools-mode result
The capability gate already treats tool_choice alone as tools-mode; the answer model now shares the same predicate, so array-capable answers to a tool_choice-only request validate instead of being rejected as the wrong kind. Also rewords 'form elicitation' to 'form-mode elicitation' in the docs.
1 parent d8fe463 commit 5fbf766

5 files changed

Lines changed: 41 additions & 10 deletions

File tree

docs/handlers/dependencies.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,13 @@ That's the right default for a precondition: no answer, no order. When declining
136136

137137
## Ask the client, not the user
138138

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 `ListRootsResult`:
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`:
140140

141141
```python title="server.py" hl_lines="11-16 22"
142142
--8<-- "docs_src/dependencies/tutorial004.py"
143143
```
144144

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 `elicitation`; `sampling.tools` when the request carries `tools` or `tool_choice`).
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`).
146146
* 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.
147147
* 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.
148148

docs/handlers/sampling-and-roots.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A resolver returns `Sample(...)` and the tool receives the completion, through t
1515
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
1616
```
1717

18-
* `Sample(messages, max_tokens=...)` mirrors the `sampling/createMessage` parameters. The injected value is the client's `CreateMessageResult`; pass `tools=[...]` and it becomes a `CreateMessageResultWithTools` instead.
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.
1919
* 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.
2020
* 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.
2121
* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares.

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ dependencies elicit via `Resolve(...)`: the resolver owns that tool's
4545
A v1 server could send elicitation, sampling, and roots requests to clients
4646
that never declared the matching capability; only tools-bearing sampling was
4747
checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`)
48-
enforce the spec's egress rule: an undeclared capability (form `elicitation`,
48+
enforce the spec's egress rule: an undeclared capability (form-mode `elicitation`,
4949
`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools`
5050
or `tool_choice`) fails the call with a `-32021`
5151
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a

src/mcp/server/mcpserver/resolve.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,11 @@ def __init__(self, message: str, schema: type[T]) -> None:
121121
class Sample:
122122
"""A resolver's request to sample the client's LLM via `sampling/createMessage`.
123123
124-
The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` are
125-
given); requires the `sampling` capability (`sampling.tools` when `tools` or `tool_choice` are given). On
126-
>= 2026-07-28 the request must render identically across retry rounds, and the sampled result
127-
rides `request_state` on every later round. `include_context` other than "none" is deprecated in the draft spec.
124+
The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` or
125+
`tool_choice` are given, which also requires the client's `sampling.tools`); requires the
126+
`sampling` capability. On >= 2026-07-28 the request must render identically across retry
127+
rounds, and the sampled result rides `request_state` on every later round. `include_context`
128+
other than "none" is deprecated in the draft spec.
128129
"""
129130

130131
def __init__(
@@ -679,7 +680,7 @@ def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -
679680
name = "form elicitation"
680681
elif isinstance(marker, Sample):
681682
sampling = capabilities.sampling if capabilities is not None else None
682-
wants_tools = marker.params.tools is not None or marker.params.tool_choice is not None
683+
wants_tools = _wants_tools(marker.params)
683684
if sampling is not None and (not wants_tools or sampling.tools is not None):
684685
return
685686
required = ClientCapabilities(
@@ -709,13 +710,18 @@ def _render_request(marker: _Marker) -> InputRequest:
709710
return ListRootsRequest()
710711

711712

713+
def _wants_tools(params: CreateMessageRequestParams) -> bool:
714+
"""Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer."""
715+
return params.tools is not None or params.tool_choice is not None
716+
717+
712718
def _result_type(
713719
marker: Sample | ListRoots,
714720
) -> type[CreateMessageResult] | type[CreateMessageResultWithTools] | type[ListRootsResult]:
715721
"""The result model a `Sample`/`ListRoots` response must validate against."""
716722
if isinstance(marker, ListRoots):
717723
return ListRootsResult
718-
return CreateMessageResult if marker.params.tools is None else CreateMessageResultWithTools
724+
return CreateMessageResultWithTools if _wants_tools(marker.params) else CreateMessageResult
719725

720726

721727
class _StateEntry(BaseModel):

tests/server/mcpserver/test_resolve.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2754,3 +2754,28 @@ async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
27542754
assert isinstance(message, SessionMessage)
27552755
assert isinstance(message.message, JSONRPCError)
27562756
assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
2757+
2758+
2759+
@pytest.mark.anyio
2760+
async def test_tool_choice_only_sample_validates_as_tools_mode():
2761+
# Gate and answer model share one predicate: tool_choice alone is tools-mode,
2762+
# so a single-content answer still validates (WithTools accepts both shapes).
2763+
mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral())
2764+
2765+
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
2766+
assert params.tool_choice is not None and params.tools is None
2767+
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
2768+
2769+
@mcp.tool()
2770+
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str:
2771+
assert isinstance(answer, CreateMessageResultWithTools)
2772+
content = answer.content[0] if isinstance(answer.content, list) else answer.content
2773+
assert isinstance(content, TextContent)
2774+
return content.text
2775+
2776+
async with Client(
2777+
mcp,
2778+
sampling_callback=sampler,
2779+
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
2780+
) as client:
2781+
assert await _text(client, "calc", {}) == "4"

0 commit comments

Comments
 (0)