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
45 changes: 45 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,51 @@ 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
construction:

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

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

Two reference extensions ship in their own modules:

- `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`) 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).
- `mcp.server.tasks.Tasks` (`io.modelcontextprotocol/tasks`, SEP-2663) defers a
`tools/call` as a task: for a client that declared the extension on a modern
connection, the server may return a `CreateTaskResult` (`resultType: "task"`)
instead of the `CallToolResult`, and the client polls `tasks/get` /
`tasks/cancel`. The server decides augmentation (the legacy `params.task` field
is ignored); a `tasks/*` call from a non-declaring client is rejected with
`-32003`. This is the conformant core; `tasks/update` + the MRTR input loop,
`ToolExecution.taskSupport` gating, `notifications/tasks`, and task routing
headers are deferred.

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
39 changes: 33 additions & 6 deletions examples/stories/apps/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
# 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

`tasks/` (the interceptive half of the extension API),
`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)
17 changes: 15 additions & 2 deletions examples/stories/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ 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.tasks]
# SEP-2663 tasks extension; server-decided augmentation + tasks/get drop to client.session.
# extensions ride server/discover (modern-only), so the connection is pinned to "auto".
lowlevel = false
transports = ["in-memory", "http-asgi"]
era = "dual-in-body"

[story.schema_validators]

[story.middleware]
Expand Down Expand Up @@ -142,7 +157,5 @@ 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"
skills = "#2896 — SEP-2640"
events = "#2901 + #2896"
50 changes: 41 additions & 9 deletions examples/stories/tasks/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
# 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 (SEP-2663). A client declares the

@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: Docs are inconsistent: the Tasks story is documented as implemented/runnable here, but the stories index still labels tasks/ as "not yet implemented".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/stories/tasks/README.md, line 3:

<comment>Docs are inconsistent: the Tasks story is documented as implemented/runnable here, but the stories index still labels `tasks/` as "not yet implemented".</comment>

<file context>
@@ -1,24 +1,48 @@
-`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs`
-fields — so it lands in a separate PR with the conformance `tasks-*` scenarios
-wired in.
+Task-augmented execution (SEP-2663). A client declares the
+`io.modelcontextprotocol/tasks` extension; the server may then answer a
+`tools/call` with a `CreateTaskResult` (carrying a task id) instead of blocking,
</file context>
Fix with cubic

`io.modelcontextprotocol/tasks` extension; the server may then answer a
`tools/call` with a `CreateTaskResult` (carrying a task id) instead of blocking,
and the client polls `tasks/get` for status and the eventual 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.
## Run it

```bash
# stdio (default — the client spawns the server as a subprocess)

@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.

P2: README run instructions imply stdio demonstrates tasks flow, but stdio cannot negotiate the tasks extension. Users running the default command will not see the documented task behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/stories/tasks/README.md, line 11:

<comment>README run instructions imply stdio demonstrates tasks flow, but stdio cannot negotiate the tasks extension. Users running the default command will not see the documented task behavior.</comment>

<file context>
@@ -1,24 +1,48 @@
+## Run it
+
+```bash
+# stdio (default — the client spawns the server as a subprocess)
+uv run python -m stories.tasks.client
+
</file context>
Suggested change
# stdio (default — the client spawns the server as a subprocess)
# stdio (legacy handshake only; cannot negotiate `io.modelcontextprotocol/tasks` yet)
Fix with cubic

uv run python -m stories.tasks.client

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

## What to look at

- `server.py` `MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=...)])` —
opt in at construction. The extension advertises `io.modelcontextprotocol/tasks`
and serves `tasks/get` and `tasks/cancel`.
- `mcp.server.tasks.Tasks.intercept_tool_call` — the server DECIDES augmentation;
the legacy `params.task` field is ignored. It augments only for a client that
declared the extension on the request, returning a flat `CreateTaskResult`
(`resultType: "task"`).
- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — declaring the
extension is what lets the server defer; `main` then reads the `CreateTaskResult`
and polls `tasks/get`, whose completed `DetailedTask` inlines the original
`CallToolResult`.

## Scope

This is the SEP-2663 conformant *core*. The tool runs to completion inline (so a
task is observed as `completed` immediately), and the store is in-memory. Deferred
to follow-ups, each needing deeper SDK plumbing: `tasks/update` + the MRTR
`input_required` loop, `ToolExecution.taskSupport` gating with the `-32021`
required-task error, `notifications/tasks`, and SEP-2243 task routing headers.

## Spec

[Tasksbasic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)
[SEP-2663Tasks extension](https://modelcontextprotocol.io/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),
`custom_methods/` (a non-spec method without an extension).
Empty file.
54 changes: 54 additions & 0 deletions examples/stories/tasks/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Declare the tasks extension, let the server defer a tool call, then poll tasks/get.

The client declares `io.modelcontextprotocol/tasks` (via `Client(extensions=...)`),
so the server is free to answer `tools/call` with a `CreateTaskResult`. `Client`
exposes only spec verbs, so the augmented call and `tasks/get` drop to
`client.session`; the thin `_send` helper keeps that out of the story below.
"""

from typing import Any, Literal, cast

import mcp_types as types
from pydantic import TypeAdapter

from mcp.client import Client, ClientSession
from mcp.server.tasks import EXTENSION_ID, GetTaskRequestParams
from stories._harness import Target, run_client

_RAW: TypeAdapter[dict[str, Any]] = TypeAdapter(dict)


class _GetTaskRequest(types.Request[GetTaskRequestParams, Literal["tasks/get"]]):
method: Literal["tasks/get"] = "tasks/get"
params: GetTaskRequestParams


async def _send(session: ClientSession, request: types.Request[Any, Any]) -> dict[str, Any]:
"""Send a request whose result has a non-spec (extension) shape; return the raw dict."""
return await session.send_request(cast("types.ClientRequest", request), cast("Any", _RAW))


async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client:
# The extension is a modern-only capability negotiated over server/discover.
# A legacy connection (today's stdio) cannot carry it, and the server then
# must not augment, so the task flow only runs once it is negotiated.
if client.server_capabilities.extensions is None:
return
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}

# The server augments this tools/call into a task because we declared the extension.
call = types.CallToolRequest(
params=types.CallToolRequestParams(name="render_report", arguments={"title": "Q3", "sections": 2})
)
created = await _send(client.session, call)
assert created["resultType"] == "task", created
task_id = created["taskId"]

task = await _send(client.session, _GetTaskRequest(params=GetTaskRequestParams(task_id=task_id)))
assert task["status"] == "completed", task
assert task["result"]["content"][0]["text"].startswith("# Q3"), task


if __name__ == "__main__":
run_client(main)
27 changes: 27 additions & 0 deletions examples/stories/tasks/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Tasks (SEP-2663): the server defers a tool call as a task the client polls.

`Tasks` is an opt-in `Extension`. The server decides, per request, to return a
`CreateTaskResult` instead of a `CallToolResult` for a client that declared the
`io.modelcontextprotocol/tasks` extension; the client then polls `tasks/get` for
status and the eventual result. `render_report` is the kind of slower, multi-step
tool a caller would rather run as a task than block on.
"""

from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks
from stories._hosting import run_server_from_args


def build_server() -> MCPServer:
mcp = MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=60_000)])

@mcp.tool(description="Render a multi-section report for the given title.", structured_output=False)
def render_report(title: str, sections: int) -> str:
body = "\n".join(f"## Section {n}\n(generated)" for n in range(1, sections + 1))
return f"# {title}\n\n{body}"

return mcp


if __name__ == "__main__":
run_server_from_args(build_server)
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