Skip to content

Commit d8fe463

Browse files
committed
Gate resolver egress on the request channel's sendability
The legacy capability gate keyed on whether the handshake's declaration was visible, which let a session initialized with a bare notifications/initialized (no declared capabilities, live back-channel) receive ungated requests. Gate on the request-scoped channel's can_send_request instead, the channel these sends actually ride, so any sendable session is checked and a channel-less one keeps failing with its no-back-channel error. Adds ServerSession.can_send_request and a bare-initialized regression test, and corrects docs wording: tool_choice counts toward sampling.tools, the sampling.tools declaration needs the explicit client field, and the v1 claims are scoped to what v1 checked.
1 parent 8c62f61 commit d8fe463

6 files changed

Lines changed: 65 additions & 29 deletions

File tree

docs/handlers/dependencies.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Elicitation is one of the three questions a resolver can ask, and the multi-roun
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**. 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).
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`).
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A resolver returns `Sample(...)` and the tool receives the completion, through t
1616
```
1717

1818
* `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.
19-
* The client must have declared the `sampling` capability (`sampling.tools` if you pass tools). If it didn't, the call fails with a `-32021` protocol error before anything is sent.
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.
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.
2222

@@ -29,7 +29,7 @@ Roots are the folders the client says the server may operate on. They are inform
2929
```
3030

3131
* 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` before a request is sent.
32+
* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request.
3333

3434
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)**.
3535

@@ -40,7 +40,7 @@ On the other side of the wire, the client answers both requests with the callbac
4040
## Recap
4141

4242
* 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` before a request is sent.
43+
* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent.
4444
* 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.
4545

4646
Reporting how far along a slow tool is: **[Progress](progress.md)**.

docs/migration.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,21 @@ dependencies elicit via `Resolve(...)`: the resolver owns that tool's
4242

4343
### Resolver-routed requests require the client capability on every protocol version
4444

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`, or `roots`, plus `sampling.tools` when
50-
the request carries tools), the call fails with a `-32021`
45+
A v1 server could send elicitation, sampling, and roots requests to clients
46+
that never declared the matching capability; only tools-bearing sampling was
47+
checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`)
48+
enforce the spec's egress rule: an undeclared capability (form `elicitation`,
49+
`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools`
50+
or `tool_choice`) fails the call with a `-32021`
5151
`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.
52+
request the client cannot handle. This applies on 2025-11-25 sessions with a
53+
live back-channel too; a session with no back-channel keeps failing with its
54+
no-back-channel error. To migrate, declare the capability: the SDK client
55+
declares `elicitation`, `sampling`, and `roots` when the matching callback is
56+
set, and `sampling.tools` needs an explicit
57+
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
58+
`ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous
59+
behavior, including the pre-existing tools check on `create_message`.
5760

5861
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
5962

