|
2 | 2 |
|
3 | 3 | An **extension** is an opt-in bundle of MCP behaviour behind one identifier. |
4 | 4 |
|
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 |
8 | 9 | it has one golden rule: **extensions are off by default**. |
9 | 10 |
|
10 | 11 | ## Using an extension |
@@ -79,7 +80,7 @@ And `main()` is the proof, an in-memory client straight against `mcp`: |
79 | 80 | An extension can register **new request methods**: its own verbs, served next to the |
80 | 81 | spec's: |
81 | 82 |
|
82 | | -```python title="server.py" hl_lines="15-21 30 39-47" |
| 83 | +```python title="server.py" hl_lines="16-22 31 40-48" |
83 | 84 | --8<-- "docs_src/extensions/tutorial004.py" |
84 | 85 | ``` |
85 | 86 |
|
@@ -108,15 +109,15 @@ runtime: |
108 | 109 |
|
109 | 110 | The same file's `main()` is the whole client story, both halves of it: |
110 | 111 |
|
111 | | -```python title="server.py" hl_lines="53-57" |
| 112 | +```python title="server.py" hl_lines="54-58" |
112 | 113 | --8<-- "docs_src/extensions/tutorial004.py" |
113 | 114 | ``` |
114 | 115 |
|
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 |
120 | 121 | `ctx.session.check_client_capability(...)` read the right source on both paths. |
121 | 122 | * Vendor methods drop one layer to `client.session.send_request(...)`; `Client` |
122 | 123 | only grows first-class methods for spec verbs. `send_request` accepts any |
@@ -144,15 +145,103 @@ or veto a tool call: |
144 | 145 | The hook wraps `tools/call` and nothing else. For every-message concerns, use |
145 | 146 | [Middleware](middleware.md). That is what it is for. |
146 | 147 |
|
| 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 | + |
147 | 234 | ## What an extension cannot do |
148 | 235 |
|
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: |
151 | 239 |
|
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. |
153 | 241 | * **Replace core behaviour.** Spec methods are rejected at construction, and |
154 | 242 | `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. |
156 | 245 |
|
157 | 246 | If you are fighting these walls, you are not writing an extension. You are writing |
158 | 247 | a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]` |
|
0 commit comments