Skip to content

Commit 2c7fc6e

Browse files
committed
Surface the SEP-2133 extensions capability map
Thread an `extensions` argument through the low-level `Server.get_capabilities` and `create_initialization_options` (mirroring `experimental`), backed by a `Server.extensions` attribute so the streamable-HTTP `server/discover` path advertises it too. Add an `extensions` branch to `Connection.check_capability` (presence-of-identifier, since settings are negotiated per-extension) and let a client advertise its own support via `Client(extensions=...)` / `ClientSession(extensions=...)`, mirrored into `ClientCapabilities.extensions`.
1 parent 067f905 commit 2c7fc6e

11 files changed

Lines changed: 225 additions & 24 deletions

File tree

docs/migration.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,38 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
407407

408408
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
409409

410+
### Server extensions API (SEP-2133)
411+
412+
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
413+
reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
414+
(the 2026-07-28 capability map). An extension subclasses `mcp.server.mcpserver.Extension`
415+
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
416+
(additive) and `intercept_tool_call()` (wraps `tools/call`). Pass instances at
417+
construction, or register later with `add_extension`:
418+
419+
```python
420+
from mcp.server.mcpserver import MCPServer
421+
from mcp.server.apps import Apps
422+
from mcp.server.tasks import Tasks
423+
424+
mcp = MCPServer("demo", extensions=[Apps(), Tasks()])
425+
# or: mcp.add_extension(Apps())
426+
```
427+
428+
Two reference extensions ship in their own modules:
429+
430+
- `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`) — binds a tool to a
431+
`ui://` UI resource via `_meta.ui.resourceUri`; `client_supports_apps(ctx)`
432+
gates the SEP-2133 text-only fallback.
433+
- `mcp.server.tasks.Tasks` (`io.modelcontextprotocol/tasks`) — intercepts
434+
task-augmented `tools/call` and serves the `tasks/*` methods.
435+
436+
Clients advertise extension support with the new `Client(extensions=...)` /
437+
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
438+
The extensions capability map is negotiated over `server/discover` (modern path);
439+
a legacy `initialize` handshake does not carry it. Extensions are off by default
440+
and never alter behaviour unless registered.
441+
410442
### `McpError` renamed to `MCPError`
411443

412444
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.

examples/stories/apps/README.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
11
# apps
22

3-
MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource
4-
that the host renders as an interactive surface. The story will register a
5-
`@ui` resource and return it from a tool.
3+
MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://`
4+
resource that the host renders as an interactive surface. The server opts in via
5+
the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by
6+
advertising the `text/html;profile=mcp-app` MIME type.
67

7-
**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
8-
The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
9-
cannot advertise Apps support and a client cannot negotiate it.
8+
## Run it
9+
10+
```bash
11+
# stdio (default — the client spawns the server as a subprocess)
12+
uv run python -m stories.apps.client
13+
14+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
15+
uv run python -m stories.apps.client --http
16+
```
17+
18+
## What to look at
19+
20+
- `server.py` `Apps()` + `mcp.add_extension(apps)` — the extension advertises
21+
`io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` and
22+
contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
23+
never learns about "ui"; it applies a closed set of contributions.
24+
- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
25+
the tool; `add_html_resource` registers the matching `ui://` resource at
26+
`text/html;profile=mcp-app`.
27+
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
28+
client that did not negotiate Apps gets a text-only result.
29+
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
30+
support so the server returns the UI-enabled result, then reads the tool's
31+
`_meta.ui.resourceUri` and fetches that resource.
1032

1133
## Spec
1234

1335
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
1436
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
37+
38+
## See also
39+
40+
`tasks/` (the interceptive half of the extension API),
41+
`custom_methods/` (registering a non-spec method without an extension).

examples/stories/manifest.toml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ status = "deprecated"
4848
[story.custom_methods]
4949
lowlevel = false
5050

51+
[story.apps]
52+
# Extension API is MCPServer-tier (Apps decorators + add_extension); no lowlevel variant.
53+
# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
54+
# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
55+
lowlevel = false
56+
transports = ["in-memory", "http-asgi"]
57+
era = "dual-in-body"
58+
59+
[story.tasks]
60+
# Interceptive extension; the tasks/* methods drop to client.session like custom_methods.
61+
# extensions ride server/discover (modern-only), so the connection is pinned to "auto".
62+
lowlevel = false
63+
transports = ["in-memory", "http-asgi"]
64+
era = "dual-in-body"
65+
5166
[story.schema_validators]
5267

