Skip to content

Commit 1a12981

Browse files
committed
Tighten comments and docstrings in the resolver marker additions
1 parent f24a39f commit 1a12981

3 files changed

Lines changed: 31 additions & 82 deletions

File tree

src/mcp/client/client.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,7 @@ async def main():
304304
"""Callback for handling sampling requests."""
305305

306306
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-
"""
307+
"""Sampling sub-capabilities (e.g. tools) declared alongside `sampling_callback`; no effect without it."""
311308

312309
list_roots_callback: ListRootsFnT | None = None
313310
"""Callback for handling list roots requests."""

src/mcp/server/mcpserver/resolve.py

Lines changed: 24 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,13 @@
44
resolver `fn` before the tool body, instead of from the LLM-supplied arguments.
55
Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies,
66
take tool arguments by name, and take the `Context`. A resolver may return a
7-
request marker - `Elicit[T]` to ask the user, `Sample` to run an LLM call on the
8-
client, or `ListRoots` to fetch the client's roots - and the framework runs the
9-
request and injects the response. These are the three request kinds the
10-
multi-round-trip flow allows.
11-
12-
The framework picks the transport from the negotiated protocol. At >= 2026-07-28
13-
it returns an `InputRequiredResult` carrying the batched requests and resumes
14-
when the client retries with `input_responses`/`request_state` (independent
15-
resolvers are asked in one round; a resolver depending on another's answer is
16-
asked in a later round). At <= 2025-11-25 it issues the standalone server-to-client
17-
request (`elicitation/create`, `sampling/createMessage`, `roots/list`) mid-call.
18-
Only *asked* outcomes are carried in `request_state` across rounds (so the user
19-
is asked - and the client's LLM is sampled - once per question). Resolver
7+
request marker (`Elicit[T]` to ask the user, `Sample` to sample the client's
8+
LLM, `ListRoots` to fetch its roots); the framework injects the response.
9+
10+
The transport follows the negotiated protocol: >= 2026-07-28 batches the requests
11+
into an `InputRequiredResult` and resumes when the client retries with
12+
`input_responses`/`request_state`; <= 2025-11-25 sends each standalone server-to-client
13+
request mid-call. Only *asked* outcomes ride `request_state`, so each question is asked once. Resolver
2014
bodies may re-run on every round; a recorded outcome is consulted only when the
2115
body asks its question again, so a resolver's own computation always wins over
2216
anything the client echoes back in `request_state`.
@@ -28,9 +22,7 @@
2822
- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the
2923
full outcome; the consumer branches on accept/decline/cancel.
3024
31-
`Sample` and `ListRoots` have no decline arm (a client refuses by erroring), so
32-
their consumers annotate the result directly: `CreateMessageResult` (or
33-
`CreateMessageResultWithTools` when tools are given) and `ListRootsResult`.
25+
`Sample` and `ListRoots` have no decline arm; their consumers annotate the result type directly.
3426
"""
3527

3628
from __future__ import annotations
@@ -127,22 +119,12 @@ def __init__(self, message: str, schema: type[T]) -> None:
127119

128120

