Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
fe9723853d
commit
4b519782f1
1 changed files with 2598 additions and 24 deletions
159
docs/advanced/extensions.md
Normal file
159
docs/advanced/extensions.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Extensions
|
||||
|
||||
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
|
||||
|
||||
It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
|
||||
The server advertises it under `capabilities.extensions`, the client opts in the same way,
|
||||
and nothing changes for anyone who didn't ask for it. That is the contract (SEP-2133), and
|
||||
it has one golden rule: **extensions are off by default**.
|
||||
|
||||
## Using an extension
|
||||
|
||||
Pass instances at construction:
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/extensions/tutorial001.py"
|
||||
```
|
||||
|
||||
Done. The server now advertises `io.modelcontextprotocol/ui` under
|
||||
`capabilities.extensions` and serves everything the extension contributes.
|
||||
|
||||
`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
|
||||
|
||||
!!! note
|
||||
Extensions are fixed at construction. There is no `add_extension` to call later:
|
||||
a server's capability map should not change while clients are connected to it.
|
||||
|
||||
The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
|
||||
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
|
||||
the extension. Design for that: an extension *augments* a server, it must not be the
|
||||
only way the server is usable.
|
||||
|
||||
## Writing your own
|
||||
|
||||
Subclass `Extension` and override only what you need. Every method has a default.
|
||||
|
||||
### The identifier
|
||||
|
||||
```python
|
||||
--8<-- "docs_src/extensions/tutorial002.py"
|
||||
```
|
||||
|
||||
The identifier is a `vendor-prefix/name` string following the spec's `_meta` key
|
||||
grammar: dot-separated labels (each starts with a letter, ends with a letter or
|
||||
digit), a slash, then the name. It is validated **when the class is defined**, so a
|
||||
typo doesn't wait for a server to boot:
|
||||
|
||||
```text
|
||||
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
|
||||
(reverse-DNS prefix required), got 'stamps'
|
||||
```
|
||||
|
||||
Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions
|
||||
specified by the MCP project itself.
|
||||
|
||||
### Contributing tools
|
||||
|
||||
The smallest useful extension is one tool and a settings map:
|
||||
|
||||
```python title="server.py" hl_lines="17 19-20 22-23 26"
|
||||
--8<-- "docs_src/extensions/tutorial003.py"
|
||||
```
|
||||
|
||||
* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you
|
||||
had called `mcp.add_tool(...)` yourself: same schema generation, same `Context`
|
||||
injection, same everything.
|
||||
* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`.
|
||||
Return `{}` (the default) to advertise the extension with no settings.
|
||||
* The extension never receives the server. It declares contributions as data;
|
||||
`MCPServer` consumes them. There is no `self.server` to mutate.
|
||||
|
||||
And `main()` is the proof, an in-memory client straight against `mcp`:
|
||||
|
||||
```python title="server.py" hl_lines="29-34"
|
||||
--8<-- "docs_src/extensions/tutorial003.py"
|
||||
```
|
||||
|
||||
### Serving your own methods
|
||||
|
||||
An extension can register **new request methods**: its own verbs, served next to the
|
||||
spec's:
|
||||
|
||||
```python title="server.py" hl_lines="15-21 30 39-47"
|
||||
--8<-- "docs_src/extensions/tutorial004.py"
|
||||
```
|
||||
|
||||
* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses
|
||||
uniformly and your handler gets validated params, never a raw dict. Bound what
|
||||
the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before
|
||||
your code allocates anything for it.
|
||||
* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not
|
||||
declare the extension gets the `-32021` (missing required client capability) error,
|
||||
with the machine-readable `requiredCapabilities` payload the spec asks for.
|
||||
* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version.
|
||||
At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method
|
||||
didn't exist there. For that client, it doesn't.
|
||||
|
||||
Methods are **strictly additive**. The SDK enforces this at construction, not at
|
||||
runtime:
|
||||
|
||||
* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...)
|
||||
raises `ValueError` when the binding is constructed. Core verbs belong to the server.
|
||||
* Two extensions binding the same method raise when the second one registers.
|
||||
Last-write-wins is how plugins corrupt each other; we don't do that.
|
||||
* An empty `protocol_versions` set raises too: a method that can never be served
|
||||
is a bug, not a configuration.
|
||||
|
||||
### The client side
|
||||
|
||||
The same file's `main()` is the whole client story, both halves of it:
|
||||
|
||||
```python title="server.py" hl_lines="53-57"
|
||||
--8<-- "docs_src/extensions/tutorial004.py"
|
||||
```
|
||||
|
||||
* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
|
||||
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in
|
||||
the per-request `_meta` envelope, so the server sees it on **every** request; on
|
||||
a legacy connection it rides the `initialize` handshake. Server code doesn't care
|
||||
which: `require_client_extension(ctx, ...)` and
|
||||
`ctx.session.check_client_capability(...)` read the right source on both paths.
|
||||
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
|
||||
only grows first-class methods for spec verbs. The `cast` is there because
|
||||
`send_request` is typed against the spec's closed request union.
|
||||
|
||||
### Intercepting `tools/call`
|
||||
|
||||
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
|
||||
or veto a tool call:
|
||||
|
||||
```python title="server.py" hl_lines="18-25"
|
||||
--8<-- "docs_src/extensions/tutorial005.py"
|
||||
```
|
||||
|
||||
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
|
||||
`params.arguments` without touching raw JSON.
|
||||
* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe),
|
||||
return something else (replace), or raise an `MCPError` (refuse).
|
||||
* With several extensions, interceptors nest in registration order: the first
|
||||
extension in `extensions=[...]` is outermost.
|
||||
* The default implementation is a pass-through, and a server whose extensions never
|
||||
override this hook installs **no** middleware at all. You don't pay for what
|
||||
you don't use.
|
||||
|
||||
The hook wraps `tools/call` and nothing else. For every-message concerns, use
|
||||
[Middleware](middleware.md). That is what it is for.
|
||||
|
||||
## What an extension cannot do
|
||||
|
||||
The contribution surface is **closed** on purpose: settings, tools, resources,
|
||||
methods, one `tools/call` interceptor. An extension cannot:
|
||||
|
||||
* **Reach into the server.** It declares data; it holds no server reference.
|
||||
* **Replace core behaviour.** Spec methods are rejected at construction, and
|
||||
`initialize` is reserved by the runner outright.
|
||||
* **Register late.** After `MCPServer(...)` returns, the extension set is what it is.
|
||||
|
||||
If you are fighting these walls, you are not writing an extension. You are writing
|
||||
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`
|
||||
knows *everything* those two can have touched.
|
||||
Loading…
Reference in a new issue