|
| 1 | +# Extensions |
| 2 | + |
| 3 | +An **extension** is an opt-in bundle of MCP behaviour behind one identifier. |
| 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), and |
| 8 | +it has one golden rule: **extensions are off by default**. |
| 9 | + |
| 10 | +## Using an extension |
| 11 | + |
| 12 | +Pass instances at construction: |
| 13 | + |
| 14 | +```python title="server.py" |
| 15 | +--8<-- "docs_src/extensions/tutorial001.py" |
| 16 | +``` |
| 17 | + |
| 18 | +Done. The server now advertises `io.modelcontextprotocol/ui` under |
| 19 | +`capabilities.extensions` and serves everything the extension contributes. |
| 20 | + |
| 21 | +`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**. |
| 22 | + |
| 23 | +!!! note |
| 24 | + Extensions are fixed at construction. There is no `add_extension` to call later: |
| 25 | + a server's capability map should not change while clients are connected to it. |
| 26 | + |
| 27 | +The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy |
| 28 | +`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see |
| 29 | +the extension. Design for that: an extension *augments* a server, it must not be the |
| 30 | +only way the server is usable. |
| 31 | + |
| 32 | +## Writing your own |
| 33 | + |
| 34 | +Subclass `Extension` and override only what you need. Every method has a default. |
| 35 | + |
| 36 | +### The identifier |
| 37 | + |
| 38 | +```python |
| 39 | +--8<-- "docs_src/extensions/tutorial002.py" |
| 40 | +``` |
| 41 | + |
| 42 | +The identifier is a `vendor-prefix/name` string following the spec's `_meta` key |
| 43 | +grammar: dot-separated labels (each starts with a letter, ends with a letter or |
| 44 | +digit), a slash, then the name. It is validated **when the class is defined**, so a |
| 45 | +typo doesn't wait for a server to boot: |
| 46 | + |
| 47 | +```text |
| 48 | +TypeError: Stamps.identifier must be a `vendor-prefix/name` string |
| 49 | +(reverse-DNS prefix required), got 'stamps' |
| 50 | +``` |
| 51 | + |
| 52 | +Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions |
| 53 | +specified by the MCP project itself. |
| 54 | + |
| 55 | +### Contributing tools |
| 56 | + |
| 57 | +The smallest useful extension is one tool and a settings map: |
| 58 | + |
| 59 | +```python title="server.py" hl_lines="17 19-20 22-23 26" |
| 60 | +--8<-- "docs_src/extensions/tutorial003.py" |
| 61 | +``` |
| 62 | + |
| 63 | +* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you |
| 64 | + had called `mcp.add_tool(...)` yourself: same schema generation, same `Context` |
| 65 | + injection, same everything. |
| 66 | +* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`. |
| 67 | + Return `{}` (the default) to advertise the extension with no settings. |
| 68 | +* The extension never receives the server. It declares contributions as data; |
| 69 | + `MCPServer` consumes them. There is no `self.server` to mutate. |
| 70 | + |
| 71 | +And `main()` is the proof, an in-memory client straight against `mcp`: |
| 72 | + |
| 73 | +```python title="server.py" hl_lines="29-34" |
| 74 | +--8<-- "docs_src/extensions/tutorial003.py" |
| 75 | +``` |
| 76 | + |
| 77 | +### Serving your own methods |
| 78 | + |
| 79 | +An extension can register **new request methods**: its own verbs, served next to the |
| 80 | +spec's: |
| 81 | + |
| 82 | +```python title="server.py" hl_lines="15-21 30 39-47" |
| 83 | +--8<-- "docs_src/extensions/tutorial004.py" |
| 84 | +``` |
| 85 | + |
| 86 | +* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses |
| 87 | + uniformly and your handler gets validated params, never a raw dict. Bound what |
| 88 | + the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before |
| 89 | + your code allocates anything for it. |
| 90 | +* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not |
| 91 | + declare the extension gets the `-32021` (missing required client capability) error, |
| 92 | + with the machine-readable `requiredCapabilities` payload the spec asks for. |
| 93 | +* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version. |
| 94 | + At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method |
| 95 | + didn't exist there. For that client, it doesn't. |
| 96 | + |
| 97 | +Methods are **strictly additive**. The SDK enforces this at construction, not at |
| 98 | +runtime: |
| 99 | + |
| 100 | +* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...) |
| 101 | + raises `ValueError` when the binding is constructed. Core verbs belong to the server. |
| 102 | +* Two extensions binding the same method raise when the second one registers. |
| 103 | + Last-write-wins is how plugins corrupt each other; we don't do that. |
| 104 | +* An empty `protocol_versions` set raises too: a method that can never be served |
| 105 | + is a bug, not a configuration. |
| 106 | + |
| 107 | +### The client side |
| 108 | + |
| 109 | +The same file's `main()` is the whole client story, both halves of it: |
| 110 | + |
| 111 | +```python title="server.py" hl_lines="53-57" |
| 112 | +--8<-- "docs_src/extensions/tutorial004.py" |
| 113 | +``` |
| 114 | + |
| 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 |
| 120 | + `ctx.session.check_client_capability(...)` read the right source on both paths. |
| 121 | +* 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. |
| 124 | + |
| 125 | +### Intercepting `tools/call` |
| 126 | + |
| 127 | +The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit, |
| 128 | +or veto a tool call: |
| 129 | + |
| 130 | +```python title="server.py" hl_lines="18-25" |
| 131 | +--8<-- "docs_src/extensions/tutorial005.py" |
| 132 | +``` |
| 133 | + |
| 134 | +* `params` is the validated `CallToolRequestParams`: you get `params.name` and |
| 135 | + `params.arguments` without touching raw JSON. |
| 136 | +* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe), |
| 137 | + return something else (replace), or raise an `MCPError` (refuse). |
| 138 | +* With several extensions, interceptors nest in registration order: the first |
| 139 | + extension in `extensions=[...]` is outermost. |
| 140 | +* The default implementation is a pass-through, and a server whose extensions never |
| 141 | + override this hook installs **no** middleware at all. You don't pay for what |
| 142 | + you don't use. |
| 143 | + |
| 144 | +The hook wraps `tools/call` and nothing else. For every-message concerns, use |
| 145 | +[Middleware](middleware.md). That is what it is for. |
| 146 | + |
| 147 | +## What an extension cannot do |
| 148 | + |
| 149 | +The contribution surface is **closed** on purpose: settings, tools, resources, |
| 150 | +methods, one `tools/call` interceptor. An extension cannot: |
| 151 | + |
| 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. |
| 156 | + |
| 157 | +If you are fighting these walls, you are not writing an extension. You are writing |
| 158 | +a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]` |
| 159 | +knows *everything* those two can have touched. |
0 commit comments