src/mcp/server/mcpserver/resolve.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class Sample:
122122
"""A resolver's request to sample the client's LLM via `sampling/createMessage`.
123123
124124
The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` are
125-
given); requires the `sampling` capability (`sampling.tools` when tools are given). On
125+
given); requires the `sampling` capability (`sampling.tools` when `tools` or `tool_choice` are given). On
126126
>= 2026-07-28 the request must render identically across retry rounds, and the sampled result
127127
rides `request_state` on every later round. `include_context` other than "none" is deprecated in the draft spec.
128128
"""
@@ -449,10 +449,9 @@ def __init__(
449449
self.asked = decoded.asked
450450
# In-call dedup keyed by resolver identity (distinguishes two instances of
451451
# the same bound method); `persist` holds the wire-shaped record of each
452-
# elicited outcome, keyed by its wire key - exactly what the next round's
453-
# `request_state` carries. Entries are the client's own (validated) wire
454-
# data, never re-derived from a model, so encode-restore is the identity.
455-
# Pure resolvers are cheap to re-run each round and are not persisted.
452+
# asked outcome, keyed by its wire key - exactly what the next round's `request_state`
453+
# carries: the client's own validated content (elicitation) or the validated result's
454+
# dump (sample/roots). Pure resolvers are cheap to re-run each round and are not persisted.
456455
self.cache: dict[Hashable, ElicitationResult[Any]] = {}
457456
self.persist: dict[str, _StateEntry] = {}
458457
self.pending: InputRequests = {}
@@ -570,9 +569,9 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
570569
async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationResult[Any]:
571570
"""Turn a resolver's request marker into an outcome via the negotiated transport."""
572571
if not res.input_required:
573-
# Gate only when the handshake's declaration is visible: a session with no
574-
# client info (e.g. stateless HTTP) has no back-channel, and the send path reports that.
575-
if res.context.client_capabilities is not None:
572+
# Gate wherever the request could actually be sent; otherwise the send path
573+
# itself reports the failure.
574+
if res.context.session.can_send_request:
576575
_require_capability(res.context, marker, key)
577576
if isinstance(marker, Elicit):
578577
return await res.context.elicit(marker.message, marker.schema)
@@ -770,9 +769,7 @@ def _decode_state(request_state: str | None) -> _State:
770769
def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str:
771770
"""Encode recorded outcomes and asked-question digests for the next round.
772771
773-
Outcome entries already hold the client's wire-shaped data exactly as it was
774-
sent (and validated), so encoding is pure wrapping: encode-restore is the
775-
identity.
772+
Outcome entries are already wire-shaped, so encoding is pure wrapping.
776773
"""
777774
state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked))
778775
return compact_json(state.model_dump(mode="json"))
@@ -796,9 +793,9 @@ def _outcome_from_state(entry: _StateEntry, marker: _Marker) -> ElicitationResul
796793
def _restore_outcome(res: _Resolution, key: str, marker: _Marker, q: str) -> ElicitationResult[Any] | None:
797794
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
798795
799-
An entry pinned to a question digest other than `q`, or whose accepted
800-
data fails validation against the live `schema`, is dropped as if no
801-
progress was recorded, so the question is asked again.
796+
An entry pinned to a question digest other than `q`, or that fails
797+
validation against the live marker, is dropped as if no progress was
798+
recorded, so the question is asked again.
802799
803800
Carries the original decoded entry forward unchanged in `res.persist`: if a
804801
later resolver is still pending, the next round's `request_state` is built from

src/mcp/server/session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ def client_params(self) -> types.InitializeRequestParams | None:
4545
"""The client's `initialize` request params; `None` when no client info was supplied."""
4646
return self._connection.client_params
4747

48+
@property
49+
def can_send_request(self) -> bool:
50+
"""Whether this request's channel can currently deliver a server-initiated request."""
51+
return self._request_outbound.can_send_request
52+
4853
@property
4954
def protocol_version(self) -> str:
5055
"""The protocol version this connection speaks.

tests/server/mcpserver/test_resolve.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
ElicitResult,
2121
InputRequiredResult,
2222
InputResponses,
23+
JSONRPCError,
24+
JSONRPCNotification,
25+
JSONRPCRequest,
2326
ListRootsResult,
2427
Root,
2528
SamplingCapability,
@@ -36,6 +39,7 @@
3639

3740
from mcp import Client, InputRequiredRoundsExceededError
3841
from mcp.client import ClientRequestContext
42+
from mcp.client._memory import InMemoryTransport
3943
from mcp.server.context import ServerRequestContext
4044
from mcp.server.mcpserver import (
4145
AcceptedElicitation,
@@ -69,6 +73,7 @@
6973
)
7074
from mcp.server.mcpserver.tools.base import Tool
7175
from mcp.shared.exceptions import MCPError
76+
from mcp.shared.message import SessionMessage
7277

7378

7479
def _question_digest(elicit: Elicit[Any]) -> str:
@@ -2723,3 +2728,29 @@ def test_decline_entry_for_a_sample_marker_is_invalid():
27232728
# Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation.
27242729
with pytest.raises(ValidationError):
27252730
_outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None)))
2731+
2732+
2733+
@pytest.mark.anyio
2734+
async def test_bare_initialized_session_is_still_gated():
2735+
# notifications/initialized alone commits the handshake: a live back-channel, no declared capabilities.
2736+
mcp = MCPServer(name="BareInit", request_state_security=RequestStateSecurity.ephemeral())
2737+
2738+
async def ask(ctx: Context) -> Elicit[Login]:
2739+
return Elicit("user?", Login)
2740+
2741+
@mcp.tool()
2742+
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
2743+
return login.username # pragma: no cover
2744+
2745+
async with InMemoryTransport(mcp) as (read, write):
2746+
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
2747+
await write.send(
2748+
SessionMessage(
2749+
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "tool", "arguments": {}})
2750+
)
2751+
)
2752+
with anyio.fail_after(5):
2753+
message = await read.receive()
2754+
assert isinstance(message, SessionMessage)
2755+
assert isinstance(message.message, JSONRPCError)
2756+
assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY

0 commit comments

Comments
 (0)