Skip to content

Commit 4df6091

Browse files
authored
Add a client extension API (#3034)
1 parent 7322ca5 commit 4df6091

37 files changed

Lines changed: 3410 additions & 180 deletions

docs/advanced/extensions.md

Lines changed: 108 additions & 18 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,19 +109,19 @@ 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`
122-
only grows first-class methods for spec verbs. The `cast` is there because
123-
`send_request` is typed against the spec's closed request union.
123+
only grows first-class methods for spec verbs. `send_request` accepts any
124+
`Request` subclass, so the vendor request passes as-is.
124125

125126
### Intercepting `tools/call`
126127

@@ -144,15 +145,104 @@ 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="67-69"
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: 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
167+
unrecognized `resultType`. Off by default, on both ends of the wire.
168+
169+
To advertise an identifier with **no** client-side behaviour (the server gates on
170+
the capability, the client does nothing, as in the search client above), use
171+
`advertise()`:
172+
173+
```python
174+
from mcp.client import advertise
175+
176+
client = Client(mcp, extensions=[advertise("com.example/search")])
177+
```
178+
179+
## Writing a client extension
180+
181+
Subclass `ClientExtension` and override only what you need. Three contribution
182+
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
183+
184+
```python title="client.py" hl_lines="18-19 44-45 47-48"
185+
--8<-- "docs_src/extensions/tutorial006.py"
186+
```
187+
188+
* The identifier follows the same grammar as the server's, validated when the class
189+
is defined.
190+
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
191+
resolver that finishes it. The model must pin the tag with
192+
`result_type: Literal["receipt"]` and must not subclass the verb's core result
193+
types; both are enforced when the claim is constructed. Vendor fields like
194+
`receipt_token` ride the wire as-is: a substituted shape reaches the client
195+
verbatim.
196+
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
197+
same public handle as `client.session`, so follow-ups are ordinary session calls.
198+
It returns the verb's normal `CallToolResult`.
199+
* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`,
200+
read once at `Client` construction.
201+
202+
`notifications()` declares vendor server notifications to observe:
203+
204+
```python
205+
def notifications(self) -> Sequence[NotificationBinding[Any]]:
206+
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
207+
```
208+
209+
The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
210+
or reply.
211+
212+
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
213+
ad follows them: on a legacy connection the claims dissolve and the identifier drops
214+
out of the ad with them, so the client never advertises an extension whose shapes it
215+
would reject. And when you want the claimed shape yourself instead of the resolver,
216+
call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a
217+
claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
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.
153-
* **Replace core behaviour.** Spec methods are rejected at construction, and
154-
`initialize` is reserved by the runner outright.
155-
* **Register late.** After `MCPServer(...)` returns, the extension set is what it is.
240+
* **Reach into the host.** It declares data; it holds no server or client reference.
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.
244+
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
245+
set is what it is.
156246

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

docs/migration.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,11 +469,42 @@ extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, i
469469
to reject a request with the `-32021` (missing required client capability) error
470470
when the client did not declare the extension.
471471

472-
Clients advertise extension support with the new `Client(extensions=...)` /
473-
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
474-
The extensions capability map is negotiated over `server/discover` (modern path);
475-
a legacy `initialize` handshake does not carry it. Extensions are off by default
476-
and never alter behaviour unless registered.
472+
On the client, `Client(extensions=...)` takes a sequence of
473+
`mcp.client.ClientExtension` instances. A client extension contributes its
474+
capability ad (mirrored into `ClientCapabilities.extensions`), its result
475+
claims (extra `tools/call` result shapes that `Client.call_tool` resolves
476+
transparently through the claim's resolver), and its notification bindings
477+
(handlers for vendor server notifications). The capability map rides
478+
`server/discover` and every modern request's `_meta` envelope; a legacy
479+
`initialize` handshake carries only the claim-less identifiers, since claimed
480+
result shapes cannot be delivered on a legacy wire. Extensions are off by
481+
default and never alter behaviour unless registered. (The low-level
482+
`ClientSession(extensions=...)` keeps the raw identifier-to-settings dict.)
483+
484+
Changed in the v2 pre-releases: earlier alphas took
485+
`Client(extensions={identifier: settings})`, an advertisement-only dict.
486+
Extensions now contribute behaviour (claims and notification handlers), not
487+
just an ad, so the argument is a sequence of declaration objects. An ad-only
488+
entry becomes an `advertise()` call:
489+
490+
**Before (v2 alphas):**
491+
492+
```python
493+
client = Client(server, extensions={"com.example/ui": {"mimeTypes": [...]}})
494+
```
495+
496+
**After:**
497+
498+
```python
499+
from mcp.client import advertise
500+
501+
client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [...]})])
502+
```
503+
504+
`advertise()` is only for identifiers with no client-side behaviour. For a
505+
behavioural extension (e.g. tasks, once its extension ships), construct that
506+
extension's object instead; advertising an identifier you do not implement
507+
asserts wire support you don't have.
477508

478509
### `McpError` renamed to `MCPError`
479510

docs_src/apps/tutorial001.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from mcp import Client
2+
from mcp.client import advertise
23
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
34
from mcp.server.mcpserver import MCPServer
45
from mcp.server.mcpserver.context import Context
@@ -32,7 +33,7 @@ def get_time(ctx: Context) -> str:
3233

3334

3435
async def main() -> None:
35-
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
36+
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
3637
result = await client.call_tool("get_time", {})
3738
print(result.content)
3839
# [TextContent(text='2026-06-26T12:00:00Z')]

docs_src/extensions/tutorial004.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from collections.abc import Sequence
2-
from typing import Any, Literal, cast
2+
from typing import Any, Literal
33

44
import mcp_types as types
55
from pydantic import Field
66

77
from mcp import Client
8+
from mcp.client import advertise
89
from mcp.server.context import ServerRequestContext
910
from mcp.server.extension import Extension, MethodBinding
1011
from mcp.server.mcpserver import MCPServer, require_client_extension
@@ -51,8 +52,8 @@ def methods(self) -> Sequence[MethodBinding]:
5152

5253

5354
async def main() -> None:
54-
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
55+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
5556
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
56-
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
57+
result = await client.session.send_request(request, SearchResult)
5758
print(result.items)
5859
# ['mcp-0', 'mcp-1', 'mcp-2']

docs_src/extensions/tutorial006.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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, require_client_extension
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+
receipt_token: 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+
require_client_extension(ctx, EXTENSION_ID)
36+
return {"resultType": "receipt", "receiptToken": "r-117"}
37+
38+
39+
class Receipts(ClientExtension):
40+
"""Client half: claims the `receipt` shape and supplies the code that finishes it."""
41+
42+
identifier = EXTENSION_ID
43+
44+
def claims(self) -> Sequence[ResultClaim[Any]]:
45+
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
46+
47+
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
48+
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
49+
50+
51+
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])
52+
53+
54+
@mcp.tool()
55+
def buy(item: str) -> types.CallToolResult:
56+
"""Buy an item."""
57+
raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs
58+
59+
60+
@mcp.tool()
61+
def redeem(token: str) -> str:
62+
"""Exchange a receipt token for the goods."""
63+
return f"goods for {token}"
64+
65+
66+
async def main() -> None:
67+
async with Client(mcp, extensions=[Receipts()]) as client:
68+
result = await client.call_tool("buy", {"item": "lamp"})
69+
print(result.content)
70+
# [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

examples/stories/apps/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ uv run python -m stories.apps.client --http
2626
`text/html;profile=mcp-app`.
2727
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
2828
client that did not negotiate Apps gets a text-only result.
29-
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
29+
- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps
3030
support so the server returns the UI-enabled result, then reads the tool's
3131
`_meta.ui.resourceUri` and fetches that resource.
3232

examples/stories/apps/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
from mcp_types import TextContent, TextResourceContents
44

5-
from mcp.client import Client
5+
from mcp.client import Client, advertise
66
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
77
from stories._harness import Target, run_client
88

99

1010
async def main(target: Target, *, mode: str = "auto") -> None:
1111
# Advertise MCP Apps support so the server returns the UI-enabled result; a
1212
# client that omits this gets the text-only fallback (graceful degradation).
13-
async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
13+
async with Client(
14+
target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
15+
) as client:
1416
# The extensions capability map rides `server/discover` (modern only). On a
1517
# legacy connection (today's stdio) it is absent, so assert it only when present.
1618
if client.server_capabilities.extensions is not None:

0 commit comments

Comments
 (0)