5368
[story.middleware]
@@ -142,7 +157,5 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
142157
[deferred]
143158
caching = "client honouring + per-result override unlanded"
144159
subscriptions = "#2901 — Client.listen / ServerEventBus"
145-
tasks = "extensions capability map + tasks runtime"
146-
apps = "#2896 — extensions capability map"
147160
skills = "#2896 — SEP-2640"
148161
events = "#2901 + #2896"

examples/stories/tasks/README.md

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
11
# tasks
22

3-
The `io.modelcontextprotocol/tasks` extension: long-running work registered
4-
with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with
5-
`tasks/cancel`. The story will show a task that outlives the request that
6-
started it.
3+
Task-augmented tool execution. A client sends `tools/call` with a `task` field;
4+
the server records the call under a task id and the client polls `tasks/get` /
5+
`tasks/result`. This is the *interceptive* half of the extension API — the
6+
`Tasks` extension (`io.modelcontextprotocol/tasks`) wraps `tools/call` rather
7+
than only adding tools.
78

8-
**Status: not yet implemented.** The extension types exist but the `extensions`
9-
capability map is not yet surfaced on `MCPServer`, and the runtime trails the
10-
release. The TypeScript SDK deliberately removed its tasks example pending the
11-
same work.
9+
## Run it
10+
11+
```bash
12+
# stdio (default — the client spawns the server as a subprocess)
13+
uv run python -m stories.tasks.client
14+
15+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
16+
uv run python -m stories.tasks.client --http
17+
```
18+
19+
## What to look at
20+
21+
- `server.py` `MCPServer(extensions=[Tasks()])` — opt in at construction. The
22+
extension advertises `io.modelcontextprotocol/tasks` and serves `tasks/get`,
23+
`tasks/result`, `tasks/cancel`, and `tasks/list`.
24+
- `mcp.server.tasks.Tasks.intercept_tool_call` — the interceptive seam: a plain
25+
call passes through; a call with a `task` field is recorded and returned with
26+
the task id in `_meta["io.modelcontextprotocol/related-task"]`.
27+
- `client.py` — sends a task-augmented `tools/call` via `client.session` (the
28+
`task` field and `tasks/*` methods are outside the spec verbs `Client`
29+
exposes), then drives the lifecycle through `tasks/get` and `tasks/result`.
30+
31+
## Caveats
32+
33+
This is a reference implementation for the extension API, not a production task
34+
runtime. The tool runs to completion inline (so a task is observed as
35+
`completed` immediately), and the augmented call returns a normal
36+
`CallToolResult` with the task id in `_meta` rather than the spec's
37+
`CreateTaskResult` — the `tools/call` result schema admits only
38+
`CallToolResult | InputRequiredResult` (see `TODO(L56)` in `mcp.server.runner`),
39+
so returning `CreateTaskResult` would require extending the methods-layer
40+
validation maps. The lifecycle runs through the dedicated `tasks/*` methods instead.
1241

1342
## Spec
1443

