Skip to content

Commit be8e160

Browse files
committed
Document client extensions and prove the loop in the interaction suite
docs/advanced/extensions.md gains the client half: using an extension (construct, pass extensions=[...], call tools normally), writing one (identifier, claims with a resolver doing real follow-up sends, notifications, read-once settings), and extension verbs via Request subclasses with name_param. Two runnable tutorials back the page. Interaction tests prove the five-sentence story over the real harness: the both-ends claimed-result loop with a resolver follow-up, the off-switch (undeclared shape fails validation; legacy ad drops claim-bearing identifiers), per-request capability gating with -32021 refusal, and Mcp-Name from name_param observed on the modern HTTP wire.
1 parent 1084616 commit be8e160

8 files changed

Lines changed: 585 additions & 18 deletions

File tree

docs/advanced/extensions.md

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
44

5-
It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
6-
The server advertises it under `capabilities.extensions`, the client opts in the same way,
7-
and nothing changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
5+
On a server it can contribute tools, resources, and new request methods, and it can wrap
6+
`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor
7+
notifications. Each side advertises under its own `capabilities.extensions`, and nothing
8+
changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
89
it has one golden rule: **extensions are off by default**.
910

1011
## Using an extension
@@ -79,7 +80,7 @@ And `main()` is the proof, an in-memory client straight against `mcp`:
7980
An extension can register **new request methods**: its own verbs, served next to the
8081
spec's:
8182

82-
```python title="server.py" hl_lines="15-21 30 39-47"
83+
```python title="server.py" hl_lines="16-22 31 40-48"
8384
--8<-- "docs_src/extensions/tutorial004.py"
8485
```
8586

@@ -108,15 +109,15 @@ runtime:
108109

109110
The same file's `main()` is the whole client story, both halves of it:
110111