129121
class Sample:
130-
"""A resolver's request to sample the client's LLM.
131-
132-
Returned from a resolver to have the client run an LLM call; the framework
133-
injects the `CreateMessageResult` (a `CreateMessageResultWithTools` when
134-
`tools` are given). Requires the client to declare the `sampling` capability
135-
(plus `sampling.tools` when tools are given). Mirrors the parameters of
136-
`sampling/createMessage`.
137-
138-
On >= 2026-07-28 the rendered request must be identical across retry rounds
139-
(the recorded result is pinned to it) - derive it only from tool arguments
140-
and stable data, never timestamps or random values. The sampled result rides
141-
the `request_state` envelope on every remaining round, so very large
142-
completions inflate the rest of the exchange.
143-
144-
Note: `include_context` values other than "none" are deprecated in the draft
145-
specification and should be avoided.
122+
"""A resolver's request to sample the client's LLM via `sampling/createMessage`.
123+
124+
The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` are
125+
given); requires the `sampling` capability (`sampling.tools` when tools 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.
146128
"""
147129

148130
def __init__(
@@ -175,16 +157,11 @@ def __init__(
175157

176158

177159
class ListRoots:
178-
"""A resolver's request for the client's current roots.
179-
180-
Returned from a resolver to fetch the client's roots list; the framework
181-
injects the `ListRootsResult`. Requires the client to declare the `roots`
182-
capability.
183-
"""
160+
"""A resolver's request for the client's roots via `roots/list`; the framework injects the `ListRootsResult`."""
184161

185162

186163
_Marker = Elicit[Any] | Sample | ListRoots
187-
"""The request kinds a resolver may return - the closed set the multi-round-trip flow allows."""
164+
"""The request markers a resolver may return."""
188165

189166

190167
class _ParamPlan:
@@ -308,18 +285,14 @@ def _check_elicit_return(return_annotation: Any, name: str) -> None:
308285
"""Validate the request-marker arms of a resolver's return annotation.
309286
310287
Raises:
311-
InvalidSignature: If the annotation has more than one marker arm
312-
(`Elicit[...]`, `Sample`, `ListRoots`); a resolver asks one
313-
question - a second arm means it should be split.
288+
InvalidSignature: If the annotation has more than one marker arm.
314289
"""
315-
# A bare marker type is itself a candidate; a union contributes its members.
316290
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
317291
# Typing dedupes equal union members, so two arms here are genuinely distinct.
318292
arms: list[Any] = [
319293
c
320294
for c in candidates
321-
# The `get_origin(c) is None` guard keeps 3.10 safe: there `dict[str, Any]`
322-
# passes `isinstance(c, type)` and would crash `issubclass`.
295+
# Origin guard for 3.10: `dict[str, Any]` passes `isinstance(c, type)` there and would crash `issubclass`.
323296
if get_origin(c) is Elicit
324297
or (get_origin(c) is None and isinstance(c, type) and issubclass(c, Elicit | Sample | ListRoots))
325298
]
@@ -597,10 +570,8 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
597570
async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationResult[Any]:
598571
"""Turn a resolver's request marker into an outcome via the negotiated transport."""
599572
if not res.input_required:
600-
# Gate only when the handshake's declaration is visible. A session with no
601-
# client info (e.g. stateless HTTP) has no back-channel either, and the send
602-
# path reports that truthfully; on >= 2026-07-28 absence means not declared,
603-
# because capabilities arrive per-request there.
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.
604575
if res.context.client_capabilities is not None:
605576
_require_capability(res.context, marker, key)
606577
if isinstance(marker, Elicit):
@@ -633,9 +604,7 @@ async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationRes
633604
res.pending[key] = request
634605
raise _Pending
635606
if not isinstance(marker, Elicit):
636-
# The response union cannot always discriminate the two sampling result shapes
637-
# (a no-tool-use answer to a tools request parses as the plain one), so validate
638-
# the wire data against the marker's expected model instead of the union member.
607+
# A no-tool-use answer to a tools request parses as the plain result; validate against the marker's model.
639608
wire = answer.model_dump(mode="json", by_alias=True, exclude_none=True)
640609
try:
641610
result = _result_type(marker).model_validate(wire)
@@ -672,7 +641,6 @@ def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any:
672641

673642

674643
def _is_marker(value: Any) -> TypeGuard[_Marker]:
675-
"""Runtime narrow of a resolver's return value to a request marker."""
676644
return isinstance(value, Elicit | Sample | ListRoots)
677645

678646

@@ -695,12 +663,9 @@ def _uses_input_required(protocol_version: str | None) -> bool:
695663

696664

697665
def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -> None:
698-
"""Assert the client declared the capability `marker`'s request needs before sending it.
666+
"""Assert the client declared the capability `marker`'s request needs.
699667
700-
The spec forbids sending a client a request it has not declared a capability
701-
for; the same predicate gates both transports. A bare `elicitation: {}`
702-
declaration (the only shape before modes existed) counts as form support; an
703-
explicit url-only declaration does not.
668+
A bare `elicitation: {}` (the only shape before modes existed) counts as form support; url-only does not.
704669
705670
Raises:
706671
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a
@@ -817,9 +782,7 @@ def _outcome_from_state(entry: _StateEntry, marker: _Marker) -> ElicitationResul
817782
"""Rebuild an outcome from a decoded `request_state` entry.
818783
819784
Raises:
820-
ValidationError: If the entry does not fit the live marker - accepted
821-
data failing the expected shape, or a decline/cancel recorded for a
822-
kind that has no such outcome (its `data` is `None`).
785+
ValidationError: If the entry does not fit the live marker.
823786
"""
824787
if isinstance(marker, Elicit):
825788
if entry.action == "decline":

tests/server/mcpserver/test_resolve.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2407,8 +2407,7 @@ def _sample_capital(ctx: Context) -> Sample:
24072407
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
24082408
@pytest.mark.parametrize("mode", ["legacy", "auto"])
24092409
async def test_sample_resolver_injects_result(mode: Literal["legacy", "auto"]):
2410-
# Marker-routed sampling must not fire the SEP-2577 deprecation warning on
2411-
# either transport: the embedded/marker form is the 2026-blessed carrier.
2410+
# The marker form is the 2026-blessed carrier: no SEP-2577 deprecation warning on either mode.
24122411
mcp = MCPServer(name="Sampler", request_state_security=RequestStateSecurity.ephemeral())
24132412
prompts: list[str] = []
24142413

@@ -2473,7 +2472,6 @@ async def combo(
24732472
first = await client.session.call_tool("combo", {}, allow_input_required=True)
24742473
assert isinstance(first, InputRequiredResult)
24752474
assert first.input_requests is not None
2476-
# All three kinds are asked together in a single round.
24772475
kinds = sorted(type(req).__name__ for req in first.input_requests.values())
24782476
assert kinds == ["CreateMessageRequest", "ElicitRequest", "ListRootsRequest"]
24792477
responses: InputResponses = {}
@@ -2531,9 +2529,7 @@ async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) ->
25312529

25322530
@pytest.mark.anyio
25332531
async def test_legacy_eliciting_tool_without_capability_is_a_protocol_error():
2534-
# The 2025 back-channel leg enforces the same egress gate as `input_requests`:
2535-
# a client that never declared elicitation gets -32021 instead of a request it
2536-
# cannot handle, and the session keeps working afterwards.
2532+
# Same egress gate as the input_requests leg; the session stays usable after the refusal.
25372533
mcp = MCPServer(name="LegacyGate", request_state_security=RequestStateSecurity.ephemeral())
25382534

25392535
async def ask(ctx: Context) -> Elicit[Login]:
@@ -2573,8 +2569,6 @@ def _ask_with_tool_choice(ctx: Context) -> Sample:
25732569
@pytest.mark.anyio
25742570
@pytest.mark.parametrize("ask", [_ask_with_tools, _ask_with_tool_choice])
25752571
async def test_sample_tools_require_the_tools_subcapability(ask: Callable[[Context], Sample]):
2576-
# Either `tools` or `tool_choice` on the marker demands `sampling.tools`, and the
2577-
# refusal names the full requirement so the client can remediate in one step.
25782572
mcp = MCPServer(name="NoToolsSubcapability", request_state_security=RequestStateSecurity.ephemeral())
25792573

25802574
@mcp.tool()
@@ -2615,9 +2609,7 @@ async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with
26152609

26162610
@pytest.mark.anyio
26172611
async def test_no_tool_use_answer_to_a_tools_request_is_accepted():
2618-
# A model may legally answer a tools request without using a tool; the wire
2619-
# payload then parses out of the response union as the plain result shape.
2620-
# The answer must still validate and inject as `CreateMessageResultWithTools`.
2612+
# The answer parses off the wire as plain CreateMessageResult but must inject as CreateMessageResultWithTools.
26212613
mcp = MCPServer(name="NoToolUse", request_state_security=RequestStateSecurity.ephemeral())
26222614

26232615
@mcp.tool()
@@ -2652,8 +2644,7 @@ async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with
26522644

26532645
@pytest.mark.anyio
26542646
async def test_sample_outcome_persists_across_rounds():
2655-
# A dependent chain forces three rounds; the client's LLM is sampled exactly
2656-
# once and later rounds restore the recorded result from `request_state`.
2647+
# The confirm arm depends on the sample, forcing extra rounds that restore the result instead of re-sampling.
26572648
mcp = MCPServer(name="Chain", request_state_security=RequestStateSecurity.ephemeral())
26582649
samples = 0
26592650

@@ -2718,8 +2709,7 @@ async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
27182709

27192710

27202711
def test_marker_union_with_generic_alias_member_registers():
2721-
# `dict[str, Any]` passes `isinstance(c, type)` on Python 3.10; the arm filter
2722-
# must not feed it to `issubclass`.
2712+
# dict[str, Any] passes isinstance(c, type) on Python 3.10; the arm filter must not feed it to issubclass.
27232713
async def maybe_ask(ctx: Context) -> Sample | dict[str, Any]:
27242714
raise NotImplementedError # pragma: no cover
27252715

@@ -2730,7 +2720,6 @@ async def tool(answer: Annotated[CreateMessageResult, Resolve(maybe_ask)]) -> st
27302720

27312721

27322722
def test_decline_entry_for_a_sample_marker_is_invalid():
2733-
# Only elicitations have decline/cancel outcomes; a decline entry consulted by a
2734-
# Sample marker fails validation (data is None) and is dropped for a re-ask.
2723+
# Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation.
27352724
with pytest.raises(ValidationError):
27362725
_outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None)))

0 commit comments

Comments
 (0)