Skip to content

Commit 02b8519

Browse files
committed
Claimed shapes carry vendor fields verbatim; say so honestly
A short-circuiting tools/call interceptor's dict is passed through by the runner as a trusted well-formed result — nothing strips vendor top-level fields on the way to the client. The docs, tutorials, and test fixtures previously claimed the opposite and string-packed payloads into requestState; the claim models now carry real vendor fields end to end (the settings-echo test gains a dict-typed field instead of JSON string-packing). Also: the closed-surface bullet now says notification bindings shadowed by core vocabulary go quiet rather than being rejected, the core-subclass rule is scoped to the verb's result types, and the notification-binding contribution kind gets a deferred manifest entry naming its session-tier coverage.
1 parent be8e160 commit 02b8519

6 files changed

Lines changed: 47 additions & 35 deletions

File tree

docs/advanced/extensions.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,10 @@ kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
188188
is defined.
189189
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
190190
resolver that finishes it. The model must pin the tag with
191-
`result_type: Literal["receipt"]` and must not subclass a core result type — both
192-
enforced when the claim is constructed. (The payload rides `requestState` here
193-
because an `MCPServer` substituting a claimed shape serializes only the core
194-
`tools/call` surface fields; a server on another SDK may send richer shapes.)
191+
`result_type: Literal["receipt"]` and must not subclass the verb's core result
192+
types — both enforced when the claim is constructed. Vendor fields like
193+
`receipt_token` ride the wire as-is: a substituted shape reaches the client
194+
verbatim.
195195
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
196196
same public handle as `client.session`, so follow-ups are ordinary session calls.
197197
It returns the verb's normal `CallToolResult`.
@@ -238,8 +238,9 @@ resources, methods, one `tools/call` interceptor; on the client: settings, resul
238238
claims, notification bindings. An extension cannot:
239239

240240
* **Reach into the host.** It declares data; it holds no server or client reference.
241-
* **Replace core behaviour.** Spec methods are rejected at construction, and
242-
`initialize` is reserved by the runner outright.
241+
* **Replace core behaviour.** Spec methods and core result tags are rejected at
242+
construction (`initialize` is reserved by the runner outright); a notification
243+
binding shadowed by core vocabulary goes quiet with a warning instead.
243244
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
244245
set is what it is.
245246

docs_src/extensions/tutorial006.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class ReceiptResult(types.Result):
1616
"""The claimed result shape; `result_type` pins the wire tag."""
1717

1818
result_type: Literal["receipt"] = "receipt"
19-
request_state: str
19+
receipt_token: str
2020

2121

2222
class ReceiptIssuer(Extension):
@@ -32,7 +32,7 @@ async def intercept_tool_call(
3232
) -> HandlerResult:
3333
if params.name != "buy":
3434
return await call_next(ctx)
35-
return {"resultType": "receipt", "requestState": "r-117"}
35+
return {"resultType": "receipt", "receiptToken": "r-117"}
3636

3737

3838
class Receipts(ClientExtension):
@@ -44,7 +44,7 @@ def claims(self) -> Sequence[ResultClaim[Any]]:
4444
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
4545

4646
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
47-
return await ctx.session.call_tool("redeem", {"token": claimed.request_state})
47+
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
4848

4949

5050
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])

tests/client/test_client_extensions.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
44
Claimed-shape servers here are real `MCPServer`s whose SEP-2133 server extension
55
rewrites `tools/call` results via `intercept_tool_call` — the full public-API loop.
6-
The in-process server can only deliver claimed fields the v2026 tools/call surface
7-
keeps (`resultType`, `requestState`, `inputRequests`, `_meta`): the server-side
8-
`serialize_server_result` drops anything else, so claimed payloads here ride
9-
`requestState`.
6+
A short-circuiting interceptor's dict reaches the client verbatim (the runner trusts
7+
it as a well-formed result), so the claimed models carry vendor top-level fields.
108
119
`tools/call` is never cached (`Client.call_tool` has no `_cached_fetch` weave and the
1210
SEP-2549 cacheable verbs do not include it), so the claim path needs no cache tests.
@@ -48,11 +46,11 @@ def _name_elicitation() -> types.ElicitRequest:
4846

4947

