Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,40 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th

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.

### Server extensions API (SEP-2133)

`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension`
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
`vendor-prefix/name` string, enforced when the subclass is defined. Pass instances at

@cubic-dev-ai cubic-dev-ai Bot Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Migration docs overstate when extension identifier validation runs. Instance-assigned identifiers are validated at extension registration, not only at subclass definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 417:

<comment>Migration docs overstate when extension identifier validation runs. Instance-assigned identifiers are validated at extension registration, not only at subclass definition.</comment>

<file context>
@@ -411,9 +411,10 @@ For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may
 and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
-(additive) and `intercept_tool_call()` (wraps `tools/call`). Pass instances at
+(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
+`vendor-prefix/name` string, enforced when the subclass is defined. Pass instances at
 construction:
 
</file context>
Suggested change
`vendor-prefix/name` string, enforced when the subclass is defined. Pass instances at
`vendor-prefix/name` string. Class-level identifiers are validated when the subclass is defined; instance-assigned identifiers are validated when the extension is registered. Pass instances at
Fix with cubic

construction:

```python
from mcp.server.mcpserver import MCPServer
from mcp.server.apps import Apps

mcp = MCPServer("demo", extensions=[Apps()])
```

The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the
client advertised the `text/html;profile=mcp-app` MIME type).

@cubic-dev-ai cubic-dev-ai Bot Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Apps fallback docs describe MIME-type gating too narrowly. Current behavior also treats missing mimeTypes as supported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 430:

<comment>Apps fallback docs describe MIME-type gating too narrowly. Current behavior also treats missing `mimeTypes` as supported.</comment>

<file context>
@@ -425,7 +426,14 @@ mcp = MCPServer("demo", extensions=[Apps()])
 it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
-`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback.
+`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the
+client advertised the `text/html;profile=mcp-app` MIME type).
+
+A `MethodBinding` may set `protocol_versions` to scope an extension method to
</file context>
Suggested change
client advertised the `text/html;profile=mcp-app` MIME type).
client declared the Apps extension and either omitted `mimeTypes` or included `text/html;profile=mcp-app`).
Fix with cubic


A `MethodBinding` may set `protocol_versions` to scope an extension method to
specific wire versions; a request at any other version is `METHOD_NOT_FOUND`. An
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
to reject a request with the `-32021` (missing required client capability) error
when the client did not declare the extension.

Clients advertise extension support with the new `Client(extensions=...)` /
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
The extensions capability map is negotiated over `server/discover` (modern path);
a legacy `initialize` handshake does not carry it. Extensions are off by default
and never alter behaviour unless registered.

### `McpError` renamed to `MCPError`

The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
Expand Down
38 changes: 32 additions & 6 deletions examples/stories/apps/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
# apps

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

**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
cannot advertise Apps support and a client cannot negotiate it.
## Run it

```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.apps.client

# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.apps.client --http
```

## What to look at

- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension
advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions`
and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
never learns about "ui"; it applies a closed set of contributions.
- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
the tool; `add_html_resource` registers the matching `ui://` resource at
`text/html;profile=mcp-app`.
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
client that did not negotiate Apps gets a text-only result.
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
support so the server returns the UI-enabled result, then reads the tool's
`_meta.ui.resourceUri` and fetches that resource.

## Spec

[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)

## See also

`custom_methods/` (registering a non-spec method without an extension).
Empty file.
35 changes: 35 additions & 0 deletions examples/stories/apps/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""

from mcp_types import TextContent, TextResourceContents

from mcp.client import Client
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
from stories._harness import Target, run_client


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

listed = await client.list_tools()
tool = next(t for t in listed.tools if t.name == "get_time")
assert tool.meta is not None, tool
assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta

ui = await client.read_resource("ui://get-time/app.html")
contents = ui.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.mime_type == APP_MIME_TYPE, contents.mime_type

result = await client.call_tool("get_time", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text


if __name__ == "__main__":
run_client(main)
43 changes: 43 additions & 0 deletions examples/stories/apps/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface.

`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The
`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the
tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool
degrades gracefully: `client_supports_apps(ctx)` reports whether the client
negotiated Apps, so it returns text-only output otherwise.
"""

from mcp.server.apps import Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
from stories._hosting import run_server_from_args

RESOURCE_URI = "ui://get-time/app.html"
CLOCK_HTML = """<!doctype html>
<title>Current time</title>
<h1 id="now">…</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""


def build_server() -> MCPServer:
apps = Apps()

@apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T00:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now

apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
return MCPServer("apps-example", extensions=[apps])


if __name__ == "__main__":
run_server_from_args(build_server)
11 changes: 9 additions & 2 deletions examples/stories/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ status = "deprecated"
[story.custom_methods]
lowlevel = false

[story.apps]
# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant.
# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
lowlevel = false
transports = ["in-memory", "http-asgi"]
era = "dual-in-body"

[story.schema_validators]

[story.middleware]
Expand Down Expand Up @@ -142,7 +150,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
[deferred]
caching = "client honouring + per-result override unlanded"
subscriptions = "#2901 — Client.listen / ServerEventBus"
tasks = "extensions capability map + tasks runtime"
apps = "#2896 — extensions capability map"
tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
skills = "#2896 — SEP-2640"
events = "#2901 + #2896"
26 changes: 17 additions & 9 deletions examples/stories/tasks/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
# tasks

The `io.modelcontextprotocol/tasks` extension: long-running work registered
with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with
`tasks/cancel`. The story will show a task that outlives the request that
started it.
Task-augmented execution: a requestor augments a `tools/call` with a `task`, the
receiver returns a `CreateTaskResult` immediately, and the requestor polls
`tasks/get` and retrieves the deferred result.

**Status: not yet implemented.** The extension types exist but the `extensions`
capability map is not yet surfaced on `MCPServer`, and the runtime trails the
release. The TypeScript SDK deliberately removed its tasks example pending the
same work.
**Status: deferred.** Tasks ship in 2026-07-28 as
[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md),
an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the
2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime
needs to be built to the SEP — server-decided augmentation (ignoring the legacy
`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the
`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs`
fields — so it lands in a separate PR with the conformance `tasks-*` scenarios
wired in.

## Spec

[Tasksbasic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)
[SEP-2663Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)

## See also

`apps/` (the additive half of the extension API).
5 changes: 5 additions & 0 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ async def main():
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
to drive the loop manually instead."""

extensions: dict[str, dict[str, Any]] | None = None
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""

_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
Expand Down Expand Up @@ -255,6 +259,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
extensions=self.extensions,
)

async def __aenter__(self) -> Client:
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,14 @@ def __init__(
client_info: types.Implementation | None = None,
*,
sampling_capabilities: types.SamplingCapability | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
dispatcher: Dispatcher[Any] | None = None,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO
self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities
self._extensions = extensions
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
Expand Down Expand Up @@ -369,7 +371,9 @@ def _build_capabilities(self) -> types.ClientCapabilities:
if self._list_roots_callback is not _default_list_roots_callback
else None
)
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
return types.ClientCapabilities(
sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots
)

async def initialize(self) -> types.InitializeResult:
if self._initialize_result is not None:
Expand Down
Loading
Loading