Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
31 changes: 31 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,37 @@ 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.mcpserver.Extension`
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
(additive) and `intercept_tool_call()` (wraps `tools/call`). Pass instances at
construction:

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

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

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`; `client_supports_apps(ctx)`
gates the SEP-2133 text-only fallback.
- `mcp.server.tasks.Tasks` (`io.modelcontextprotocol/tasks`) — intercepts
task-augmented `tools/call` and serves the `tasks/*` methods.

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]
# Interceptive extension; the tasks/* methods drop to client.session like custom_methods.
# 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"
63 changes: 54 additions & 9 deletions examples/stories/tasks/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
# 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 tool execution. A client sends `tools/call` with a `task` field;
the server records the call under a task id and the client polls `tasks/get` /
`tasks/result`. This is the *interceptive* half of the extension API — the
`Tasks` extension (`io.modelcontextprotocol/tasks`) wraps `tools/call` rather
than only adding tools.

**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)
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()])` — opt in at
construction. The extension advertises `io.modelcontextprotocol/tasks` and
serves `tasks/get`, `tasks/result`, `tasks/cancel`, and `tasks/list`. The
`render_report` tool is the kind of slower, multi-step work a caller would
rather run as a task than block on.
- `mcp.server.tasks.Tasks.intercept_tool_call` — the interceptive seam: a plain
call passes through; a call with a `task` field is recorded and returned with
the task id in `_meta["io.modelcontextprotocol/related-task"]`.
- `client.py` `main` — start the call as a task, read its `tasks/get` status,
then fetch the payload with `tasks/result`. The `task` field and `tasks/*`
methods are outside the spec verbs `Client` exposes, so the thin
`_start_task` / `_get_task` / `_task_result` helpers wrap `client.session`.

## Caveats

This is a reference implementation for the extension API, not a production task
runtime. A plain `tools/call` (no `task` field) is unchanged — only a call the
client explicitly augments with a `task` field becomes a task. Three deliberate
simplifications:

- The tool runs to completion inline, so a task is observed as `completed`
immediately (no detached/background execution, no TTL eviction).
- The augmented call returns a normal `CallToolResult` with the task id in
`_meta` rather than the spec's `CreateTaskResult` — the `tools/call` result
schema admits only `CallToolResult | InputRequiredResult` (see `TODO(L56)` in
`mcp.server.runner`), so returning `CreateTaskResult` would require extending
the methods-layer validation maps. The lifecycle runs through the dedicated
`tasks/*` methods instead.
- Any tool may be task-augmented on request; per-tool gating on the declared
`ToolExecution.task_support` (`forbidden`/`optional`/`required`) is not enforced.

## Spec

[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)
[Tasks — extensions](https://modelcontextprotocol.io/specification/draft/extensions)
· [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),
`middleware/` (the low-level `tools/call` wrapping the interceptor builds on).
Empty file.
56 changes: 56 additions & 0 deletions examples/stories/tasks/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Start a tool call as a task, then poll the task to completion and fetch its result.

`Client` exposes only spec verbs, so the `task` augmentation and the `tasks/*`
methods drop to `client.session`. The thin `_start_task` / `_get_task` /
`_task_result` helpers keep that `cast` noise out of the story below; `main`
itself reads as: kick off the work, see it as a task, collect the report.
"""

from typing import cast

import mcp_types as types

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


async def _start_task(session: ClientSession, name: str, arguments: dict[str, object]) -> types.CallToolResult:
"""Call a tool with task augmentation; the result carries the task id in `_meta`."""
request = types.CallToolRequest(
params=types.CallToolRequestParams(name=name, arguments=arguments, task=types.TaskMetadata(ttl=60))
)
return await session.send_request(cast("types.ClientRequest", request), types.CallToolResult)


async def _get_task(session: ClientSession, task_id: str) -> types.GetTaskResult:
request = types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id))
return await session.send_request(cast("types.ClientRequest", request), types.GetTaskResult)


async def _task_result(session: ClientSession, task_id: str) -> types.CallToolResult:
request = types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id))
return await session.send_request(cast("types.ClientRequest", request), types.CallToolResult)


async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
# The extensions capability map rides `server/discover` (modern only); a legacy
# connection (today's stdio) omits it, so assert it only when present.
if client.server_capabilities.extensions is not None:
assert client.server_capabilities.extensions == {EXTENSION_ID: {"list": {}, "cancel": {}}}

started = await _start_task(client.session, "render_report", {"title": "Q3", "sections": 2})
task_id = started.meta[RELATED_TASK_META_KEY]["taskId"] if started.meta else None
assert task_id is not None, started

task = await _get_task(client.session, task_id)
assert task.status == "completed", task

report = await _task_result(client.session, task_id)
assert isinstance(report.content[0], types.TextContent)
assert report.content[0].text.startswith("# Q3"), report


if __name__ == "__main__":
run_client(main)
28 changes: 28 additions & 0 deletions examples/stories/tasks/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tasks: task-augmented tool execution via the interceptive half of the extension API.

`Tasks` is an opt-in `Extension`. It intercepts `tools/call`: a plain call runs
inline and returns its `CallToolResult`, but a call carrying a `task` field is
recorded under a task id and returned with that id in
`_meta["io.modelcontextprotocol/related-task"]`, so the client can poll
`tasks/get` / `tasks/result` instead of blocking. `render_report` is the kind of
slower, multi-step tool a caller would rather run as a task.
"""

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()])

@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