5048
class VoucherResult(Result):
51-
"""The claimed `tools/call` shape, tagged `voucher`; its payload rides `requestState`
52-
(the only open payload-bearing field the in-process server's surface dump keeps)."""
49+
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field
50+
(a short-circuiting server interceptor's dict reaches the client verbatim)."""
5351

5452
result_type: Literal["voucher"] = "voucher"
55-
request_state: str | None = None
53+
voucher_code: str | None = None
5654

5755

5856
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
@@ -78,7 +76,7 @@ class _VoucherIssuer(Extension):
7876
async def intercept_tool_call(
7977
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
8078
) -> HandlerResult:
81-
return {"resultType": "voucher", "requestState": "v-42"}
79+
return {"resultType": "voucher", "voucherCode": "v-42"}
8280

8381

8482
class _TwoRoundVoucherIssuer(Extension):
@@ -91,7 +89,7 @@ async def intercept_tool_call(
9189
) -> HandlerResult:
9290
if params.input_responses is None:
9391
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
94-
return {"resultType": "voucher", "requestState": "after-input"}
92+
return {"resultType": "voucher", "voucherCode": "after-input"}
9593

9694

9795
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
@@ -414,7 +412,7 @@ async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -
414412

415413
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
416414
received.append(claimed)
417-
product = CallToolResult(content=[TextContent(text=f"honored {claimed.request_state}")])
415+
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
418416
produced.append(product)
419417
return product
420418

@@ -423,7 +421,7 @@ async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
423421
result = await client.call_tool("issue", {})
424422
assert_type(result, CallToolResult)
425423

426-
assert [claimed.request_state for claimed in received] == ["v-42"]
424+
assert [claimed.voucher_code for claimed in received] == ["v-42"]
427425
assert result is produced[0]
428426
assert result.content == [TextContent(text="honored v-42")]
429427

@@ -442,7 +440,7 @@ async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
442440
async with Client(_voucher_server(), extensions=extensions) as client:
443441
result = await client.call_tool("issue", {})
444442

445-
assert [claimed.request_state for claimed in received] == ["v-42"]
443+
assert [claimed.voucher_code for claimed in received] == ["v-42"]
446444
assert result.content == [TextContent(text="routed")]
447445

448446

@@ -586,7 +584,7 @@ async def elicitation_callback(
586584

587585
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
588586
received.append(claimed)
589-
return CallToolResult(content=[TextContent(text=f"honored {claimed.request_state}")])
587+
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
590588

591589
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
592590
with anyio.fail_after(5):
@@ -596,7 +594,7 @@ async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
596594
result = await client.call_tool("issue", {})
597595

598596
assert prompted == ["What is your name?"]
599-
assert [claimed.request_state for claimed in received] == ["after-input"]
597+
assert [claimed.voucher_code for claimed in received] == ["after-input"]
600598
assert result.content == [TextContent(text="honored after-input")]
601599

602600

tests/docs_src/test_extensions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
126126
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
127127
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
128128
assert isinstance(result, tutorial006.ReceiptResult)
129-
assert result.request_state == "r-117"
129+
assert result.receipt_token == "r-117"
130130

131131

132132
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:

tests/interaction/_requirements.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2428,6 +2428,19 @@ def __post_init__(self) -> None:
24282428
),
24292429
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
24302430
),
2431+
"extensions:client:notification-binding-delivery": Requirement(
2432+
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
2433+
behavior=(
2434+
"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."
2436+
),
2437+
added_in="2026-07-28",
2438+
deferred=(
2439+
"Covered at session tier by tests/client/test_session_notification_bindings.py: no public "
2440+
"server-side surface emits vendor-method notifications (ServerNotification is a closed union), "
2441+
"and HTTP-modern arrival additionally needs the subscriptions/listen client runtime."
2442+
),
2443+
),
24312444
# ═══════════════════════════════════════════════════════════════════════════
24322445
# Transports (in-suite coverage)
24332446
# ═══════════════════════════════════════════════════════════════════════════

tests/interaction/mcpserver/test_extensions.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22
33
The servers here are MCPServers whose server extension substitutes a claimed `tools/call`
44
shape via `intercept_tool_call`; the client declares the owning `ClientExtension` and its
5-
claim resolver finishes the call. The in-process server's 2026 result surface keeps only
6-
`resultType` / `requestState` / `inputRequests` / `_meta` on a claimed result, so claimed
7-
payloads here ride `requestState`.
5+
claim resolver finishes the call. A short-circuiting interceptor's dict is passed through
6+
verbatim (the runner trusts it as a well-formed result), so claimed shapes carry their
7+
vendor fields end to end — the models below prove that with top-level vendor fields.
88
"""
99

10-
import json
1110
from collections.abc import Awaitable, Callable, Sequence
1211
from typing import Any, Literal
1312

@@ -32,10 +31,11 @@
3231

3332

3433
class ReceiptResult(Result):
35-
"""The claimed `tools/call` shape, tagged `receipt`; its payload rides `requestState`."""
34+
"""The claimed `tools/call` shape, tagged `receipt`, carrying vendor top-level fields."""
3635

3736
result_type: Literal["receipt"] = "receipt"
38-
request_state: str
37+
receipt_token: str
38+
settings_echo: dict[str, Any] | None = None
3939

4040

4141
_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]
@@ -67,7 +67,7 @@ async def intercept_tool_call(
6767
) -> HandlerResult:
6868
if params.name != "buy":
6969
return await call_next(ctx)
70-
return {"resultType": "receipt", "requestState": "r-117"}
70+
return {"resultType": "receipt", "receiptToken": "r-117"}
7171

7272

7373
def _receipt_shop(issuer: Extension) -> MCPServer:
@@ -97,12 +97,12 @@ async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(conn
9797

9898
async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
9999
received.append(claimed)
100-
return await ctx.session.call_tool("redeem", {"token": claimed.request_state})
100+
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
101101

102102
async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
103103
result = await client.call_tool("buy", {"item": "lamp"})
104104

105-
assert [claimed.request_state for claimed in received] == ["r-117"]
105+
assert [claimed.receipt_token for claimed in received] == ["r-117"]
106106
assert result == snapshot(
107107
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
108108
)
@@ -132,7 +132,7 @@ async def intercept_tool_call(
132132
assert client_params is not None # require_client_extension just read it
133133
extensions = client_params.capabilities.extensions
134134
assert extensions is not None
135-
return {"resultType": "receipt", "requestState": json.dumps(extensions[_RECEIPTS], sort_keys=True)}
135+
return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}
136136

137137

138138
@requirement("extensions:client:capability-ad:gates-server-behaviour")
@@ -157,7 +157,7 @@ async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
157157
async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
158158
result = await client.call_tool("buy", {"item": "lamp"})
159159
assert result.content == [TextContent(text="done")]
160-
assert [json.loads(claimed.request_state) for claimed in received] == [{"tier": "gold"}]
160+
assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]
161161

162162
async with connect(server) as client:
163163
with pytest.raises(MCPError) as exc_info:

0 commit comments

Comments
 (0)