111-
```python title="server.py" hl_lines="53-57"
112+
```python title="server.py" hl_lines="54-58"
112113
--8<-- "docs_src/extensions/tutorial004.py"
113114
```
114115

115-
* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
116-
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in
117-
the per-request `_meta` envelope, so the server sees it on **every** request; on
118-
a legacy connection it rides the `initialize` handshake. Server code doesn't care
119-
which: `require_client_extension(ctx, ...)` and
116+
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
117+
declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection
118+
the map travels in the per-request `_meta` envelope, so the server sees it on
119+
**every** request; on a legacy connection it rides the `initialize` handshake.
120+
Server code doesn't care which: `require_client_extension(ctx, ...)` and
120121
`ctx.session.check_client_capability(...)` read the right source on both paths.
121122
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
122123
only grows first-class methods for spec verbs. `send_request` accepts any
@@ -144,15 +145,103 @@ or veto a tool call:
144145
The hook wraps `tools/call` and nothing else. For every-message concerns, use
145146
[Middleware](middleware.md). That is what it is for.
146147

148+
## Using a client extension
149+
150+
A **client extension** is the same contract from the consuming side: a bundle of
151+
client-side behaviour behind one identifier. Pass instances to
152+
`Client(extensions=[...])` and call tools normally:
153+
154+
```python title="client.py" hl_lines="66-68"
155+
--8<-- "docs_src/extensions/tutorial006.py"
156+
```
157+
158+
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
159+
the extension changed: the server may now answer `buy` with a `receipt` **result
160+
shape** instead of a final result, and `Receipts` finishes it — here by redeeming the
161+
receipt with a follow-up call — before `call_tool` returns. Nothing about the call
162+
site moves.
163+
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
166+
unrecognized `resultType`. Off by default, on both ends of the wire.
167+
168+
To advertise an identifier with **no** client-side behaviour — the server gates on
169+
the capability, the client does nothing, as in the search client above — use
170+
`advertise()`:
171+
172+
```python
173+
from mcp.client import advertise
174+
175+
client = Client(mcp, extensions=[advertise("com.example/search")])
176+
```
177+
178+
## Writing a client extension
179+
180+
Subclass `ClientExtension` and override only what you need. Three contribution
181+
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
182+
183+
```python title="client.py" hl_lines="18-19 43-44 46-47"
184+
--8<-- "docs_src/extensions/tutorial006.py"
185+
```
186+
187+
* The identifier follows the same grammar as the server's, validated when the class
188+
is defined.
189+
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
190+
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.)
195+
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
196+
same public handle as `client.session`, so follow-ups are ordinary session calls.
197+
It returns the verb's normal `CallToolResult`.
198+
* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`,
199+
read once at `Client` construction.
200+
201+
`notifications()` declares vendor server notifications to observe:
202+
203+
```python
204+
def notifications(self) -> Sequence[NotificationBinding[Any]]:
205+
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
206+
```
207+
208+
The handler receives validated params, in arrival order. It observes; it cannot veto
209+
or reply.
210+
211+
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
212+
ad follows them: on a legacy connection the claims dissolve and the identifier drops
213+
out of the ad in the same breath, so the client never advertises an extension whose
214+
shapes it would reject. And when you want the claimed shape yourself instead of the
215+
resolver, call `client.session.call_tool(..., allow_claimed=True)` — the escape hatch
216+
`UnexpectedClaimedResult` names when a claimed shape reaches a session-tier caller
217+
that didn't opt in.
218+
219+
### Extension verbs
220+
221+
An extension's own request methods need no client-side registration. A vendor request
222+
type subclasses `mcp_types.Request` and goes through `client.session.send_request`,
223+
as in [Serving your own methods](#serving-your-own-methods). One addition: when a
224+
params key must ride the `Mcp-Name` header (extension specs such as tasks require
225+
this for their verbs), the request type declares `name_param`:
226+
227+
```python title="client.py" hl_lines="23-26 47-48"
228+
--8<-- "docs_src/extensions/tutorial007.py"
229+
```
230+
231+
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
232+
missing value fails loudly rather than silently omitting a required header.
233+
147234
## What an extension cannot do
148235

149-
The contribution surface is **closed** on purpose: settings, tools, resources,
150-
methods, one `tools/call` interceptor. An extension cannot:
236+
The contribution surface is **closed** on purpose — on the server: settings, tools,
237+
resources, methods, one `tools/call` interceptor; on the client: settings, result
238+
claims, notification bindings. An extension cannot:
151239

152-
* **Reach into the server.** It declares data; it holds no server reference.
240+
* **Reach into the host.** It declares data; it holds no server or client reference.
153241
* **Replace core behaviour.** Spec methods are rejected at construction, and
154242
`initialize` is reserved by the runner outright.
155-
* **Register late.** After `MCPServer(...)` returns, the extension set is what it is.
243+
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
244+
set is what it is.
156245

157246
If you are fighting these walls, you are not writing an extension. You are writing
158247
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`

docs_src/extensions/tutorial006.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from collections.abc import Sequence
2+
from typing import Any, Literal
3+
4+
import mcp_types as types
5+
6+
from mcp import Client
7+
from mcp.client import ClaimContext, ClientExtension, ResultClaim
8+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
9+
from mcp.server.extension import Extension
10+
from mcp.server.mcpserver import MCPServer
11+
12+
EXTENSION_ID = "com.example/receipts"
13+
14+
15+
class ReceiptResult(types.Result):
16+
"""The claimed result shape; `result_type` pins the wire tag."""
17+
18+
result_type: Literal["receipt"] = "receipt"
19+
request_state: str
20+
21+
22+
class ReceiptIssuer(Extension):
23+
"""Server half: answers `buy` with a receipt instead of a final result."""
24+
25+
identifier = EXTENSION_ID
26+
27+
async def intercept_tool_call(
28+
self,
29+
params: types.CallToolRequestParams,
30+
ctx: ServerRequestContext[Any, Any],
31+
call_next: CallNext,
32+
) -> HandlerResult:
33+
if params.name != "buy":
34+
return await call_next(ctx)
35+
return {"resultType": "receipt", "requestState": "r-117"}
36+
37+
38+
class Receipts(ClientExtension):
39+
"""Client half: claims the `receipt` shape and supplies the code that finishes it."""
40+
41+
identifier = EXTENSION_ID
42+
43+
def claims(self) -> Sequence[ResultClaim[Any]]:
44+
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
45+
46+
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
47+
return await ctx.session.call_tool("redeem", {"token": claimed.request_state})
48+
49+
50+
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])
51+
52+
53+
@mcp.tool()
54+
def buy(item: str) -> types.CallToolResult:
55+
"""Buy an item."""
56+
raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs
57+
58+
59+
@mcp.tool()
60+
def redeem(token: str) -> str:
61+
"""Exchange a receipt token for the goods."""
62+
return f"goods for {token}"
63+
64+
65+
async def main() -> None:
66+
async with Client(mcp, extensions=[Receipts()]) as client:
67+
result = await client.call_tool("buy", {"item": "lamp"})
68+
print(result.content)
69+
# [TextContent(text='goods for r-117')]

docs_src/extensions/tutorial007.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from collections.abc import Sequence
2+
from typing import Any, Literal
3+
4+
import mcp_types as types
5+
6+
from mcp import Client
7+
from mcp.client import advertise
8+
from mcp.server.context import ServerRequestContext
9+
from mcp.server.extension import Extension, MethodBinding
10+
from mcp.server.mcpserver import MCPServer
11+
12+
EXTENSION_ID = "com.example/jobs"
13+
14+
15+
class JobParams(types.RequestParams):
16+
job_id: str
17+
18+
19+
class JobStatus(types.Result):
20+
status: str
21+
22+
23+
class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]):
24+
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
25+
params: JobParams
26+
name_param = "jobId" # params["jobId"] rides the Mcp-Name header
27+
28+
29+
async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus:
30+
return JobStatus(status=f"{params.job_id} is running")
31+
32+
33+
class Jobs(Extension):
34+
"""An extension whose verb names its subject, so the header can route on it."""
35+
36+
identifier = EXTENSION_ID
37+
38+
def methods(self) -> Sequence[MethodBinding]:
39+
return [MethodBinding("com.example/jobs.status", JobParams, job_status)]
40+
41+
42+
mcp = MCPServer("worker", extensions=[Jobs()])
43+
44+
45+
async def main() -> None:
46+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
47+
request = JobStatusRequest(params=JobParams(job_id="job-7"))
48+
result = await client.session.send_request(request, JobStatus)
49+
print(result.status)
50+
# job-7 is running

tests/docs_src/test_extensions.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@
55
import pytest
66
from inline_snapshot import snapshot
77
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
8-
9-
from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
8+
from pydantic import ValidationError
9+
10+
from docs_src.extensions import (
11+
tutorial001,
12+
tutorial002,
13+
tutorial003,
14+
tutorial004,
15+
tutorial005,
16+
tutorial006,
17+
tutorial007,
18+
)
1019
from mcp import Client, MCPError
1120
from mcp.client import advertise
1221
from mcp.server.extension import Extension
@@ -94,3 +103,34 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through(
94103
assert result.structured_content == {"result": 5}
95104
messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
96105
assert messages == ["tool 'add' called"]
106+
107+
108+
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
109+
"""tutorial006: `main()` is the literal client program on the page — the claimed
110+
`receipt` shape never surfaces, and the printed content is the redeemed result."""
111+
await tutorial006.main()
112+
assert "goods for r-117" in capsys.readouterr().out
113+
114+
115+
async def test_a_claimed_shape_fails_validation_without_the_extension() -> None:
116+
"""The page's off-by-default claim: a client that does not construct `Receipts`
117+
rejects the `receipt` shape as invalid (spec: an unrecognized resultType is invalid)."""
118+
async with Client(tutorial006.mcp) as client:
119+
with pytest.raises(ValidationError):
120+
await client.call_tool("buy", {"item": "lamp"})
121+
122+
123+
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
124+
"""The page's escape hatch: `client.session.call_tool(..., allow_claimed=True)` hands
125+
back the parsed claim model instead of running the resolver."""
126+
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
127+
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
128+
assert isinstance(result, tutorial006.ReceiptResult)
129+
assert result.request_state == "r-117"
130+
131+
132+
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
133+
"""tutorial007: a vendor request type with `name_param` goes through
134+
`client.session.send_request` with no registration and returns its typed result."""
135+
await tutorial007.main()
136+
assert "job-7 is running" in capsys.readouterr().out

0 commit comments

Comments
 (0)