Skip to content

Commit 99fec3c

Browse files
committed
Address review feedback on claim construction and notification ordering
ResultClaim now rejects models that do not subclass Result at runtime (the typing bound only constrains checked callers) and models whose fields alias requestState or inputRequests, which the core result surface types and would reject before the claim adapter runs. The notification ordering contract is stated honestly: per-binding serial delivery in dispatch order, with near-simultaneous notifications on stream transports possibly dispatched out of wire order. The receipts tutorial server now gates substitution on the client declaring the extension, the stale custom-methods README caveat points at NotificationBinding, and the capability-ad docstring no longer overstates which identifiers drop.
1 parent 8157846 commit 99fec3c

8 files changed

Lines changed: 70 additions & 19 deletions

File tree

docs/advanced/extensions.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ A **client extension** is the same contract from the consuming side: a bundle of
151151
client-side behaviour behind one identifier. Pass instances to
152152
`Client(extensions=[...])` and call tools normally:
153153

154-
```python title="client.py" hl_lines="66-68"
154+
```python title="client.py" hl_lines="67-69"
155155
--8<-- "docs_src/extensions/tutorial006.py"
156156
```
157157

@@ -161,8 +161,9 @@ shape** instead of a final result, and `Receipts` finishes it (here by redeeming
161161
receipt with a follow-up call) before `call_tool` returns. Nothing about the call
162162
site moves.
163163

164-
Drop the extension and none of this exists: a `receipt` shape arriving at a client
165-
that didn't declare it fails validation, exactly as the spec requires for an
164+
Drop the extension and none of this exists: the server's gate refuses a client
165+
that did not declare it (error -32021), and a claimed shape from a server that
166+
skips the gate fails validation, exactly as the spec requires for an
166167
unrecognized `resultType`. Off by default, on both ends of the wire.
167168

168169
To advertise an identifier with **no** client-side behaviour (the server gates on
@@ -180,7 +181,7 @@ client = Client(mcp, extensions=[advertise("com.example/search")])
180181
Subclass `ClientExtension` and override only what you need. Three contribution
181182
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
182183

183-
```python title="client.py" hl_lines="18-19 43-44 46-47"
184+
```python title="client.py" hl_lines="18-19 44-45 47-48"
184185
--8<-- "docs_src/extensions/tutorial006.py"
185186
```
186187