15-
[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)
44+
[Tasks — extensions](https://modelcontextprotocol.io/specification/draft/extensions)
1645
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
46+
47+
## See also
48+
49+
`apps/` (the additive half of the extension API),
50+
`custom_methods/` (a non-spec method without an extension),
51+
`middleware/` (the low-level `tools/call` wrapping the interceptor builds on).

src/mcp/client/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ async def main():
217217
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
218218
to drive the loop manually instead."""
219219

220+
extensions: dict[str, dict[str, Any]] | None = None
221+
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
222+
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
223+
220224
_entered: bool = field(init=False, default=False)
221225
_session: ClientSession | None = field(init=False, default=None)
222226
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
@@ -255,6 +259,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
255259
message_handler=self.message_handler,
256260
client_info=self.client_info,
257261
elicitation_callback=self.elicitation_callback,
262+
extensions=self.extensions,
258263
)
259264

260265
async def __aenter__(self) -> Client:

src/mcp/client/session.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,14 @@ def __init__(
224224
client_info: types.Implementation | None = None,
225225
*,
226226
sampling_capabilities: types.SamplingCapability | None = None,
227+
extensions: dict[str, dict[str, Any]] | None = None,
227228
dispatcher: Dispatcher[Any] | None = None,
228229
) -> None:
229230
self._session_read_timeout_seconds = read_timeout_seconds
230231
self._client_info = client_info or DEFAULT_CLIENT_INFO
231232
self._sampling_callback = sampling_callback or _default_sampling_callback
232233
self._sampling_capabilities = sampling_capabilities
234+
self._extensions = extensions
233235
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
234236
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
235237
self._logging_callback = logging_callback or _default_logging_callback
@@ -369,7 +371,9 @@ def _build_capabilities(self) -> types.ClientCapabilities:
369371
if self._list_roots_callback is not _default_list_roots_callback
370372
else None
371373
)
372-
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
374+
return types.ClientCapabilities(
375+
sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots
376+
)
373377

374378
async def initialize(self) -> types.InitializeResult:
375379
if self._initialize_result is not None:

src/mcp/server/connection.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,14 @@ def check_capability(self, capability: ClientCapabilities) -> bool:
345345
for k, v in capability.experimental.items():
346346
if k not in have.experimental or have.experimental[k] != v:
347347
return False
348+
if capability.extensions is not None:
349+
# SEP-2133: an extension is supported when the client declares its
350+
# identifier. Settings are negotiated per-extension (the client may
351+
# advertise more than the server asks for), so presence - not value
352+
# equality - is the meaningful check.
353+
if have.extensions is None:
354+
return False
355+
for identifier in capability.extensions:
356+
if identifier not in have.extensions:
357+
return False
348358
return True

src/mcp/server/lowlevel/server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,10 @@ def __init__(
434434
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
435435
# v2 final.
436436
self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]
437+
# SEP-2133 extension settings advertised under `ServerCapabilities.extensions`
438+
# (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`)
439+
# populate it; `get_capabilities` reads it when no explicit map is passed.
440+
self.extensions: dict[str, dict[str, Any]] = {}
437441
logger.debug("Initializing server %r", name)
438442

439443
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
@@ -521,8 +525,15 @@ def create_initialization_options(
521525
self,
522526
notification_options: NotificationOptions | None = None,
523527
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
528+
extensions: dict[str, dict[str, Any]] | None = None,
524529
) -> InitializationOptions:
525-
"""Create initialization options from this server instance."""
530+
"""Create initialization options from this server instance.
531+
532+
`extensions` advertises SEP-2133 extension support under
533+
`ServerCapabilities.extensions`; keys are extension identifiers (e.g.
534+
`io.modelcontextprotocol/ui`), values are per-extension settings.
535+
Defaults to `self.extensions`, which higher layers populate.
536+
"""
526537
return InitializationOptions(
527538
server_name=self.name,
528539
server_version=self.version if self.version else _package_version("mcp"),
@@ -531,6 +542,7 @@ def create_initialization_options(
531542
capabilities=self.get_capabilities(
532543
notification_options or NotificationOptions(),
533544
experimental_capabilities or {},
545+
extensions if extensions is not None else self.extensions,
534546
),
535547
instructions=self.instructions,
536548
website_url=self.website_url,
@@ -541,8 +553,14 @@ def get_capabilities(
541553
self,
542554
notification_options: NotificationOptions | None = None,
543555
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
556+
extensions: dict[str, dict[str, Any]] | None = None,
544557
) -> types.ServerCapabilities:
545-
"""Convert existing handlers to a ServerCapabilities object."""
558+
"""Convert existing handlers to a ServerCapabilities object.
559+
560+
`extensions` is the SEP-2133 extension map (identifier -> settings)
561+
advertised under `ServerCapabilities.extensions`; it defaults to
562+
`self.extensions`.
563+
"""
546564
notification_options = notification_options or NotificationOptions()
547565
prompts_capability = None
548566
resources_capability = None
@@ -579,6 +597,7 @@ def get_capabilities(
579597
tools=tools_capability,
580598
logging=logging_capability,
581599
experimental=experimental_capabilities,
600+
extensions=extensions if extensions is not None else (self.extensions or None),
582601
completions=completions_capability,
583602
)
584603
return capabilities

src/mcp/server/mcpserver/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@
33
from mcp_types import Icon
44

55
from .context import Context
6+
from .extension import Extension, MethodBinding, ResourceBinding, ToolBinding
67
from .server import MCPServer
78
from .utilities.types import Audio, Image
89

9-
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
10+
__all__ = [
11+
"MCPServer",
12+
"Context",
13+
"Image",
14+
"Audio",
15+
"Icon",
16+
"Extension",
17+
"ToolBinding",
18+
"ResourceBinding",
19+
"MethodBinding",
20+
]

src/mcp/server/mcpserver/resources/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class TextResource(Resource):
2626

2727
async def read(self) -> str:
2828
"""Read the text content."""
29-
return self.text # pragma: no cover
29+
return self.text
3030

3131

3232
class BinaryResource(Resource):

0 commit comments

Comments
 (0)