@@ -205,7 +206,7 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]:
205206
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
206207
```
207208

208-
The handler receives validated params, in arrival order. It observes; it cannot veto
209+
The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
209210
or reply.
210211

211212
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability

docs_src/extensions/tutorial006.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from mcp.client import ClaimContext, ClientExtension, ResultClaim
88
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
99
from mcp.server.extension import Extension
10-
from mcp.server.mcpserver import MCPServer
10+
from mcp.server.mcpserver import MCPServer, require_client_extension
1111

1212
EXTENSION_ID = "com.example/receipts"
1313

@@ -32,6 +32,7 @@ async def intercept_tool_call(
3232
) -> HandlerResult:
3333
if params.name != "buy":
3434
return await call_next(ctx)
35+
require_client_extension(ctx, EXTENSION_ID)
3536
return {"resultType": "receipt", "receiptToken": "r-117"}
3637

3738

examples/stories/custom_methods/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ uv run python -m stories.custom_methods.client --http
3434
## Caveats
3535

3636
- The TypeScript SDK's equivalent example also shows a custom server→client
37-
**notification** (`acme/searchProgress`). The Python client currently drops
38-
any notification whose method is not in the spec registry
39-
(`ClientSession._on_notify``KeyError` → silent drop), and there is no
40-
`set_notification_handler` analogue. That half is omitted here.
37+
**notification** (`acme/searchProgress`). The Python client can observe
38+
vendor notifications via `NotificationBinding` (see
39+
`docs/advanced/extensions.md`). That half is omitted here because the
40+
lowlevel server has no surface for emitting vendor notifications yet.
4141

4242
## Spec
4343

src/mcp/client/extension.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
_CLAIM_METHODS: Final[frozenset[str]] = frozenset({"tools/call"})
3333
"""The closed set of verbs a claim may attach to; widen together with the `method` Literal."""
3434

35+
_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"})
36+
"""Typed optional fields of the core result surface that pre-validates every inbound result."""
37+
3538
ClaimedT = TypeVar("ClaimedT", bound=Result)
3639
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
3740

@@ -66,8 +69,17 @@ def __post_init__(self) -> None:
6669
raise ValueError(f"claims attach to {sorted(_CLAIM_METHODS)} only; got method {self.method!r}")
6770
if self.result_type in CORE_RESULT_TYPES:
6871
raise ValueError(f"resultType {self.result_type!r} is core protocol vocabulary")
72+
if Result not in self.model.__mro__: # runtime guard; the ClaimedT bound only constrains checked callers
73+
raise ValueError(f"{self.model.__name__} must subclass mcp_types.Result")
6974
if issubclass(self.model, CallToolResult | InputRequiredResult):
7075
raise ValueError("claim models must not subclass core result types")
76+
for name, model_field in self.model.model_fields.items():
77+
if (model_field.alias or name) in _RESERVED_WIRE_ALIASES:
78+
raise ValueError(
79+
f"{self.model.__name__}.{name} aliases {model_field.alias or name!r}, a typed field "
80+
"of the core result surface; a colliding value would fail core validation before "
81+
"the claim adapter runs"
82+
)
7183
field = self.model.model_fields.get("result_type")
7284
if field is None or get_args(field.annotation) != (self.result_type,):
7385
raise ValueError(f"{self.model.__name__}.result_type must be Literal[{self.result_type!r}]")
@@ -101,9 +113,11 @@ def __init__(self, result: Result) -> None:
101113
class NotificationBinding(Generic[NotifyParamsT]):
102114
"""Deliver server notifications for `method` (the bare wire name) to `handler`.
103115
104-
Observation-only: validated params arrive in order through a bounded queue,
105-
dropping the oldest with a warning on overflow. Methods the negotiated
106-
version's core tables handle are never delivered to bindings.
116+
Observation-only: validated params arrive one at a time per binding, in
117+
dispatch order, through a bounded queue that drops the oldest with a warning
118+
on overflow. Stream transports dispatch each notification independently, so
119+
near-simultaneous notifications may be dispatched out of wire order. Methods
120+
the negotiated version's core tables handle are never delivered to bindings.
107121
"""
108122

109123
method: str

src/mcp/client/session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,9 @@ async def send_notification(self, notification: types.ClientNotification) -> Non
505505
def _build_capabilities(self, version: str) -> types.ClientCapabilities:
506506
"""Build the capability ad for a wire speaking `version`.
507507
508-
Identifiers with no active claim drop, so the client never advertises result shapes it would reject.
508+
Claim-bearing identifiers whose claims are all inactive at `version` drop, so
509+
the client never advertises result shapes it would reject; claim-less
510+
identifiers always advertise.
509511
"""
510512
extensions = self._extensions
511513
if extensions is not None and self._result_claims:

tests/client/test_extension.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,34 @@ def test_claim_rejects_mismatched_result_type_literal() -> None:
120120
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
121121

122122

123+
class _NotAResult(BaseModel):
124+
result_type: Literal["plain"] = "plain"
125+
126+
127+
class _ReservedAliasResult(Result):
128+
result_type: Literal["clash"] = "clash"
129+
request_state: dict[str, Any] = {}
130+
131+
132+
def test_claim_rejects_model_not_subclassing_result() -> None:
133+
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
134+
with pytest.raises(ValueError) as exc_info:
135+
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
136+
137+
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
138+
139+
140+
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
141+
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
142+
with pytest.raises(ValueError) as exc_info:
143+
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
144+
145+
assert str(exc_info.value) == snapshot(
146+
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
147+
"result surface; a colliding value would fail core validation before the claim adapter runs"
148+
)
149+
150+
123151
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
124152
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
125153
with pytest.raises(ValueError) as exc_info:

tests/docs_src/test_extensions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import pytest
66
from inline_snapshot import snapshot
77
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
8-
from pydantic import ValidationError
98

109
from docs_src.extensions import (
1110
tutorial001,
@@ -111,11 +110,12 @@ async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureF
111110
assert "goods for r-117" in capsys.readouterr().out
112111

113112

114-
async def test_a_claimed_shape_fails_validation_without_the_extension() -> None:
115-
"""The page's off-by-default claim: a client without `Receipts` rejects the `receipt` shape as invalid."""
113+
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
114+
"""The page's off-by-default claim: the server's capability gate refuses a non-declaring client."""
116115
async with Client(tutorial006.mcp) as client:
117-
with pytest.raises(ValidationError):
116+
with pytest.raises(MCPError) as exc_info:
118117
await client.call_tool("buy", {"item": "lamp"})
118+
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
119119

120120

121121
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:

tests/interaction/_requirements.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2403,6 +2403,11 @@ def __post_init__(self) -> None:
24032403
"declared claims, never more)."
24042404
),
24052405
added_in="2026-07-28",
2406+
note=(
2407+
"Known leniency: the monolith result surface still accepts an unknown tag when the payload "
2408+
"also parses as a complete core result (open result_type, extras ignored). Rejecting tags "
2409+
"outside core plus active claims is a tracked follow-up ruling."
2410+
),
24062411
),
24072412
"extensions:client:capability-ad:gates-server-behaviour": Requirement(
24082413
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
@@ -2432,7 +2437,7 @@ def __post_init__(self) -> None:
24322437
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
24332438
behavior=(
24342439
"A vendor server notification bound by a ClientExtension's NotificationBinding is validated "
2435-
"against the binding's params type and delivered to its handler in arrival order."
2440+
"against the binding's params type and delivered to its handler serially, in dispatch order."
24362441
),
24372442
added_in="2026-07-28",
24382443
deferred=(

0 commit comments

Comments
 (0)