From 2c7fc6eb33f8b0fc9ffc83fbc788f25e7d1e5de8 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:17:05 +0200 Subject: [PATCH 01/13] 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`. --- docs/migration.md | 32 +++++++++++++ examples/stories/apps/README.md | 39 ++++++++++++--- examples/stories/manifest.toml | 17 ++++++- examples/stories/tasks/README.md | 53 +++++++++++++++++---- src/mcp/client/client.py | 5 ++ src/mcp/client/session.py | 6 ++- src/mcp/server/connection.py | 10 ++++ src/mcp/server/lowlevel/server.py | 23 ++++++++- src/mcp/server/mcpserver/__init__.py | 13 ++++- src/mcp/server/mcpserver/resources/types.py | 2 +- src/mcp/server/mcpserver/server.py | 49 ++++++++++++++++++- 11 files changed, 225 insertions(+), 24 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index e987b626c6..1a19283a37 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -407,6 +407,38 @@ 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-` 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, or register later with `add_extension`: + +```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()]) +# or: mcp.add_extension(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`; `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. diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index b802525fa0..a4f429067d 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -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` `Apps()` + `mcp.add_extension(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). diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index fb688d2942..a0a6f2ac24 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -48,6 +48,21 @@ status = "deprecated" [story.custom_methods] lowlevel = false +[story.apps] +# Extension API is MCPServer-tier (Apps decorators + add_extension); 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] @@ -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" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index ef15ae63fc..67ee807183 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,16 +1,51 @@ # 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(extensions=[Tasks()])` — opt in at construction. The + extension advertises `io.modelcontextprotocol/tasks` and serves `tasks/get`, + `tasks/result`, `tasks/cancel`, and `tasks/list`. +- `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` — sends a task-augmented `tools/call` via `client.session` (the + `task` field and `tasks/*` methods are outside the spec verbs `Client` + exposes), then drives the lifecycle through `tasks/get` and `tasks/result`. + +## Caveats + +This is a reference implementation for the extension API, not a production task +runtime. The tool runs to completion inline (so a task is observed as +`completed` immediately), and 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. ## 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). diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d6a6e4caae..d3290f3080 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -217,6 +217,10 @@ async def main(): `read_resource` give up. Use `client.session.(..., 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) @@ -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: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index fa71d1330d..3cebb569ec 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -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 @@ -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: diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 76917f8967..4d9496fef1 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -345,4 +345,14 @@ def check_capability(self, capability: ClientCapabilities) -> bool: for k, v in capability.experimental.items(): if k not in have.experimental or have.experimental[k] != v: return False + if capability.extensions is not None: + # SEP-2133: an extension is supported when the client declares its + # identifier. Settings are negotiated per-extension (the client may + # advertise more than the server asks for), so presence - not value + # equality - is the meaningful check. + if have.extensions is None: + return False + for identifier in capability.extensions: + if identifier not in have.extensions: + return False return True diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index bbd2ff3318..76a66b3520 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -434,6 +434,10 @@ def __init__( # Context/middleware rework (covariant `Context[L]`, outbound seam) before # v2 final. self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()] + # SEP-2133 extension settings advertised under `ServerCapabilities.extensions` + # (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`) + # populate it; `get_capabilities` reads it when no explicit map is passed. + self.extensions: dict[str, dict[str, Any]] = {} logger.debug("Initializing server %r", name) _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ @@ -521,8 +525,15 @@ def create_initialization_options( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> InitializationOptions: - """Create initialization options from this server instance.""" + """Create initialization options from this server instance. + + `extensions` advertises SEP-2133 extension support under + `ServerCapabilities.extensions`; keys are extension identifiers (e.g. + `io.modelcontextprotocol/ui`), values are per-extension settings. + Defaults to `self.extensions`, which higher layers populate. + """ return InitializationOptions( server_name=self.name, server_version=self.version if self.version else _package_version("mcp"), @@ -531,6 +542,7 @@ def create_initialization_options( capabilities=self.get_capabilities( notification_options or NotificationOptions(), experimental_capabilities or {}, + extensions if extensions is not None else self.extensions, ), instructions=self.instructions, website_url=self.website_url, @@ -541,8 +553,14 @@ def get_capabilities( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> types.ServerCapabilities: - """Convert existing handlers to a ServerCapabilities object.""" + """Convert existing handlers to a ServerCapabilities object. + + `extensions` is the SEP-2133 extension map (identifier -> settings) + advertised under `ServerCapabilities.extensions`; it defaults to + `self.extensions`. + """ notification_options = notification_options or NotificationOptions() prompts_capability = None resources_capability = None @@ -579,6 +597,7 @@ def get_capabilities( tools=tools_capability, logging=logging_capability, experimental=experimental_capabilities, + extensions=extensions if extensions is not None else (self.extensions or None), completions=completions_capability, ) return capabilities diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index e36a7ae7d6..42e3b6ed96 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -3,7 +3,18 @@ from mcp_types import Icon from .context import Context +from .extension import Extension, MethodBinding, ResourceBinding, ToolBinding from .server import MCPServer from .utilities.types import Audio, Image -__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"] +__all__ = [ + "MCPServer", + "Context", + "Image", + "Audio", + "Icon", + "Extension", + "ToolBinding", + "ResourceBinding", + "MethodBinding", +] diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index a25213e7bf..e295e21e02 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -26,7 +26,7 @@ class TextResource(Resource): async def read(self) -> str: """Read the text content.""" - return self.text # pragma: no cover + return self.text class BinaryResource(Resource): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 855770eda7..f754268661 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -5,7 +5,7 @@ import base64 import inspect import re -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -55,12 +55,13 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.context import ServerRequestContext +from mcp.server.context import ServerMiddleware, ServerRequestContext from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError +from mcp.server.mcpserver.extension import Extension, compose_tool_call_interceptor from mcp.server.mcpserver.prompts import Prompt, PromptManager from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager from mcp.server.mcpserver.tools import Tool, ToolManager @@ -142,6 +143,7 @@ def __init__( *, tools: list[Tool] | None = None, resources: list[Resource] | None = None, + extensions: Sequence[Extension] | None = None, debug: bool = False, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", warn_on_duplicate_resources: bool = True, @@ -207,6 +209,11 @@ def __init__( # Configure logging configure_logging(self.settings.log_level) + self._extensions: list[Extension] = [] + self._extension_interceptor: ServerMiddleware[LifespanResultT] | None = None + for extension in extensions or (): + self.add_extension(extension) + @property def name(self) -> str: return self._lowlevel_server.name @@ -247,6 +254,44 @@ def session_manager(self) -> StreamableHTTPSessionManager: """ return self._lowlevel_server.session_manager + def add_extension(self, extension: Extension) -> None: + """Register an opt-in MCP extension (SEP-2133). + + Applies the extension's contributions through the server's public surface: + its tools and resources are registered, its request methods are wired onto + the low-level server, and its `tools/call` interceptor joins a single + composed `tools/call` middleware. The extension's settings are advertised + under `ServerCapabilities.extensions[extension.identifier]`. + + Args: + extension: The extension to install. + + Raises: + ValueError: If an extension with the same identifier is already registered. + """ + if any(e.identifier == extension.identifier for e in self._extensions): + raise ValueError(f"Extension {extension.identifier!r} is already registered") + self._extensions.append(extension) + + for tool in extension.tools(): + self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs) + for resource in extension.resources(): + self.add_resource(resource.resource) + for method in extension.methods(): + self._lowlevel_server.add_request_handler(method.method, method.params_type, method.handler) + + self._lowlevel_server.extensions[extension.identifier] = extension.settings() + self._refresh_extension_interceptor() + + def _refresh_extension_interceptor(self) -> None: + """Rebuild the single composed `tools/call` interceptor from all extensions.""" + if self._extension_interceptor is not None: + self._lowlevel_server.middleware.remove(self._extension_interceptor) + self._extension_interceptor = None + if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): + self._extension_interceptor = compose_tool_call_interceptor(self._extensions) + self._lowlevel_server.middleware.append(self._extension_interceptor) + @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... From 68b9c7e4ef22ba45d209f013116d42b57338401f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:17:22 +0200 Subject: [PATCH 02/13] Add the pluggable Extension API to MCPServer Introduce `Extension`, a narrow base class (HTTPX `Transport`/`Auth` style) whose methods default so an extension overrides only what it needs: `settings()`, `tools()`, `resources()`, `methods()`, and `intercept_tool_call()`. `MCPServer` accepts `extensions=[...]` at construction and `add_extension()` later, applying a closed set of contributions (tool/resource/method bindings) and composing every extension's `tools/call` interceptor into one `ServerMiddleware`. The server never hands itself to an extension; the extension declares what it adds as data. --- src/mcp/server/mcpserver/extension.py | 130 ++++++++++ tests/server/mcpserver/test_extension.py | 294 +++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 src/mcp/server/mcpserver/extension.py create mode 100644 tests/server/mcpserver/test_extension.py diff --git a/src/mcp/server/mcpserver/extension.py b/src/mcp/server/mcpserver/extension.py new file mode 100644 index 0000000000..0a4ed09359 --- /dev/null +++ b/src/mcp/server/mcpserver/extension.py @@ -0,0 +1,130 @@ +"""Pluggable extension interface for `MCPServer` (SEP-2133). + +An extension is a self-contained, opt-in bundle of MCP behaviour, identified by +a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed at +construction - `MCPServer(..., extensions=[Apps(), Tasks(store)])` - and the +server applies a *closed* set of contribution kinds: tools, resources, new +request methods, and one `tools/call` interceptor. The server never hands itself +to an extension; the extension declares what it adds, and the server consumes it. + +The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class +whose methods have sensible defaults, so an extension overrides only what it +needs. A purely additive extension (Apps) overrides `tools`/`resources`; an +interceptive one (Tasks) overrides `methods`/`intercept_tool_call`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import Any + +from mcp_types import CallToolRequestParams +from pydantic import BaseModel + +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.mcpserver.resources import Resource + +RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] + + +@dataclass(frozen=True) +class ToolBinding: + """A tool an extension contributes, plus the `_meta` to stamp on it.""" + + fn: Callable[..., Any] + meta: dict[str, Any] | None = None + kwargs: dict[str, Any] = field(default_factory=lambda: {}) + + +@dataclass(frozen=True) +class ResourceBinding: + """A pre-built resource an extension contributes.""" + + resource: Resource + + +@dataclass(frozen=True) +class MethodBinding: + """A new request method an extension serves, e.g. `tasks/get`. + + `params_type` validates incoming params before `handler` runs; it should + subclass `RequestParams` so `_meta` parses uniformly. + """ + + method: str + params_type: type[BaseModel] + handler: RequestHandler + + +class Extension: + """Base class for an opt-in MCP extension. Override only the methods you need. + + Subclass and set `identifier`, then override the contribution methods that + apply. Every method has a default, so a minimal extension overrides nothing + but `identifier` and one of `tools`/`resources`/`methods`. + """ + + #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. + identifier: str + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `capabilities.extensions[identifier]`. + + An empty dict (the default) advertises the extension with no settings. + """ + return {} + + def tools(self) -> Sequence[ToolBinding]: + """Tools this extension contributes (additive).""" + return () + + def resources(self) -> Sequence[ResourceBinding]: + """Resources this extension contributes (additive).""" + return () + + def methods(self) -> Sequence[MethodBinding]: + """New request methods this extension serves (additive).""" + return () + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + """Wrap `tools/call`. Default: pass through unchanged. + + Override to short-circuit (return a result without calling `call_next`) + or to observe the call. `params` is the validated `tools/call` params; + `call_next(ctx)` runs the rest of the chain and the real handler. + """ + return await call_next(ctx) + + +def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]: + """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`. + + The returned middleware nests the interceptors (first extension outermost) + and is a no-op for any method other than `tools/call`. It validates the + `tools/call` params once and threads them to each interceptor. + """ + + async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method != "tools/call": + return await call_next(ctx) + params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False) + + chain = call_next + for extension in reversed(extensions): + chain = _bind_interceptor(extension, params, chain) + return await chain(ctx) + + return middleware + + +def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: + async def call(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await extension.intercept_tool_call(params, ctx, call_next) + + return call diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py new file mode 100644 index 0000000000..45cdfa2635 --- /dev/null +++ b/tests/server/mcpserver/test_extension.py @@ -0,0 +1,294 @@ +"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring). + +These exercise the closed set of extension contribution kinds - tools, +resources, request methods, and the single `tools/call` interceptor - through +the highest-level public surface (in-memory `Client`), plus the +`compose_tool_call_interceptor` helper directly. +""" + +from typing import Any, Literal, cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from mcp.client.client import Client +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.extension import ( + Extension, + MethodBinding, + ResourceBinding, + ToolBinding, + compose_tool_call_interceptor, +) +from mcp.server.mcpserver.resources import TextResource + +pytestmark = pytest.mark.anyio + +_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}} + + +class _AdditiveExt(Extension): + """Override `tools()`/`resources()` only - a purely additive extension.""" + + identifier = "com.example/additive" + + def tools(self): + def ping() -> str: + """Reply with pong.""" + return "pong" + + return [ToolBinding(fn=ping, meta=_TOOL_META)] + + def resources(self): + return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))] + + +class _SettingsExt(Extension): + """Override `settings()` so the extension advertises a non-empty settings map.""" + + identifier = "com.example/settings" + + def settings(self) -> dict[str, Any]: + return {"feature": {"enabled": True}} + + +class _PingParams(types.RequestParams): + pass + + +class _PingResult(types.Result): + pong: bool + + +class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]): + method: Literal["com.example/ping"] = "com.example/ping" + params: _PingParams + + +class _MethodExt(Extension): + """Override `methods()` to serve a new vendor request verb.""" + + identifier = "com.example/method" + + def methods(self): + async def handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: + return _PingResult(pong=True) + + return [MethodBinding("com.example/ping", _PingParams, handler)] + + +class _ReplacingExt(Extension): + """Override `intercept_tool_call()` to short-circuit with a fixed result.""" + + identifier = "com.example/replacing" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return CallToolResult(content=[TextContent(type="text", text="intercepted")]) + + +class _PassThroughExt(Extension): + """Override `intercept_tool_call()` but always delegate to `call_next` unchanged.""" + + identifier = "com.example/passthrough" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return await call_next(ctx) + + +class _DefaultExt(Extension): + """Override nothing - relies on the base `intercept_tool_call` default (pass through).""" + + identifier = "com.example/default" + + +class _RecordingExt(Extension): + """Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through.""" + + def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None: + self.identifier = identifier + self._log = log + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + self._log.append((self.identifier, params.name)) + return await call_next(ctx) + + +def _echo(value: str) -> str: + """Echo the input value (shared tool body across interceptor tests).""" + return value + + +async def test_additive_extension_registers_its_tool_and_resource() -> None: + """SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both + through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's + `_meta` round-trips equal to the exact dict the binding carried (identity can't + hold - the value is JSON-serialized over the transport).""" + server = MCPServer("test", extensions=[_AdditiveExt()]) + + async with Client(server) as client: + tools = await client.list_tools() + resources = await client.list_resources() + called = await client.call_tool("ping", {}) + + assert [t.name for t in tools.tools] == ["ping"] + assert tools.tools[0].meta == _TOOL_META + assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"})) + assert resources == snapshot( + types.ListResourcesResult( + resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] + ) + ) + + +async def test_extension_settings_advertised_under_server_capabilities() -> None: + """SDK-defined: `settings()` rides `server/discover` and lands under + `server_capabilities.extensions[identifier]` on the modern (`auto`) path.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="auto") as client: + extensions = client.server_capabilities.extensions + + assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}}) + + +async def test_extension_settings_dropped_on_legacy_handshake() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` + field, so a legacy `initialize` handshake drops the advertised extension even + though the modern `auto` path carries it.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +def test_duplicate_extension_identifier_raises() -> None: + """SDK-defined: registering two extensions with the same `identifier` is a + construction error.""" + with pytest.raises(ValueError): + MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()]) + + +def test_add_extension_after_construction_rejects_duplicate_identifier() -> None: + """SDK-defined: `add_extension` enforces the same uniqueness as the constructor.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + with pytest.raises(ValueError): + server.add_extension(_SettingsExt()) + + +async def test_extension_method_reachable_via_session_send_request() -> None: + """SDK-defined: an `Extension` overriding `methods()` wires a new request verb + onto the low-level server, reachable through `client.session.send_request`.""" + server = MCPServer("test", extensions=[_MethodExt()]) + + async with Client(server) as client: + request = _PingRequest(params=_PingParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult) + + assert result == snapshot(_PingResult(pong=True)) + + +async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: + """SDK-defined: an extension whose `intercept_tool_call` delegates to + `call_next` does not alter the underlying tool's `CallToolResult`.""" + server = MCPServer("test", extensions=[_PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_short_circuiting_interceptor_replaces_tool_result() -> None: + """SDK-defined: an extension that returns from `intercept_tool_call` without + calling `call_next` replaces the tool's result wholesale (the tool never runs).""" + server = MCPServer("test", extensions=[_ReplacingExt()]) + server.tool(name="echo", structured_output=False)(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + + +def test_plain_extension_installs_no_tool_call_interceptor() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` leaves + `_extension_interceptor` unset and adds no middleware - the composed + interceptor exists only when at least one extension overrides it.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_AdditiveExt()]) + + assert server._extension_interceptor is None + assert len(server._lowlevel_server.middleware) == baseline + + +def test_overriding_extension_installs_one_tool_call_interceptor() -> None: + """SDK-defined: registering an extension that overrides `intercept_tool_call` + composes exactly one middleware and records it as `_extension_interceptor`.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_ReplacingExt()]) + + assert server._extension_interceptor is not None + assert len(server._lowlevel_server.middleware) == baseline + 1 + assert server._lowlevel_server.middleware[-1] is server._extension_interceptor + + +async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` runs the + base-class default (pass through) when another extension forces the composed + middleware to exist, leaving the tool result untouched.""" + server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: + """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so + two passing-through interceptors record in registration order, each seeing the + validated `tools/call` params (the real tool name).""" + log: list[tuple[str, str]] = [] + server = MCPServer( + "test", + extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)], + ) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + await client.call_tool("echo", {"value": "hi"}) + + assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] + + +async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None: + """SDK-defined: the composed middleware is a no-op for any method other than + `tools/call` - it forwards to `call_next` without touching the interceptors.""" + sentinel = types.EmptyResult() + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return sentinel + + middleware = compose_tool_call_interceptor([_ReplacingExt()]) + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tasks/get", + params={"taskId": "t-1"}, + ) + + result = await middleware(ctx, call_next) + + assert result is sentinel From c8684e9245e0a29087fc1be6d9092247c8754a0b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:17:39 +0200 Subject: [PATCH 03/13] Add the MCP Apps extension (io.modelcontextprotocol/ui) `Apps` is an additive `Extension`: `@apps.tool(resource_uri=...)` binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, `add_html_resource()` serves the HTML at `text/html;profile=mcp-app`, and `client_supports_apps(ctx)` gates the SEP-2133 text-only fallback. Drop the now-exercised `# pragma: no cover` on `TextResource.read()` (the Apps resource path covers it). --- src/mcp/server/apps.py | 138 ++++++++++++++++++++++++++++++++++++++ tests/server/test_apps.py | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/mcp/server/apps.py create mode 100644 tests/server/test_apps.py diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py new file mode 100644 index 0000000000..5e49896783 --- /dev/null +++ b/src/mcp/server/apps.py @@ -0,0 +1,138 @@ +"""MCP Apps extension (`io.modelcontextprotocol/ui`). + +MCP Apps lets a tool carry a reference to an interactive UI: the tool's +`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served +with the `text/html;profile=mcp-app` MIME type) that the host renders in a +sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps +and SEP-2133 for the extension framework. + +This is a self-contained, additive `Extension`: it contributes tools and +resources and advertises the capability, but does not intercept any core method. +A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`. + + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", description="Current time") + def get_time(ctx: Context) -> str: + return datetime.now(timezone.utc).isoformat() + + apps.add_html_resource("ui://clock/app.html", CLOCK_HTML) + + mcp = MCPServer("clock", extensions=[apps]) + +Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should +still return meaningful text for clients that did not negotiate Apps. Use +`client_supports_apps(ctx)` to branch on the client's advertised support. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, TypeVar + +from mcp.server.context import ServerRequestContext +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.extension import Extension, ResourceBinding, ToolBinding +from mcp.server.mcpserver.resources import TextResource + +EXTENSION_ID = "io.modelcontextprotocol/ui" +"""The MCP Apps extension identifier (the shipped TS/C# constant).""" + +APP_MIME_TYPE = "text/html;profile=mcp-app" +"""MIME type for a `ui://` app resource.""" + +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + + +class Apps(Extension): + """The MCP Apps extension: bind tools to `ui://` UI resources. + + Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML + with `add_html_resource(...)`, then pass the instance to + `MCPServer(extensions=[apps])`. + """ + + identifier = EXTENSION_ID + + def __init__(self) -> None: + self._tools: list[ToolBinding] = [] + self._resources: list[ResourceBinding] = [] + + def tool(self, *, resource_uri: str, **tool_kwargs: Any) -> Callable[[_CallableT], _CallableT]: + """Decorator registering a tool bound to a `ui://` resource. + + Stamps `_meta.ui.resourceUri` on the tool. `tool_kwargs` are forwarded to + `MCPServer.add_tool` (name, title, description, annotations, ...). + + Args: + resource_uri: The `ui://` URI of the UI resource this tool renders. + + Raises: + ValueError: If `resource_uri` does not use the `ui://` scheme. + """ + _require_ui_scheme(resource_uri) + + def decorator(fn: _CallableT) -> _CallableT: + meta = {"ui": {"resourceUri": resource_uri}} + self._tools.append(ToolBinding(fn=fn, meta=meta, kwargs=tool_kwargs)) + return fn + + return decorator + + def add_html_resource( + self, + uri: str, + html: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + ) -> None: + """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`. + + Args: + uri: The `ui://` URI; a tool references it via `resource_uri`. + html: The HTML document the host renders. + + Raises: + ValueError: If `uri` does not use the `ui://` scheme. + """ + _require_ui_scheme(uri) + resource = TextResource( + uri=uri, + name=name or uri, + title=title, + description=description, + mime_type=APP_MIME_TYPE, + text=html, + ) + self._resources.append(ResourceBinding(resource=resource)) + + def tools(self) -> Sequence[ToolBinding]: + return self._tools + + def resources(self) -> Sequence[ResourceBinding]: + return self._resources + + +def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool: + """Whether the connected client negotiated MCP Apps support. + + Returns `False` when the client did not advertise the extension (or sent no + capabilities), so a UI-enabled tool can fall back to text-only output. + """ + capabilities = _client_capabilities(ctx) + extensions = capabilities.extensions if capabilities else None + return bool(extensions and EXTENSION_ID in extensions) + + +def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: + if isinstance(ctx, Context): + return ctx.client_capabilities + client_params = ctx.session.client_params + return client_params.capabilities if client_params else None + + +def _require_ui_scheme(uri: str) -> None: + if not uri.startswith("ui://"): + raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}") diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py new file mode 100644 index 0000000000..63647f7c86 --- /dev/null +++ b/tests/server/test_apps.py @@ -0,0 +1,135 @@ +"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133). + +The headline property is SEP-2133 graceful degradation: a UI-bound tool returns +rich output to a client that negotiated Apps and text-only output to one that did +not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp, +the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme +validation). +""" + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context + +pytestmark = pytest.mark.anyio + + +def _clock_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + if not client_supports_apps(ctx): + return "The time is 2026-06-26T00:00:00Z." + return "2026-06-26T00:00:00Z" + + apps.add_html_resource("ui://clock/app.html", "Clock", title="Clock") + return MCPServer("clock", extensions=[apps]) + + +async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None: + """SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the + advertised tool, observed end-to-end through `list_tools`.""" + async with Client(_clock_server()) as client: + result = await client.list_tools() + assert [(t.name, t.meta) for t in result.tools] == snapshot( + [("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})] + ) + + +async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: + """SDK-defined: `add_html_resource` registers the `ui://` resource served as + `text/html;profile=mcp-app`, observed through `read_resource`.""" + async with Client(_clock_server()) as client: + result = await client.read_resource("ui://clock/app.html") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="ui://clock/app.html", + mime_type="text/html;profile=mcp-app", + text="Clock", + ) + ] + ) + ) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None: + """SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client + sees `EXTENSION_ID` under `server_capabilities.extensions`.""" + async with Client(_clock_server(), mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}}) + + +async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field, + so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'` + (server/discover) does. This pins the divergence rather than fixing it.""" + async with Client(_clock_server(), mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None: + """SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the + rich (UI) path, while one that did not gets the text-only fallback. The same tool, + branching on `client_supports_apps(ctx)`, drives both halves.""" + server = _clock_server() + + async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: + rich = await supports.call_tool("get_time", {}) + async with Client(server) as plain: + fallback = await plain.call_tool("get_time", {}) + + assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")]) + assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")]) + + +async def test_client_supports_apps_reads_lowlevel_request_context() -> None: + """SDK-defined: `client_supports_apps` accepts a lowlevel `ServerRequestContext` too, + reading the client's advertised extensions off `session.client_params`.""" + observed: list[bool] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + observed.append(client_supports_apps(ctx)) + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool) + + async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: + await supports.call_tool("probe", {}) + async with Client(server) as plain: + await plain.call_tool("probe", {}) + + assert observed == [True, False] + + +def test_apps_tool_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a + programmer error raised at decoration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.tool(resource_uri="https://example.com/app.html") + + +def test_add_html_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is + a programmer error raised at registration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_html_resource("https://example.com/app.html", "x") From 51ad1006b3f9c06f4068fedffe5ebd8214c625bd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:17:55 +0200 Subject: [PATCH 04/13] Add the Tasks extension (io.modelcontextprotocol/tasks) `Tasks` is an interceptive `Extension`: `intercept_tool_call` records a task-augmented `tools/call` and stamps the task id into `_meta[io.modelcontextprotocol/related-task]`, while `methods()` serves `tasks/get`, `tasks/result`, `tasks/cancel`, and `tasks/list` over an in-memory store. It demonstrates the interceptive seam; the augmented call returns a `CallToolResult` rather than `CreateTaskResult` because the `tools/call` result schema admits only `CallToolResult | InputRequiredResult` (TODO L56). Also add the negotiation-plumbing tests shared by both extensions. --- src/mcp/server/tasks.py | 183 +++++++++++++ tests/server/test_extensions_capability.py | 134 ++++++++++ tests/server/test_tasks.py | 287 +++++++++++++++++++++ 3 files changed, 604 insertions(+) create mode 100644 src/mcp/server/tasks.py create mode 100644 tests/server/test_extensions_capability.py create mode 100644 tests/server/test_tasks.py diff --git a/src/mcp/server/tasks.py b/src/mcp/server/tasks.py new file mode 100644 index 0000000000..42b317e092 --- /dev/null +++ b/src/mcp/server/tasks.py @@ -0,0 +1,183 @@ +"""Tasks extension (`io.modelcontextprotocol/tasks`). + +Tasks let a client request *task-augmented* execution of a tool call: instead of +blocking for the `CallToolResult`, the client sends `tools/call` with a `task` +field and immediately gets back a `CreateTaskResult` carrying a task id. It then +polls `tasks/get` for status and `tasks/result` for the payload, and may +`tasks/cancel` or `tasks/list`. Tasks were part of the core spec in 2025-11-25 +and now continue as an extension. See SEP-2133 for the extension framework. + +This module demonstrates the *interceptive* half of the extension API. A `Tasks` +instance: + + - overrides `intercept_tool_call` to branch on `params.task`: a plain call + passes through untouched; a task-augmented call still runs the tool, but its + result is recorded under a task id and returned with that id stamped into + `_meta["io.modelcontextprotocol/related-task"]`, and + - overrides `methods` to serve `tasks/get`, `tasks/result`, `tasks/cancel`, + and `tasks/list` so a client can poll status and fetch the payload. + + mcp = MCPServer("demo", extensions=[Tasks()]) + +Scope: this is a reference implementation for the extension API, not a +production task runtime. Two deliberate simplifications keep it self-contained: + + - The tool runs to completion inline, so a task is observed as `completed` + immediately (no detached/background execution, no TTL eviction). + - A task-augmented `tools/call` returns a normal `CallToolResult` (with the + task id in `_meta`) rather than the spec's `CreateTaskResult`. The wire + schema for `tools/call` only admits `CallToolResult | InputRequiredResult` + (even at 2026-07-28; see the `TODO(L56)` in `mcp.server.runner`), so + returning `CreateTaskResult` would require extending the methods-layer + validation maps. Driving the lifecycle through the dedicated `tasks/*` + methods stays within the schema while still exercising the interceptor. + +The store is in-memory and per-server. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +import mcp_types as types + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver.extension import Extension, MethodBinding +from mcp.shared.exceptions import MCPError + +EXTENSION_ID = "io.modelcontextprotocol/tasks" +"""The Tasks extension identifier.""" + +RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task" +"""`_meta` key associating a `CallToolResult` with the task that produced it.""" + +Clock = Callable[[], str] +"""Returns the current time as an ISO-8601 string (injectable for determinism).""" + + +def _fixed_clock() -> str: + return "1970-01-01T00:00:00Z" + + +class TaskStore: + """In-memory record of tasks and their completed payloads.""" + + def __init__(self) -> None: + self._tasks: dict[str, types.Task] = {} + self._results: dict[str, dict[str, Any]] = {} + self._counter = 0 + + def create(self, now: str, ttl: int | None) -> types.Task: + self._counter += 1 + task_id = f"task-{self._counter}" + task = types.Task( + task_id=task_id, + status="working", + created_at=now, + last_updated_at=now, + ttl=ttl, + ) + self._tasks[task_id] = task + return task + + def complete(self, task_id: str, now: str, result: dict[str, Any]) -> None: + task = self._tasks[task_id] + self._tasks[task_id] = task.model_copy(update={"status": "completed", "last_updated_at": now}) + self._results[task_id] = result + + def fail(self, task_id: str, now: str) -> None: + task = self._tasks[task_id] + self._tasks[task_id] = task.model_copy(update={"status": "failed", "last_updated_at": now}) + + def cancel(self, task_id: str, now: str) -> types.Task: + task = self._tasks[task_id] + cancelled = task.model_copy(update={"status": "cancelled", "last_updated_at": now}) + self._tasks[task_id] = cancelled + return cancelled + + def get(self, task_id: str) -> types.Task | None: + return self._tasks.get(task_id) + + def result(self, task_id: str) -> dict[str, Any] | None: + return self._results.get(task_id) + + def list(self) -> list[types.Task]: + return list(self._tasks.values()) + + +class Tasks(Extension): + """The Tasks extension: task-augmented tool execution plus the `tasks/*` methods.""" + + identifier = EXTENSION_ID + + def __init__(self, *, clock: Clock = _fixed_clock) -> None: + self._store = TaskStore() + self._clock = clock + + def settings(self) -> dict[str, Any]: + # Advertise list + cancel support (per ServerTasksCapability). + return {"list": {}, "cancel": {}} + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding("tasks/get", types.GetTaskRequestParams, self._handle_get), + MethodBinding("tasks/result", types.GetTaskPayloadRequestParams, self._handle_result), + MethodBinding("tasks/cancel", types.CancelTaskRequestParams, self._handle_cancel), + MethodBinding("tasks/list", types.PaginatedRequestParams, self._handle_list), + ] + + async def intercept_tool_call( + self, + params: types.CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + if params.task is None: + return await call_next(ctx) + now = self._clock() + task = self._store.create(now, params.task.ttl) + # `call_next` runs the real tool; its already-serialized `CallToolResult` + # dict is what we record and return (with the task id stamped on `_meta`). + result = await call_next(ctx) + payload = result if isinstance(result, dict) else {} + if payload.get("isError"): + self._store.fail(task.task_id, self._clock()) + else: + self._store.complete(task.task_id, self._clock(), payload) + existing_meta: dict[str, Any] = payload.get("_meta") or {} + meta = {**existing_meta, RELATED_TASK_META_KEY: {"taskId": task.task_id}} + return {**payload, "_meta": meta} + + async def _handle_get( + self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskRequestParams + ) -> types.GetTaskResult: + task = self._require(params.task_id) + return types.GetTaskResult.model_validate(task.model_dump(by_alias=True)) + + async def _handle_result( + self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskPayloadRequestParams + ) -> dict[str, Any]: + self._require(params.task_id) + payload = self._store.result(params.task_id) + if payload is None: + raise MCPError(code=types.INVALID_PARAMS, message=f"task {params.task_id!r} has no result") + return payload + + async def _handle_cancel( + self, ctx: ServerRequestContext[Any, Any], params: types.CancelTaskRequestParams + ) -> types.CancelTaskResult: + self._require(params.task_id) + cancelled = self._store.cancel(params.task_id, self._clock()) + return types.CancelTaskResult.model_validate(cancelled.model_dump(by_alias=True)) + + async def _handle_list( + self, ctx: ServerRequestContext[Any, Any], params: types.PaginatedRequestParams + ) -> types.ListTasksResult: + return types.ListTasksResult(tasks=self._store.list()) + + def _require(self, task_id: str) -> types.Task: + task = self._store.get(task_id) + if task is None: + raise MCPError(code=types.INVALID_PARAMS, message=f"unknown task {task_id!r}") + return task diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py new file mode 100644 index 0000000000..3b7f689782 --- /dev/null +++ b/tests/server/test_extensions_capability.py @@ -0,0 +1,134 @@ +"""Tests for the SEP-2133 extensions capability negotiation plumbing. + +The extension-map negotiation is independent of any concrete extension (Apps, +Tasks): the lowlevel `Server` advertises `self.extensions` under +`ServerCapabilities.extensions`, a client mirrors its own support under +`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the +server-side query. These tests pin that plumbing end-to-end and at the unit +level. Per-extension contribution wiring lives in `test_extension.py`; this file +covers only the capability advertisement and negotiation. +""" + +import mcp_types as types +import pytest +from inline_snapshot import snapshot + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.extension import Extension + +pytestmark = pytest.mark.anyio + +_EXTENSION_ID = "com.example/x" +_OTHER_EXTENSION_ID = "com.example/other" + + +class _Extension(Extension): + identifier = _EXTENSION_ID + + def settings(self) -> dict[str, object]: + return {"k": 1} + + +def test_get_capabilities_omits_extensions_when_none_registered() -> None: + """SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises + `ServerCapabilities.extensions` as `None`, not an empty map.""" + server = Server("bare") + assert server.get_capabilities().extensions is None + + +def test_get_capabilities_advertises_populated_self_extensions() -> None: + """SDK-defined: `get_capabilities` reads `self.extensions` (the map higher + layers populate) and advertises it under `ServerCapabilities.extensions`.""" + server = Server("with-ext") + settings = {"k": 1} + server.extensions = {_EXTENSION_ID: settings} + assert server.get_capabilities().extensions == {_EXTENSION_ID: settings} + + +async def test_modern_connection_carries_the_advertised_extensions_map() -> None: + """SDK-defined: over a modern (`server/discover`) connection the client reads + the server's advertised extension map from `server_capabilities`.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}}) + + +async def test_legacy_handshake_drops_the_extensions_map() -> None: + """Pinned gap: the handshake-era `initialize` result is serialized against the + 2025 wire schema, which has no `extensions` field, so a legacy handshake cannot + carry it; the client sees `None` even though the server advertised one.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_server_accepts_capability_for_client_advertised_extension() -> None: + """SDK-defined: a client advertising `extensions={id: ...}` makes the + server-side `check_client_capability` return True when queried for that id. + Observed inside a tool handler.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [True] + + +async def test_server_rejects_capability_for_undeclared_extension() -> None: + """SDK-defined: when the client advertises one extension, a server query for a + *different* identifier returns False - presence, not value, is the check.""" + queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [False] + + +async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None: + """SDK-defined: a client that declares no extensions makes any server + `check_client_capability` query for an extension return False.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server) as client: + await client.call_tool("probe", {}) + + assert supported == [False] diff --git a/tests/server/test_tasks.py b/tests/server/test_tasks.py new file mode 100644 index 0000000000..921dee2dfa --- /dev/null +++ b/tests/server/test_tasks.py @@ -0,0 +1,287 @@ +"""End-to-end tests for the Tasks extension (`io.modelcontextprotocol/tasks`, SEP-2133). + +Tasks is a reference implementation of the *interceptive* half of the extension API: a +task-augmented `tools/call` runs the tool, records the result under a task id, and stamps that +id into `_meta[RELATED_TASK_META_KEY]`; the `tasks/*` methods then poll status and fetch the +payload. The lifecycle verbs are vendor methods, so they go through the `client.session` +escape hatch (`Client` only exposes spec verbs). A fixed `clock` makes timestamps deterministic. +""" + +from typing import Any, cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import INVALID_PARAMS, CallToolResult, TextContent + +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer +from mcp.server.tasks import RELATED_TASK_META_KEY, Tasks +from mcp.shared.exceptions import MCPError + +pytestmark = pytest.mark.anyio + +FIXED_NOW = "2026-01-01T00:00:00Z" + + +def _server() -> MCPServer: + mcp = MCPServer("demo", extensions=[Tasks(clock=lambda: FIXED_NOW)]) + + @mcp.tool() + def greet(name: str) -> str: + return f"hi {name}" + + @mcp.tool() + def boom() -> str: + raise ValueError("kaboom") + + return mcp + + +def _call_tool_request(name: str, arguments: dict[str, Any], task: types.TaskMetadata | None) -> types.ClientRequest: + request = types.CallToolRequest(params=types.CallToolRequestParams(name=name, arguments=arguments, task=task)) + return cast("types.ClientRequest", request) + + +async def test_plain_tool_call_carries_no_related_task_meta() -> None: + """A `tools/call` with no `task` field passes through the interceptor untouched: SDK-defined.""" + async with Client(_server()) as client: + result = await client.call_tool("greet", {"name": "ada"}) + + assert result == snapshot( + CallToolResult( + content=[TextContent(text="hi ada")], + structured_content={"result": "hi ada"}, + ) + ) + assert result.meta is None + + +async def test_task_augmented_call_runs_tool_and_stamps_task_id() -> None: + """A task-augmented `tools/call` runs the tool and returns its result with the new task id + stamped into `_meta[RELATED_TASK_META_KEY]`: SDK-defined.""" + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + result = await client.session.send_request(request, CallToolResult) + + assert result.content == snapshot([TextContent(text="hi ada")]) + assert result.meta == snapshot({RELATED_TASK_META_KEY: {"taskId": "task-1"}}) + + +async def test_tasks_get_reports_completed_status_and_injected_clock() -> None: + """`tasks/get` returns the task as `completed` with timestamps from the injected clock: SDK-defined.""" + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + get_request = cast( + "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) + ) + task = await client.session.send_request(get_request, types.GetTaskResult) + + assert task == snapshot( + types.GetTaskResult( + task_id="task-1", + status="completed", + created_at=FIXED_NOW, + last_updated_at=FIXED_NOW, + ttl=60, + ) + ) + + +async def test_tasks_get_uses_default_clock_when_none_injected() -> None: + """A `Tasks()` with no injected clock stamps the default `_fixed_clock` epoch timestamp: SDK-defined.""" + mcp = MCPServer("demo", extensions=[Tasks()]) + + @mcp.tool() + def greet(name: str) -> str: + return f"hi {name}" + + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(mcp) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + get_request = cast( + "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) + ) + task = await client.session.send_request(get_request, types.GetTaskResult) + + assert task == snapshot( + types.GetTaskResult( + task_id="task-1", + status="completed", + created_at="1970-01-01T00:00:00Z", + last_updated_at="1970-01-01T00:00:00Z", + ttl=60, + ) + ) + + +async def test_tasks_result_returns_stored_tool_payload() -> None: + """`tasks/result` returns the tool's stored payload, without the related-task `_meta` stamp: SDK-defined.""" + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + result_request = cast( + "types.ClientRequest", + types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)), + ) + payload = await client.session.send_request(result_request, CallToolResult) + + assert payload == snapshot( + CallToolResult( + content=[TextContent(text="hi ada")], + structured_content={"result": "hi ada"}, + ) + ) + assert payload.meta is None + + +async def test_tasks_list_returns_created_task() -> None: + """`tasks/list` returns the tasks recorded by task-augmented calls: SDK-defined.""" + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + await client.session.send_request(request, CallToolResult) + + list_request = cast("types.ClientRequest", types.ListTasksRequest(params=types.PaginatedRequestParams())) + listing = await client.session.send_request(list_request, types.ListTasksResult) + + assert listing == snapshot( + types.ListTasksResult( + tasks=[ + types.Task( + task_id="task-1", + status="completed", + created_at=FIXED_NOW, + last_updated_at=FIXED_NOW, + ttl=60, + ) + ] + ) + ) + + +async def test_tasks_cancel_sets_cancelled_status() -> None: + """`tasks/cancel` transitions the task to `cancelled`: SDK-defined.""" + request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + cancel_request = cast( + "types.ClientRequest", types.CancelTaskRequest(params=types.CancelTaskRequestParams(task_id=task_id)) + ) + cancelled = await client.session.send_request(cancel_request, types.CancelTaskResult) + + assert cancelled == snapshot( + types.CancelTaskResult( + task_id="task-1", + status="cancelled", + created_at=FIXED_NOW, + last_updated_at=FIXED_NOW, + ttl=60, + ) + ) + + +async def test_failing_task_augmented_call_marks_task_failed() -> None: + """A task-augmented call to a tool that raises returns `is_error` and records the task as `failed`, + so a later `tasks/get` reports `failed`: SDK-defined.""" + request = _call_tool_request("boom", {}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + get_request = cast( + "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) + ) + task = await client.session.send_request(get_request, types.GetTaskResult) + + assert created.is_error is True + assert created.meta == snapshot({RELATED_TASK_META_KEY: {"taskId": "task-1"}}) + assert task == snapshot( + types.GetTaskResult( + task_id="task-1", + status="failed", + created_at=FIXED_NOW, + last_updated_at=FIXED_NOW, + ttl=60, + ) + ) + + +async def test_tasks_result_on_failed_task_raises_invalid_params() -> None: + """`tasks/result` for a task that exists but stored no payload (a failed task) raises INVALID_PARAMS. + + SDK-defined. + """ + request = _call_tool_request("boom", {}, types.TaskMetadata(ttl=60)) + + async with Client(_server()) as client: + created = await client.session.send_request(request, CallToolResult) + assert created.meta is not None + task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] + + result_request = cast( + "types.ClientRequest", + types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)), + ) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(result_request, CallToolResult) + + assert exc_info.value.code == INVALID_PARAMS + + +_UNKNOWN_TASK_ID = "does-not-exist" + +_UNKNOWN_ID_CASES: list[tuple[types.ClientRequest, type[types.Result]]] = [ + ( + cast("types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=_UNKNOWN_TASK_ID))), + types.GetTaskResult, + ), + ( + cast( + "types.ClientRequest", + types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=_UNKNOWN_TASK_ID)), + ), + CallToolResult, + ), + ( + cast( + "types.ClientRequest", + types.CancelTaskRequest(params=types.CancelTaskRequestParams(task_id=_UNKNOWN_TASK_ID)), + ), + types.CancelTaskResult, + ), +] + + +@pytest.mark.parametrize( + ("request_", "result_type"), _UNKNOWN_ID_CASES, ids=["tasks/get", "tasks/result", "tasks/cancel"] +) +async def test_unknown_task_id_raises_invalid_params( + request_: types.ClientRequest, result_type: type[types.Result] +) -> None: + """`tasks/get`, `tasks/result`, and `tasks/cancel` reject an unknown task id with INVALID_PARAMS: SDK-defined.""" + async with Client(_server()) as client: + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(request_, result_type) + + assert exc_info.value.code == INVALID_PARAMS From ff285f492479dffd7fe9923b1abd2c65335bd80f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:18:11 +0200 Subject: [PATCH 05/13] Add apps and tasks example stories and migration notes Wire runnable `apps` and `tasks` stories (in-memory + http-asgi) into the manifest and document the extensions API in the migration guide. --- examples/stories/apps/__init__.py | 0 examples/stories/apps/client.py | 35 +++++++++++++++++++++++ examples/stories/apps/server.py | 45 +++++++++++++++++++++++++++++ examples/stories/tasks/__init__.py | 0 examples/stories/tasks/client.py | 46 ++++++++++++++++++++++++++++++ examples/stories/tasks/server.py | 25 ++++++++++++++++ 6 files changed, 151 insertions(+) create mode 100644 examples/stories/apps/__init__.py create mode 100644 examples/stories/apps/client.py create mode 100644 examples/stories/apps/server.py create mode 100644 examples/stories/tasks/__init__.py create mode 100644 examples/stories/tasks/client.py create mode 100644 examples/stories/tasks/server.py diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py new file mode 100644 index 0000000000..8a238f469e --- /dev/null +++ b/examples/stories/apps/client.py @@ -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) diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py new file mode 100644 index 0000000000..973b8ef094 --- /dev/null +++ b/examples/stories/apps/server.py @@ -0,0 +1,45 @@ +"""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 = """ +Current time +

+ +""" + + +def build_server() -> MCPServer: + mcp = MCPServer("apps-example") + 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") + mcp.add_extension(apps) + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/tasks/__init__.py b/examples/stories/tasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/tasks/client.py b/examples/stories/tasks/client.py new file mode 100644 index 0000000000..fb195efd7b --- /dev/null +++ b/examples/stories/tasks/client.py @@ -0,0 +1,46 @@ +"""Request task-augmented execution, then drive the task lifecycle via `tasks/*`.""" + +from typing import cast + +import mcp_types as types + +from mcp.client import Client +from mcp.server.tasks import EXTENSION_ID, RELATED_TASK_META_KEY +from stories._harness import Target, run_client + + +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": {}}} + + # `Client` exposes only spec verbs, so task-augmented calls and the + # `tasks/*` methods drop to `client.session` (see custom_methods/). The + # casts satisfy the closed `ClientRequest` union; at runtime the body + # only calls `.model_dump()`. + session = client.session + call = types.CallToolRequest( + params=types.CallToolRequestParams( + name="echo", arguments={"text": "async"}, task=types.TaskMetadata(ttl=60) + ) + ) + result = await session.send_request(cast("types.ClientRequest", call), types.CallToolResult) + assert result.meta is not None, result + task_id = result.meta[RELATED_TASK_META_KEY]["taskId"] + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "async", result + + get = types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) + status = await session.send_request(cast("types.ClientRequest", get), types.GetTaskResult) + assert status.status == "completed", status + + payload_req = types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)) + payload = await session.send_request(cast("types.ClientRequest", payload_req), types.CallToolResult) + assert isinstance(payload.content[0], types.TextContent) + assert payload.content[0].text == "async", payload + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/tasks/server.py b/examples/stories/tasks/server.py new file mode 100644 index 0000000000..40548b4b30 --- /dev/null +++ b/examples/stories/tasks/server.py @@ -0,0 +1,25 @@ +"""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 passes +through, but a call carrying a `task` field is recorded under a task id and +returned with that id in `_meta`. It also serves the `tasks/*` methods so a +client can poll status and fetch the payload. +""" + +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="Echo the input back as plain text.", structured_output=False) + def echo(text: str) -> str: + return text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) From 42d6900f3aca94e4c22433b0e765723e07449c75 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:33:15 +0200 Subject: [PATCH 06/13] Make extensions construction-only and improve the tasks example Drop the public `MCPServer.add_extension`; extensions are fixed at construction via `extensions=[...]` (the apply logic moves to a private `_apply_extension`, with the `tools/call` interceptor composed once afterwards). This matches the declarative design and removes the mid-connection mutation footgun. Rework the tasks story around a `render_report` tool whose multi-step work motivates running it as a task, with named `_start_task` / `_get_task` / `_task_result` helpers so the client reads as a clear lifecycle. --- docs/migration.md | 3 +- examples/stories/apps/README.md | 6 +-- examples/stories/apps/server.py | 4 +- examples/stories/manifest.toml | 2 +- examples/stories/tasks/README.md | 15 +++--- examples/stories/tasks/client.py | 62 ++++++++++++++---------- examples/stories/tasks/server.py | 17 ++++--- src/mcp/server/mcpserver/server.py | 40 ++++++--------- tests/server/mcpserver/test_extension.py | 20 ++------ 9 files changed, 82 insertions(+), 87 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 1a19283a37..8bb244eb6b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -414,7 +414,7 @@ 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, or register later with `add_extension`: +construction: ```python from mcp.server.mcpserver import MCPServer @@ -422,7 +422,6 @@ from mcp.server.apps import Apps from mcp.server.tasks import Tasks mcp = MCPServer("demo", extensions=[Apps(), Tasks()]) -# or: mcp.add_extension(Apps()) ``` Two reference extensions ship in their own modules: diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index a4f429067d..b384a2f5ac 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -17,9 +17,9 @@ uv run python -m stories.apps.client --http ## What to look at -- `server.py` `Apps()` + `mcp.add_extension(apps)` — the extension advertises - `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` and - contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself +- `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 diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py index 973b8ef094..74d412e02c 100644 --- a/examples/stories/apps/server.py +++ b/examples/stories/apps/server.py @@ -26,7 +26,6 @@ def build_server() -> MCPServer: - mcp = MCPServer("apps-example") apps = Apps() @apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.") @@ -37,8 +36,7 @@ def get_time(ctx: Context) -> str: return now apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock") - mcp.add_extension(apps) - return mcp + return MCPServer("apps-example", extensions=[apps]) if __name__ == "__main__": diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index a0a6f2ac24..a230c01f35 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -49,7 +49,7 @@ status = "deprecated" lowlevel = false [story.apps] -# Extension API is MCPServer-tier (Apps decorators + add_extension); no lowlevel variant. +# 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 diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index 67ee807183..6a778d29e0 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -18,15 +18,18 @@ uv run python -m stories.tasks.client --http ## What to look at -- `server.py` `MCPServer(extensions=[Tasks()])` — opt in at construction. The - extension advertises `io.modelcontextprotocol/tasks` and serves `tasks/get`, - `tasks/result`, `tasks/cancel`, and `tasks/list`. +- `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` — sends a task-augmented `tools/call` via `client.session` (the - `task` field and `tasks/*` methods are outside the spec verbs `Client` - exposes), then drives the lifecycle through `tasks/get` and `tasks/result`. +- `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 diff --git a/examples/stories/tasks/client.py b/examples/stories/tasks/client.py index fb195efd7b..11fbfec998 100644 --- a/examples/stories/tasks/client.py +++ b/examples/stories/tasks/client.py @@ -1,14 +1,38 @@ -"""Request task-augmented execution, then drive the task lifecycle via `tasks/*`.""" +"""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 +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 @@ -16,30 +40,16 @@ async def main(target: Target, *, mode: str = "auto") -> None: if client.server_capabilities.extensions is not None: assert client.server_capabilities.extensions == {EXTENSION_ID: {"list": {}, "cancel": {}}} - # `Client` exposes only spec verbs, so task-augmented calls and the - # `tasks/*` methods drop to `client.session` (see custom_methods/). The - # casts satisfy the closed `ClientRequest` union; at runtime the body - # only calls `.model_dump()`. - session = client.session - call = types.CallToolRequest( - params=types.CallToolRequestParams( - name="echo", arguments={"text": "async"}, task=types.TaskMetadata(ttl=60) - ) - ) - result = await session.send_request(cast("types.ClientRequest", call), types.CallToolResult) - assert result.meta is not None, result - task_id = result.meta[RELATED_TASK_META_KEY]["taskId"] - assert isinstance(result.content[0], types.TextContent) - assert result.content[0].text == "async", result - - get = types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) - status = await session.send_request(cast("types.ClientRequest", get), types.GetTaskResult) - assert status.status == "completed", status - - payload_req = types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)) - payload = await session.send_request(cast("types.ClientRequest", payload_req), types.CallToolResult) - assert isinstance(payload.content[0], types.TextContent) - assert payload.content[0].text == "async", payload + 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__": diff --git a/examples/stories/tasks/server.py b/examples/stories/tasks/server.py index 40548b4b30..70739089e0 100644 --- a/examples/stories/tasks/server.py +++ b/examples/stories/tasks/server.py @@ -1,9 +1,11 @@ """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 passes -through, but a call carrying a `task` field is recorded under a task id and -returned with that id in `_meta`. It also serves the `tasks/*` methods so a -client can poll status and fetch the payload. +`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 @@ -14,9 +16,10 @@ def build_server() -> MCPServer: mcp = MCPServer("tasks-example", extensions=[Tasks()]) - @mcp.tool(description="Echo the input back as plain text.", structured_output=False) - def echo(text: str) -> str: - return text + @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 diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index f754268661..bcc2829975 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -55,7 +55,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.context import ServerMiddleware, ServerRequestContext +from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan @@ -210,9 +210,9 @@ def __init__( configure_logging(self.settings.log_level) self._extensions: list[Extension] = [] - self._extension_interceptor: ServerMiddleware[LifespanResultT] | None = None for extension in extensions or (): - self.add_extension(extension) + self._apply_extension(extension) + self._install_extension_interceptor() @property def name(self) -> str: @@ -254,20 +254,13 @@ def session_manager(self) -> StreamableHTTPSessionManager: """ return self._lowlevel_server.session_manager - def add_extension(self, extension: Extension) -> None: - """Register an opt-in MCP extension (SEP-2133). + def _apply_extension(self, extension: Extension) -> None: + """Apply one opt-in extension's contributions through the public surface. - Applies the extension's contributions through the server's public surface: - its tools and resources are registered, its request methods are wired onto - the low-level server, and its `tools/call` interceptor joins a single - composed `tools/call` middleware. The extension's settings are advertised - under `ServerCapabilities.extensions[extension.identifier]`. - - Args: - extension: The extension to install. - - Raises: - ValueError: If an extension with the same identifier is already registered. + Registers its tools/resources/methods and advertises its settings under + `ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed + at construction, so this is private; the `tools/call` interceptor is + composed once afterwards by `_install_extension_interceptor`. """ if any(e.identifier == extension.identifier for e in self._extensions): raise ValueError(f"Extension {extension.identifier!r} is already registered") @@ -281,16 +274,15 @@ def add_extension(self, extension: Extension) -> None: self._lowlevel_server.add_request_handler(method.method, method.params_type, method.handler) self._lowlevel_server.extensions[extension.identifier] = extension.settings() - self._refresh_extension_interceptor() - def _refresh_extension_interceptor(self) -> None: - """Rebuild the single composed `tools/call` interceptor from all extensions.""" - if self._extension_interceptor is not None: - self._lowlevel_server.middleware.remove(self._extension_interceptor) - self._extension_interceptor = None + def _install_extension_interceptor(self) -> None: + """Compose every extension's `tools/call` interceptor into one middleware. + + Installed only when at least one extension overrides `intercept_tool_call`, + so a server with purely additive extensions adds no middleware. + """ if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): - self._extension_interceptor = compose_tool_call_interceptor(self._extensions) - self._lowlevel_server.middleware.append(self._extension_interceptor) + self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index 45cdfa2635..f3b54f5c67 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -177,13 +177,6 @@ def test_duplicate_extension_identifier_raises() -> None: MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()]) -def test_add_extension_after_construction_rejects_duplicate_identifier() -> None: - """SDK-defined: `add_extension` enforces the same uniqueness as the constructor.""" - server = MCPServer("test", extensions=[_SettingsExt()]) - with pytest.raises(ValueError): - server.add_extension(_SettingsExt()) - - async def test_extension_method_reachable_via_session_send_request() -> None: """SDK-defined: an `Extension` overriding `methods()` wires a new request verb onto the low-level server, reachable through `client.session.send_request`.""" @@ -221,25 +214,22 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None: def test_plain_extension_installs_no_tool_call_interceptor() -> None: - """SDK-defined: an extension that does not override `intercept_tool_call` leaves - `_extension_interceptor` unset and adds no middleware - the composed - interceptor exists only when at least one extension overrides it.""" + """SDK-defined: an extension that does not override `intercept_tool_call` adds no + middleware - the composed interceptor exists only when at least one extension + overrides it.""" baseline = len(MCPServer("test")._lowlevel_server.middleware) server = MCPServer("test", extensions=[_AdditiveExt()]) - assert server._extension_interceptor is None assert len(server._lowlevel_server.middleware) == baseline def test_overriding_extension_installs_one_tool_call_interceptor() -> None: - """SDK-defined: registering an extension that overrides `intercept_tool_call` - composes exactly one middleware and records it as `_extension_interceptor`.""" + """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly + one additional `tools/call` middleware.""" baseline = len(MCPServer("test")._lowlevel_server.middleware) server = MCPServer("test", extensions=[_ReplacingExt()]) - assert server._extension_interceptor is not None assert len(server._lowlevel_server.middleware) == baseline + 1 - assert server._lowlevel_server.middleware[-1] is server._extension_interceptor async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: From 7ca153071b823fe8bd70264b69af7386439c9580 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:36:01 +0200 Subject: [PATCH 07/13] Clarify Tasks opt-in and per-tool task_support scope Make explicit that a plain tools/call is unchanged - only a call carrying a `task` field becomes a task - and document that per-tool gating on the declared `ToolExecution.task_support` is not enforced by this reference extension. --- examples/stories/tasks/README.md | 21 ++++++++++++++------- src/mcp/server/tasks.py | 14 ++++++++++---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index 6a778d29e0..116ccc002b 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -34,13 +34,20 @@ uv run python -m stories.tasks.client --http ## Caveats This is a reference implementation for the extension API, not a production task -runtime. The tool runs to completion inline (so a task is observed as -`completed` immediately), and 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. +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 diff --git a/src/mcp/server/tasks.py b/src/mcp/server/tasks.py index 42b317e092..756cafec6f 100644 --- a/src/mcp/server/tasks.py +++ b/src/mcp/server/tasks.py @@ -10,10 +10,11 @@ This module demonstrates the *interceptive* half of the extension API. A `Tasks` instance: - - overrides `intercept_tool_call` to branch on `params.task`: a plain call - passes through untouched; a task-augmented call still runs the tool, but its - result is recorded under a task id and returned with that id stamped into - `_meta["io.modelcontextprotocol/related-task"]`, and + - overrides `intercept_tool_call` to branch on `params.task`: a call WITHOUT a + `task` field passes through untouched (it is a normal blocking call), so + plain `tools/call` behaviour is unchanged. Only a call the client explicitly + augments with a `task` field is recorded under a task id and returned with + that id stamped into `_meta["io.modelcontextprotocol/related-task"]`, and - overrides `methods` to serve `tasks/get`, `tasks/result`, `tasks/cancel`, and `tasks/list` so a client can poll status and fetch the payload. @@ -24,6 +25,11 @@ - The tool runs to completion inline, so a task is observed as `completed` immediately (no detached/background execution, no TTL eviction). + - Any tool may be task-augmented when the client sends a `task` field; per-tool + gating on the declared `ToolExecution.task_support` + (`forbidden`/`optional`/`required`) is not enforced. A production extension + would reject a `task`-augmented call to a `forbidden` tool and a plain call + to a `required` one. - A task-augmented `tools/call` returns a normal `CallToolResult` (with the task id in `_meta`) rather than the spec's `CreateTaskResult`. The wire schema for `tools/call` only admits `CallToolResult | InputRequiredResult` From cb2c4569461076ffb88d03bc168584bd98a4d55f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 21:36:02 +0200 Subject: [PATCH 08/13] Drop the Tasks extension; defer to a SEP-2663 rewrite The Tasks implementation was built against the 2025-11-25 in-core design still carried (types-only) in mcp_types, not SEP-2663 (the extension that ships in 2026-07-28). They diverge on nearly every wire-observable detail: SEP-2663 makes the server the sole decider (ignoring the legacy params.task), uses the {tasks/get, tasks/update, tasks/cancel} method set (no tasks/list or tasks/result), returns a CreateTaskResult discriminated by resultType: "task" (not a CallToolResult with _meta), advertises {} settings, gates on execution.taskSupport, and renames ttl/pollInterval to ttlMs/pollIntervalMs. Remove the extension, its tests, and its story rather than ship a spec-violating example; restore tasks to the deferred manifest list with a SEP-2663 pointer. The generic Extension API and the Apps reference extension are unaffected and still at 100% coverage. Tasks returns as a separate PR rewritten to SEP-2663 with the conformance tasks-* scenarios wired in. --- docs/migration.md | 13 +- examples/stories/apps/README.md | 1 - examples/stories/manifest.toml | 8 +- examples/stories/tasks/README.md | 67 ++----- examples/stories/tasks/__init__.py | 0 examples/stories/tasks/client.py | 56 ------ examples/stories/tasks/server.py | 28 --- src/mcp/server/tasks.py | 189 ------------------- tests/server/test_tasks.py | 287 ----------------------------- 9 files changed, 20 insertions(+), 629 deletions(-) delete mode 100644 examples/stories/tasks/__init__.py delete mode 100644 examples/stories/tasks/client.py delete mode 100644 examples/stories/tasks/server.py delete mode 100644 src/mcp/server/tasks.py delete mode 100644 tests/server/test_tasks.py diff --git a/docs/migration.md b/docs/migration.md index f748fea2f0..fa911ec0d2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -419,18 +419,13 @@ 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()]) +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`; `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. +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. Clients advertise extension support with the new `Client(extensions=...)` / `ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`. diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index b384a2f5ac..dc180a0d3d 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -37,5 +37,4 @@ uv run python -m stories.apps.client --http ## See also -`tasks/` (the interceptive half of the extension API), `custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index a230c01f35..9816b568b8 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -56,13 +56,6 @@ 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] @@ -157,5 +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 = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index 116ccc002b..d1956d1e33 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,61 +1,24 @@ # tasks -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. - -## 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. +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: 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 -[Tasks — extensions](https://modelcontextprotocol.io/specification/draft/extensions) +[SEP-2663 — Tasks 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), -`custom_methods/` (a non-spec method without an extension), -`middleware/` (the low-level `tools/call` wrapping the interceptor builds on). +`apps/` (the additive half of the extension API). diff --git a/examples/stories/tasks/__init__.py b/examples/stories/tasks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/stories/tasks/client.py b/examples/stories/tasks/client.py deleted file mode 100644 index 11fbfec998..0000000000 --- a/examples/stories/tasks/client.py +++ /dev/null @@ -1,56 +0,0 @@ -"""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) diff --git a/examples/stories/tasks/server.py b/examples/stories/tasks/server.py deleted file mode 100644 index 70739089e0..0000000000 --- a/examples/stories/tasks/server.py +++ /dev/null @@ -1,28 +0,0 @@ -"""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) diff --git a/src/mcp/server/tasks.py b/src/mcp/server/tasks.py deleted file mode 100644 index 756cafec6f..0000000000 --- a/src/mcp/server/tasks.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tasks extension (`io.modelcontextprotocol/tasks`). - -Tasks let a client request *task-augmented* execution of a tool call: instead of -blocking for the `CallToolResult`, the client sends `tools/call` with a `task` -field and immediately gets back a `CreateTaskResult` carrying a task id. It then -polls `tasks/get` for status and `tasks/result` for the payload, and may -`tasks/cancel` or `tasks/list`. Tasks were part of the core spec in 2025-11-25 -and now continue as an extension. See SEP-2133 for the extension framework. - -This module demonstrates the *interceptive* half of the extension API. A `Tasks` -instance: - - - overrides `intercept_tool_call` to branch on `params.task`: a call WITHOUT a - `task` field passes through untouched (it is a normal blocking call), so - plain `tools/call` behaviour is unchanged. Only a call the client explicitly - augments with a `task` field is recorded under a task id and returned with - that id stamped into `_meta["io.modelcontextprotocol/related-task"]`, and - - overrides `methods` to serve `tasks/get`, `tasks/result`, `tasks/cancel`, - and `tasks/list` so a client can poll status and fetch the payload. - - mcp = MCPServer("demo", extensions=[Tasks()]) - -Scope: this is a reference implementation for the extension API, not a -production task runtime. Two deliberate simplifications keep it self-contained: - - - The tool runs to completion inline, so a task is observed as `completed` - immediately (no detached/background execution, no TTL eviction). - - Any tool may be task-augmented when the client sends a `task` field; per-tool - gating on the declared `ToolExecution.task_support` - (`forbidden`/`optional`/`required`) is not enforced. A production extension - would reject a `task`-augmented call to a `forbidden` tool and a plain call - to a `required` one. - - A task-augmented `tools/call` returns a normal `CallToolResult` (with the - task id in `_meta`) rather than the spec's `CreateTaskResult`. The wire - schema for `tools/call` only admits `CallToolResult | InputRequiredResult` - (even at 2026-07-28; see the `TODO(L56)` in `mcp.server.runner`), so - returning `CreateTaskResult` would require extending the methods-layer - validation maps. Driving the lifecycle through the dedicated `tasks/*` - methods stays within the schema while still exercising the interceptor. - -The store is in-memory and per-server. -""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from typing import Any - -import mcp_types as types - -from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.mcpserver.extension import Extension, MethodBinding -from mcp.shared.exceptions import MCPError - -EXTENSION_ID = "io.modelcontextprotocol/tasks" -"""The Tasks extension identifier.""" - -RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task" -"""`_meta` key associating a `CallToolResult` with the task that produced it.""" - -Clock = Callable[[], str] -"""Returns the current time as an ISO-8601 string (injectable for determinism).""" - - -def _fixed_clock() -> str: - return "1970-01-01T00:00:00Z" - - -class TaskStore: - """In-memory record of tasks and their completed payloads.""" - - def __init__(self) -> None: - self._tasks: dict[str, types.Task] = {} - self._results: dict[str, dict[str, Any]] = {} - self._counter = 0 - - def create(self, now: str, ttl: int | None) -> types.Task: - self._counter += 1 - task_id = f"task-{self._counter}" - task = types.Task( - task_id=task_id, - status="working", - created_at=now, - last_updated_at=now, - ttl=ttl, - ) - self._tasks[task_id] = task - return task - - def complete(self, task_id: str, now: str, result: dict[str, Any]) -> None: - task = self._tasks[task_id] - self._tasks[task_id] = task.model_copy(update={"status": "completed", "last_updated_at": now}) - self._results[task_id] = result - - def fail(self, task_id: str, now: str) -> None: - task = self._tasks[task_id] - self._tasks[task_id] = task.model_copy(update={"status": "failed", "last_updated_at": now}) - - def cancel(self, task_id: str, now: str) -> types.Task: - task = self._tasks[task_id] - cancelled = task.model_copy(update={"status": "cancelled", "last_updated_at": now}) - self._tasks[task_id] = cancelled - return cancelled - - def get(self, task_id: str) -> types.Task | None: - return self._tasks.get(task_id) - - def result(self, task_id: str) -> dict[str, Any] | None: - return self._results.get(task_id) - - def list(self) -> list[types.Task]: - return list(self._tasks.values()) - - -class Tasks(Extension): - """The Tasks extension: task-augmented tool execution plus the `tasks/*` methods.""" - - identifier = EXTENSION_ID - - def __init__(self, *, clock: Clock = _fixed_clock) -> None: - self._store = TaskStore() - self._clock = clock - - def settings(self) -> dict[str, Any]: - # Advertise list + cancel support (per ServerTasksCapability). - return {"list": {}, "cancel": {}} - - def methods(self) -> Sequence[MethodBinding]: - return [ - MethodBinding("tasks/get", types.GetTaskRequestParams, self._handle_get), - MethodBinding("tasks/result", types.GetTaskPayloadRequestParams, self._handle_result), - MethodBinding("tasks/cancel", types.CancelTaskRequestParams, self._handle_cancel), - MethodBinding("tasks/list", types.PaginatedRequestParams, self._handle_list), - ] - - async def intercept_tool_call( - self, - params: types.CallToolRequestParams, - ctx: ServerRequestContext[Any, Any], - call_next: CallNext, - ) -> HandlerResult: - if params.task is None: - return await call_next(ctx) - now = self._clock() - task = self._store.create(now, params.task.ttl) - # `call_next` runs the real tool; its already-serialized `CallToolResult` - # dict is what we record and return (with the task id stamped on `_meta`). - result = await call_next(ctx) - payload = result if isinstance(result, dict) else {} - if payload.get("isError"): - self._store.fail(task.task_id, self._clock()) - else: - self._store.complete(task.task_id, self._clock(), payload) - existing_meta: dict[str, Any] = payload.get("_meta") or {} - meta = {**existing_meta, RELATED_TASK_META_KEY: {"taskId": task.task_id}} - return {**payload, "_meta": meta} - - async def _handle_get( - self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskRequestParams - ) -> types.GetTaskResult: - task = self._require(params.task_id) - return types.GetTaskResult.model_validate(task.model_dump(by_alias=True)) - - async def _handle_result( - self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskPayloadRequestParams - ) -> dict[str, Any]: - self._require(params.task_id) - payload = self._store.result(params.task_id) - if payload is None: - raise MCPError(code=types.INVALID_PARAMS, message=f"task {params.task_id!r} has no result") - return payload - - async def _handle_cancel( - self, ctx: ServerRequestContext[Any, Any], params: types.CancelTaskRequestParams - ) -> types.CancelTaskResult: - self._require(params.task_id) - cancelled = self._store.cancel(params.task_id, self._clock()) - return types.CancelTaskResult.model_validate(cancelled.model_dump(by_alias=True)) - - async def _handle_list( - self, ctx: ServerRequestContext[Any, Any], params: types.PaginatedRequestParams - ) -> types.ListTasksResult: - return types.ListTasksResult(tasks=self._store.list()) - - def _require(self, task_id: str) -> types.Task: - task = self._store.get(task_id) - if task is None: - raise MCPError(code=types.INVALID_PARAMS, message=f"unknown task {task_id!r}") - return task diff --git a/tests/server/test_tasks.py b/tests/server/test_tasks.py deleted file mode 100644 index 921dee2dfa..0000000000 --- a/tests/server/test_tasks.py +++ /dev/null @@ -1,287 +0,0 @@ -"""End-to-end tests for the Tasks extension (`io.modelcontextprotocol/tasks`, SEP-2133). - -Tasks is a reference implementation of the *interceptive* half of the extension API: a -task-augmented `tools/call` runs the tool, records the result under a task id, and stamps that -id into `_meta[RELATED_TASK_META_KEY]`; the `tasks/*` methods then poll status and fetch the -payload. The lifecycle verbs are vendor methods, so they go through the `client.session` -escape hatch (`Client` only exposes spec verbs). A fixed `clock` makes timestamps deterministic. -""" - -from typing import Any, cast - -import mcp_types as types -import pytest -from inline_snapshot import snapshot -from mcp_types import INVALID_PARAMS, CallToolResult, TextContent - -from mcp.client.client import Client -from mcp.server.mcpserver import MCPServer -from mcp.server.tasks import RELATED_TASK_META_KEY, Tasks -from mcp.shared.exceptions import MCPError - -pytestmark = pytest.mark.anyio - -FIXED_NOW = "2026-01-01T00:00:00Z" - - -def _server() -> MCPServer: - mcp = MCPServer("demo", extensions=[Tasks(clock=lambda: FIXED_NOW)]) - - @mcp.tool() - def greet(name: str) -> str: - return f"hi {name}" - - @mcp.tool() - def boom() -> str: - raise ValueError("kaboom") - - return mcp - - -def _call_tool_request(name: str, arguments: dict[str, Any], task: types.TaskMetadata | None) -> types.ClientRequest: - request = types.CallToolRequest(params=types.CallToolRequestParams(name=name, arguments=arguments, task=task)) - return cast("types.ClientRequest", request) - - -async def test_plain_tool_call_carries_no_related_task_meta() -> None: - """A `tools/call` with no `task` field passes through the interceptor untouched: SDK-defined.""" - async with Client(_server()) as client: - result = await client.call_tool("greet", {"name": "ada"}) - - assert result == snapshot( - CallToolResult( - content=[TextContent(text="hi ada")], - structured_content={"result": "hi ada"}, - ) - ) - assert result.meta is None - - -async def test_task_augmented_call_runs_tool_and_stamps_task_id() -> None: - """A task-augmented `tools/call` runs the tool and returns its result with the new task id - stamped into `_meta[RELATED_TASK_META_KEY]`: SDK-defined.""" - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - result = await client.session.send_request(request, CallToolResult) - - assert result.content == snapshot([TextContent(text="hi ada")]) - assert result.meta == snapshot({RELATED_TASK_META_KEY: {"taskId": "task-1"}}) - - -async def test_tasks_get_reports_completed_status_and_injected_clock() -> None: - """`tasks/get` returns the task as `completed` with timestamps from the injected clock: SDK-defined.""" - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - get_request = cast( - "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) - ) - task = await client.session.send_request(get_request, types.GetTaskResult) - - assert task == snapshot( - types.GetTaskResult( - task_id="task-1", - status="completed", - created_at=FIXED_NOW, - last_updated_at=FIXED_NOW, - ttl=60, - ) - ) - - -async def test_tasks_get_uses_default_clock_when_none_injected() -> None: - """A `Tasks()` with no injected clock stamps the default `_fixed_clock` epoch timestamp: SDK-defined.""" - mcp = MCPServer("demo", extensions=[Tasks()]) - - @mcp.tool() - def greet(name: str) -> str: - return f"hi {name}" - - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(mcp) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - get_request = cast( - "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) - ) - task = await client.session.send_request(get_request, types.GetTaskResult) - - assert task == snapshot( - types.GetTaskResult( - task_id="task-1", - status="completed", - created_at="1970-01-01T00:00:00Z", - last_updated_at="1970-01-01T00:00:00Z", - ttl=60, - ) - ) - - -async def test_tasks_result_returns_stored_tool_payload() -> None: - """`tasks/result` returns the tool's stored payload, without the related-task `_meta` stamp: SDK-defined.""" - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - result_request = cast( - "types.ClientRequest", - types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)), - ) - payload = await client.session.send_request(result_request, CallToolResult) - - assert payload == snapshot( - CallToolResult( - content=[TextContent(text="hi ada")], - structured_content={"result": "hi ada"}, - ) - ) - assert payload.meta is None - - -async def test_tasks_list_returns_created_task() -> None: - """`tasks/list` returns the tasks recorded by task-augmented calls: SDK-defined.""" - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - await client.session.send_request(request, CallToolResult) - - list_request = cast("types.ClientRequest", types.ListTasksRequest(params=types.PaginatedRequestParams())) - listing = await client.session.send_request(list_request, types.ListTasksResult) - - assert listing == snapshot( - types.ListTasksResult( - tasks=[ - types.Task( - task_id="task-1", - status="completed", - created_at=FIXED_NOW, - last_updated_at=FIXED_NOW, - ttl=60, - ) - ] - ) - ) - - -async def test_tasks_cancel_sets_cancelled_status() -> None: - """`tasks/cancel` transitions the task to `cancelled`: SDK-defined.""" - request = _call_tool_request("greet", {"name": "ada"}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - cancel_request = cast( - "types.ClientRequest", types.CancelTaskRequest(params=types.CancelTaskRequestParams(task_id=task_id)) - ) - cancelled = await client.session.send_request(cancel_request, types.CancelTaskResult) - - assert cancelled == snapshot( - types.CancelTaskResult( - task_id="task-1", - status="cancelled", - created_at=FIXED_NOW, - last_updated_at=FIXED_NOW, - ttl=60, - ) - ) - - -async def test_failing_task_augmented_call_marks_task_failed() -> None: - """A task-augmented call to a tool that raises returns `is_error` and records the task as `failed`, - so a later `tasks/get` reports `failed`: SDK-defined.""" - request = _call_tool_request("boom", {}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - get_request = cast( - "types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)) - ) - task = await client.session.send_request(get_request, types.GetTaskResult) - - assert created.is_error is True - assert created.meta == snapshot({RELATED_TASK_META_KEY: {"taskId": "task-1"}}) - assert task == snapshot( - types.GetTaskResult( - task_id="task-1", - status="failed", - created_at=FIXED_NOW, - last_updated_at=FIXED_NOW, - ttl=60, - ) - ) - - -async def test_tasks_result_on_failed_task_raises_invalid_params() -> None: - """`tasks/result` for a task that exists but stored no payload (a failed task) raises INVALID_PARAMS. - - SDK-defined. - """ - request = _call_tool_request("boom", {}, types.TaskMetadata(ttl=60)) - - async with Client(_server()) as client: - created = await client.session.send_request(request, CallToolResult) - assert created.meta is not None - task_id = created.meta[RELATED_TASK_META_KEY]["taskId"] - - result_request = cast( - "types.ClientRequest", - types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)), - ) - with pytest.raises(MCPError) as exc_info: - await client.session.send_request(result_request, CallToolResult) - - assert exc_info.value.code == INVALID_PARAMS - - -_UNKNOWN_TASK_ID = "does-not-exist" - -_UNKNOWN_ID_CASES: list[tuple[types.ClientRequest, type[types.Result]]] = [ - ( - cast("types.ClientRequest", types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=_UNKNOWN_TASK_ID))), - types.GetTaskResult, - ), - ( - cast( - "types.ClientRequest", - types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=_UNKNOWN_TASK_ID)), - ), - CallToolResult, - ), - ( - cast( - "types.ClientRequest", - types.CancelTaskRequest(params=types.CancelTaskRequestParams(task_id=_UNKNOWN_TASK_ID)), - ), - types.CancelTaskResult, - ), -] - - -@pytest.mark.parametrize( - ("request_", "result_type"), _UNKNOWN_ID_CASES, ids=["tasks/get", "tasks/result", "tasks/cancel"] -) -async def test_unknown_task_id_raises_invalid_params( - request_: types.ClientRequest, result_type: type[types.Result] -) -> None: - """`tasks/get`, `tasks/result`, and `tasks/cancel` reject an unknown task id with INVALID_PARAMS: SDK-defined.""" - async with Client(_server()) as client: - with pytest.raises(MCPError) as exc_info: - await client.session.send_request(request_, result_type) - - assert exc_info.value.code == INVALID_PARAMS From 0f440b1a1f6257f83b7303cff1aa42866c865598 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 22:01:36 +0200 Subject: [PATCH 09/13] Address extension-API review: layering, enforcement, version gating, Apps fixes Framework: - Move the Extension base class from mcp/server/mcpserver/extension.py to mcp/server/extension.py so helper-tier modules (apps.py) and third-party extensions depend on the base, not the composition tier. - Enforce a vendor-prefix/name identifier via __init_subclass__ (and at apply time for per-instance identifiers), failing at class-definition rather than late with AttributeError. - Add MethodBinding.protocol_versions so an extension method can be scoped to specific wire versions; out-of-range requests get METHOD_NOT_FOUND. - Add require_client_extension(ctx, identifier) raising the -32021 missing required client capability error with a requiredCapabilities payload. Apps: - client_supports_apps now checks the client advertised the text/html;profile=mcp-app MIME type, not just the extension key. - Add a visibility kwarg to @apps.tool (_meta.ui.visibility). - Let add_html_resource set csp/permissions/domain/prefers_border on the resource _meta via typed ResourceCsp/ResourcePermissions models. - Fix the meta= double-keyword TypeError by making meta an explicit param merged with the ui entry instead of passing through **tool_kwargs. --- docs/migration.md | 14 ++- src/mcp/server/apps.py | 89 +++++++++++++-- src/mcp/server/{mcpserver => }/extension.py | 69 +++++++++--- src/mcp/server/mcpserver/__init__.py | 6 +- src/mcp/server/mcpserver/server.py | 70 +++++++++++- tests/server/mcpserver/test_extension.py | 119 +++++++++++++++++++- tests/server/test_apps.py | 89 ++++++++++++++- tests/server/test_extensions_capability.py | 2 +- 8 files changed, 416 insertions(+), 42 deletions(-) rename src/mcp/server/{mcpserver => }/extension.py (59%) diff --git a/docs/migration.md b/docs/migration.md index fa911ec0d2..c19ed2f31c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -411,9 +411,10 @@ For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may `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` +(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`). 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: ```python @@ -425,7 +426,14 @@ 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. +`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 +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`. diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index 5e49896783..e9e9786f27 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -4,7 +4,7 @@ `_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served with the `text/html;profile=mcp-app` MIME type) that the host renders in a sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps -and SEP-2133 for the extension framework. +and the ext-apps spec for the wire format, and SEP-2133 for the extension framework. This is a self-contained, additive `Extension`: it contributes tools and resources and advertises the capability, but does not intercept any core method. @@ -22,17 +22,22 @@ def get_time(ctx: Context) -> str: Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should still return meaningful text for clients that did not negotiate Apps. Use -`client_supports_apps(ctx)` to branch on the client's advertised support. +`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK +keeps Apps in-core under `mcp.server.apps` rather than a separate package; the +TypeScript and C# SDKs ship it as a standalone package.) """ from __future__ import annotations from collections.abc import Callable, Sequence -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, ResourceBinding, ToolBinding from mcp.server.mcpserver.context import Context -from mcp.server.mcpserver.extension import Extension, ResourceBinding, ToolBinding from mcp.server.mcpserver.resources import TextResource EXTENSION_ID = "io.modelcontextprotocol/ui" @@ -41,9 +46,34 @@ def get_time(ctx: Context) -> str: APP_MIME_TYPE = "text/html;profile=mcp-app" """MIME type for a `ui://` app resource.""" +Visibility = Literal["model", "app"] +"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`).""" + _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) +class ResourcePermissions(BaseModel): + """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + camera: dict[str, Any] | None = None + microphone: dict[str, Any] | None = None + geolocation: dict[str, Any] | None = None + clipboard_write: dict[str, Any] | None = None + + +class ResourceCsp(BaseModel): + """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + connect_domains: list[str] | None = None + resource_domains: list[str] | None = None + frame_domains: list[str] | None = None + base_uri_domains: list[str] | None = None + + class Apps(Extension): """The MCP Apps extension: bind tools to `ui://` UI resources. @@ -58,23 +88,36 @@ def __init__(self) -> None: self._tools: list[ToolBinding] = [] self._resources: list[ResourceBinding] = [] - def tool(self, *, resource_uri: str, **tool_kwargs: Any) -> Callable[[_CallableT], _CallableT]: + def tool( + self, + *, + resource_uri: str, + visibility: Sequence[Visibility] | None = None, + meta: dict[str, Any] | None = None, + **tool_kwargs: Any, + ) -> Callable[[_CallableT], _CallableT]: """Decorator registering a tool bound to a `ui://` resource. - Stamps `_meta.ui.resourceUri` on the tool. `tool_kwargs` are forwarded to - `MCPServer.add_tool` (name, title, description, annotations, ...). + Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the + tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title, + description, annotations, ...); pass `meta=` to merge extra `_meta` keys + alongside the `ui` entry. Args: resource_uri: The `ui://` URI of the UI resource this tool renders. + visibility: Where the tool is surfaced (`["model", "app"]`). + meta: Additional `_meta` keys to merge with the `ui` entry. Raises: ValueError: If `resource_uri` does not use the `ui://` scheme. """ _require_ui_scheme(resource_uri) + ui: dict[str, Any] = {"resourceUri": resource_uri} + if visibility is not None: + ui["visibility"] = list(visibility) def decorator(fn: _CallableT) -> _CallableT: - meta = {"ui": {"resourceUri": resource_uri}} - self._tools.append(ToolBinding(fn=fn, meta=meta, kwargs=tool_kwargs)) + self._tools.append(ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)) return fn return decorator @@ -87,9 +130,16 @@ def add_html_resource( name: str | None = None, title: str | None = None, description: str | None = None, + csp: ResourceCsp | None = None, + permissions: ResourcePermissions | None = None, + domain: str | None = None, + prefers_border: bool | None = None, ) -> None: """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`. + `csp`, `permissions`, `domain`, and `prefers_border` populate the + resource's `_meta.ui` per the ext-apps spec. + Args: uri: The `ui://` URI; a tool references it via `resource_uri`. html: The HTML document the host renders. @@ -98,12 +148,22 @@ def add_html_resource( ValueError: If `uri` does not use the `ui://` scheme. """ _require_ui_scheme(uri) + ui: dict[str, Any] = {} + if csp is not None: + ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) + if permissions is not None: + ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True) + if domain is not None: + ui["domain"] = domain + if prefers_border is not None: + ui["prefersBorder"] = prefers_border resource = TextResource( uri=uri, name=name or uri, title=title, description=description, mime_type=APP_MIME_TYPE, + meta={"ui": ui} if ui else None, text=html, ) self._resources.append(ResourceBinding(resource=resource)) @@ -118,12 +178,17 @@ def resources(self) -> Sequence[ResourceBinding]: def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool: """Whether the connected client negotiated MCP Apps support. - Returns `False` when the client did not advertise the extension (or sent no - capabilities), so a UI-enabled tool can fall back to text-only output. + Returns `True` only when the client advertised the extension AND listed the + `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool + can fall back to text-only output otherwise. """ capabilities = _client_capabilities(ctx) extensions = capabilities.extensions if capabilities else None - return bool(extensions and EXTENSION_ID in extensions) + settings = extensions.get(EXTENSION_ID) if extensions else None + if settings is None: + return False + mime_types = settings.get("mimeTypes") + return mime_types is None or APP_MIME_TYPE in mime_types def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: diff --git a/src/mcp/server/mcpserver/extension.py b/src/mcp/server/extension.py similarity index 59% rename from src/mcp/server/mcpserver/extension.py rename to src/mcp/server/extension.py index 0a4ed09359..0a151d50c9 100644 --- a/src/mcp/server/mcpserver/extension.py +++ b/src/mcp/server/extension.py @@ -1,32 +1,55 @@ -"""Pluggable extension interface for `MCPServer` (SEP-2133). +"""Pluggable extension interface for MCP servers (SEP-2133). An extension is a self-contained, opt-in bundle of MCP behaviour, identified by -a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed at -construction - `MCPServer(..., extensions=[Apps(), Tasks(store)])` - and the -server applies a *closed* set of contribution kinds: tools, resources, new -request methods, and one `tools/call` interceptor. The server never hands itself -to an extension; the extension declares what it adds, and the server consumes it. - -The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class -whose methods have sensible defaults, so an extension overrides only what it -needs. A purely additive extension (Apps) overrides `tools`/`resources`; an -interceptive one (Tasks) overrides `methods`/`intercept_tool_call`. +a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed to +`MCPServer(extensions=[...])`, and the server applies a *closed* set of +contribution kinds: tools, resources, new request methods, and one `tools/call` +interceptor. The server never hands itself to an extension; the extension +declares what it adds, and the server consumes it. + +The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class whose +methods have sensible defaults, so an extension overrides only what it needs. A +purely additive extension (Apps) overrides `tools`/`resources`; an interceptive +one overrides `methods`/`intercept_tool_call`. + +This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so that +third-party extensions and helper modules like `mcp.server.apps` depend only on +the base class, never on the composition tier that consumes it. """ from __future__ import annotations +import re from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from mcp_types import CallToolRequestParams from pydantic import BaseModel from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext -from mcp.server.mcpserver.resources import Resource + +if TYPE_CHECKING: + from mcp.server.mcpserver.resources import Resource RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] +# Extension identifiers follow the `_meta` key grammar: a mandatory reverse-DNS +# prefix, a slash, then the extension name (SEP-2133 / the spec's _meta rules). +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9.-]+/[A-Za-z0-9._-]+$") + + +def validate_extension_identifier(identifier: Any, *, owner: str) -> None: + """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. + + SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. + """ + if not isinstance(identifier, str) or not _IDENTIFIER_RE.match(identifier): + raise TypeError( + f"{owner}.identifier must be a `vendor-prefix/name` string " + f"(reverse-DNS prefix required), got {identifier!r}" + ) + @dataclass(frozen=True) class ToolBinding: @@ -49,12 +72,17 @@ class MethodBinding: """A new request method an extension serves, e.g. `tasks/get`. `params_type` validates incoming params before `handler` runs; it should - subclass `RequestParams` so `_meta` parses uniformly. + subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, + when set, restricts the method to those wire versions - a request for the + method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the + spec's `(method, version)` boundary table. `None` (the default) admits the + method at every version. """ method: str params_type: type[BaseModel] handler: RequestHandler + protocol_versions: frozenset[str] | None = None class Extension: @@ -62,12 +90,23 @@ class Extension: Subclass and set `identifier`, then override the contribution methods that apply. Every method has a default, so a minimal extension overrides nothing - but `identifier` and one of `tools`/`resources`/`methods`. + but `identifier` and one of `tools`/`resources`/`methods`. `identifier` is + enforced at subclass-definition time. """ #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. identifier: str + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Validate a class-level `identifier` at definition time. A subclass may + # instead assign `identifier` in `__init__` (per-instance ids); that case + # is validated when the extension is applied, since no class attribute + # exists to inspect here. + identifier = cls.__dict__.get("identifier") + if identifier is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + def settings(self) -> dict[str, Any]: """Per-extension settings advertised at `capabilities.extensions[identifier]`. diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 832358546e..7a8da42fef 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -2,10 +2,11 @@ from mcp_types import Icon +from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding + from .context import Context -from .extension import Extension, MethodBinding, ResourceBinding, ToolBinding from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity -from .server import MCPServer +from .server import MCPServer, require_client_extension from .utilities.types import Audio, Image __all__ = [ @@ -18,6 +19,7 @@ "ToolBinding", "ResourceBinding", "MethodBinding", + "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 0fe0ace8cb..988159c2af 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -13,10 +13,13 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, Annotations, BlobResourceContents, CallToolRequestParams, CallToolResult, + ClientCapabilities, CompleteRequestParams, CompleteResult, Completion, @@ -28,6 +31,7 @@ ListResourcesResult, ListResourceTemplatesResult, ListToolsResult, + MissingRequiredClientCapabilityErrorData, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, @@ -54,13 +58,19 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.context import ServerRequestContext +from mcp.server.context import HandlerResult, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + RequestHandler, + compose_tool_call_interceptor, + validate_extension_identifier, +) from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError -from mcp.server.mcpserver.extension import Extension, compose_tool_call_interceptor from mcp.server.mcpserver.prompts import Prompt, PromptManager from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, @@ -270,8 +280,10 @@ def _apply_extension(self, extension: Extension) -> None: at construction, so this is private; the `tools/call` interceptor is composed once afterwards by `_install_extension_interceptor`. """ - if any(e.identifier == extension.identifier for e in self._extensions): - raise ValueError(f"Extension {extension.identifier!r} is already registered") + identifier = getattr(extension, "identifier", None) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if any(e.identifier == identifier for e in self._extensions): + raise ValueError(f"Extension {identifier!r} is already registered") self._extensions.append(extension) for tool in extension.tools(): @@ -279,7 +291,8 @@ def _apply_extension(self, extension: Extension) -> None: for resource in extension.resources(): self.add_resource(resource.resource) for method in extension.methods(): - self._lowlevel_server.add_request_handler(method.method, method.params_type, method.handler) + handler = _version_gated(method) if method.protocol_versions is not None else method.handler + self._lowlevel_server.add_request_handler(method.method, method.params_type, handler) self._lowlevel_server.extensions[extension.identifier] = extension.settings() @@ -1189,3 +1202,50 @@ async def get_prompt( except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e + + +def _version_gated(method: MethodBinding) -> RequestHandler: + """Wrap a method handler so a request at a disallowed protocol version is rejected. + + The low-level `_request_handlers` dict is keyed by method only, so per-version + scoping is enforced here rather than at the runner's boundary table. + """ + versions = method.protocol_versions + assert versions is not None + + async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResult: + if ctx.protocol_version not in versions: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method.method) + return await method.handler(ctx, params) + + return gated + + +def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: str) -> None: + """Assert the connected client declared support for `identifier`. + + Call this from an extension's handler or `intercept_tool_call` before + offering extension-specific behaviour. Raises `MCPError` with the + `-32021` (missing required client capability) code and a + `requiredCapabilities` payload when the client did not declare the + extension, per SEP-2133. + + Args: + ctx: The current request context. + identifier: The extension identifier the client must have declared. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client + did not advertise `identifier`. + """ + client_params = ctx.session.client_params + declared = client_params.capabilities.extensions if client_params else None + if not declared or identifier not in declared: + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities(extensions={identifier: {}}) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare required extension {identifier!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index f3b54f5c67..0884e63a04 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -11,19 +11,25 @@ import mcp_types as types import pytest from inline_snapshot import snapshot -from mcp_types import CallToolResult, TextContent +from mcp_types import ( + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + CallToolResult, + TextContent, +) from mcp.client.client import Client from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.extension import ( +from mcp.server.extension import ( Extension, MethodBinding, ResourceBinding, ToolBinding, compose_tool_call_interceptor, ) +from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource +from mcp.shared.exceptions import MCPError pytestmark = pytest.mark.anyio @@ -282,3 +288,110 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: result = await middleware(ctx, call_next) assert result is sentinel + + +def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: + """SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the + subclass is defined (a bare name with no prefix is a TypeError).""" + with pytest.raises(TypeError): + type("_BadExt", (Extension,), {"identifier": "noprefix"}) + + +def test_extension_without_identifier_is_rejected_at_registration() -> None: + """SDK-defined: a subclass that never sets `identifier` (neither class-level nor in + `__init__`) is rejected when the server applies it.""" + + class _NoIdExt(Extension): + pass + + with pytest.raises(TypeError): + MCPServer("test", extensions=[_NoIdExt()]) + + +class _VersionPinnedParams(types.RequestParams): + pass + + +class _VersionPinnedResult(types.Result): + ok: bool + + +class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]): + method: Literal["com.example/pinned"] = "com.example/pinned" + params: _VersionPinnedParams + + +class _VersionPinnedExt(Extension): + """A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`.""" + + identifier = "com.example/pinned" + + def methods(self): + async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult: + return _VersionPinnedResult(ok=True) + + return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))] + + +async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: + """SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version + in the set.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="2026-07-28") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert result == snapshot(_VersionPinnedResult(ok=True)) + + +async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: + """SDK-defined: the same method at a version outside `protocol_versions` is rejected with + METHOD_NOT_FOUND, mirroring the spec's per-version boundary.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="legacy") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert exc_info.value.code == METHOD_NOT_FOUND + + +_NEEDS_EXT = "com.example/needed" + + +class _RequiresExt(Extension): + """A tool that requires the client to have declared `com.example/needed`.""" + + identifier = _NEEDS_EXT + + def tools(self): + def guarded(ctx: Context) -> str: + require_client_extension(ctx.request_context, _NEEDS_EXT) + return "ok" + + return [ToolBinding(fn=guarded)] + + +async def test_require_client_extension_passes_when_client_declared_it() -> None: + """SDK-defined: `require_client_extension` is a no-op when the client advertised the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server, extensions={_NEEDS_EXT: {}}) as client: + result = await client.call_tool("guarded", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + + +async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None: + """SDK-defined: `require_client_extension` raises the -32021 missing-required-capability + error when the client did not advertise the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("guarded", {}) + + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}}) diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 63647f7c86..4c48c0246c 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -14,7 +14,14 @@ from mcp.client.client import Client from mcp.server import Server, ServerRequestContext -from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.apps import ( + APP_MIME_TYPE, + EXTENSION_ID, + Apps, + ResourceCsp, + ResourcePermissions, + client_supports_apps, +) from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -133,3 +140,83 @@ def test_add_html_resource_rejects_non_ui_resource_uri() -> None: apps = Apps() with pytest.raises(ValueError): apps.add_html_resource("https://example.com/app.html", "x") + + +def _widget() -> str: + """A UI-bound tool body (shared so its one covered call serves both meta tests).""" + return "x" + + +async def test_apps_tool_stamps_visibility_when_given() -> None: + """SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`.""" + apps = Apps() + apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget) + + async with Client(MCPServer("v", extensions=[apps])) as client: + result = await client.list_tools() + called = await client.call_tool("_widget", {}) + + assert result.tools[0].meta == snapshot({"ui": {"resourceUri": "ui://v/app.html", "visibility": ["app"]}}) + assert called.content == snapshot([TextContent(text="x")]) + + +async def test_apps_tool_merges_extra_meta_alongside_ui() -> None: + """SDK-defined: `@apps.tool(meta=...)` merges extra `_meta` keys with the `ui` entry + (previously a `meta=` argument raised a duplicate-keyword TypeError).""" + apps = Apps() + apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget) + + async with Client(MCPServer("m", extensions=[apps])) as client: + result = await client.list_tools() + + assert result.tools[0].meta == snapshot({"com.example/k": 1, "ui": {"resourceUri": "ui://m/app.html"}}) + + +async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() -> None: + """SDK-defined: `csp`/`permissions` populate the resource's `_meta.ui` per ext-apps.""" + apps = Apps() + apps.add_html_resource( + "ui://r/app.html", + "r", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(camera={}), + domain="r.example.com", + prefers_border=True, + ) + + async with Client(MCPServer("r", extensions=[apps])) as client: + result = await client.read_resource("ui://r/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].meta == snapshot( + { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"camera": {}}, + "domain": "r.example.com", + "prefersBorder": True, + } + } + ) + + +async def test_client_supports_apps_false_when_mime_type_not_offered() -> None: + """SDK-defined: a client advertising the extension but NOT the + `text/html;profile=mcp-app` MIME type does not count as Apps-capable.""" + observed: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + observed.append(client_supports_apps(ctx)) + return CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}) as client: + await client.call_tool("probe", {}) + + assert observed == [False] diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py index 3b7f689782..90f24be2bc 100644 --- a/tests/server/test_extensions_capability.py +++ b/tests/server/test_extensions_capability.py @@ -15,8 +15,8 @@ from mcp.client.client import Client from mcp.server import Server, ServerRequestContext +from mcp.server.extension import Extension from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.extension import Extension pytestmark = pytest.mark.anyio From bb9f7936331382355e7fa3a6fc49935685bc7148 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:02:27 +0000 Subject: [PATCH 10/13] Address extension review: identifier grammar, additive-only methods, Apps checks - Validate extension identifiers against the spec's _meta key grammar (per-label structure, fullmatch so a trailing newline cannot pass) - Reject MethodBindings that name spec-defined request methods, collide with an already-registered handler, or pin an empty version set; the runner's per-version surface gate would never route those anyway - client_supports_apps requires mimeTypes to list text/html;profile=mcp-app, matching the reference implementation; a missing key means unsupported - Require every @apps.tool resource_uri to resolve to a resource registered on the Apps instance, failing at construction instead of 404ing on resources/read; add Apps.add_resource for pre-built ui:// resources - Document the new construction-time errors in the migration guide --- docs/migration.md | 24 ++++-- src/mcp/server/apps.py | 57 ++++++++++---- src/mcp/server/extension.py | 40 ++++++++-- src/mcp/server/mcpserver/server.py | 5 ++ tests/server/mcpserver/test_extension.py | 98 ++++++++++++++++++++++-- tests/server/test_apps.py | 92 +++++++++++++++------- 6 files changed, 257 insertions(+), 59 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index c19ed2f31c..d94db1f60b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -414,8 +414,9 @@ 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: +`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level +`identifier` is validated when the subclass is defined, one assigned in `__init__` when +the extension is registered. Pass instances at construction: ```python from mcp.server.mcpserver import MCPServer @@ -426,11 +427,20 @@ 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). - -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 +`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only +when the client's ui-extension settings list the `text/html;profile=mcp-app` +MIME type, per the Apps spec's required `mimeTypes` field. Every +`@apps.tool(resource_uri=...)` must have a matching resource registered on the +same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a +pre-built `Resource`); a tool bound to an unregistered URI raises at +`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime. + +Extension methods are strictly additive: a `MethodBinding` cannot name a +spec-defined request method, and registering one whose method collides with +another handler raises at construction. A `MethodBinding` may set +`protocol_versions` to scope an extension method to specific wire versions +(`frozenset()` is rejected — use `None` to admit every version); 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. diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index e9e9786f27..b9278fde30 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -38,7 +38,7 @@ def get_time(ctx: Context) -> str: from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, ResourceBinding, ToolBinding from mcp.server.mcpserver.context import Context -from mcp.server.mcpserver.resources import TextResource +from mcp.server.mcpserver.resources import Resource, TextResource EXTENSION_ID = "io.modelcontextprotocol/ui" """The MCP Apps extension identifier (the shipped TS/C# constant).""" @@ -85,7 +85,7 @@ class Apps(Extension): identifier = EXTENSION_ID def __init__(self) -> None: - self._tools: list[ToolBinding] = [] + self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri) self._resources: list[ResourceBinding] = [] def tool( @@ -117,7 +117,8 @@ def tool( ui["visibility"] = list(visibility) def decorator(fn: _CallableT) -> _CallableT: - self._tools.append(ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)) + binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs) + self._tools.append((binding, resource_uri)) return fn return decorator @@ -147,7 +148,6 @@ def add_html_resource( Raises: ValueError: If `uri` does not use the `ui://` scheme. """ - _require_ui_scheme(uri) ui: dict[str, Any] = {} if csp is not None: ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) @@ -157,19 +157,48 @@ def add_html_resource( ui["domain"] = domain if prefers_border is not None: ui["prefersBorder"] = prefers_border - resource = TextResource( - uri=uri, - name=name or uri, - title=title, - description=description, - mime_type=APP_MIME_TYPE, - meta={"ui": ui} if ui else None, - text=html, + self.add_resource( + TextResource( + uri=uri, + name=name or uri, + title=title, + description=description, + mime_type=APP_MIME_TYPE, + meta={"ui": ui} if ui else None, + text=html, + ) ) + + def add_resource(self, resource: Resource) -> None: + """Register a pre-built `ui://` resource. + + The escape hatch for resources `add_html_resource` cannot express (e.g. a + `FileResource` serving HTML from disk). The resource should carry the + `text/html;profile=mcp-app` MIME type for hosts to render it. + + Raises: + ValueError: If the resource URI does not use the `ui://` scheme. + """ + _require_ui_scheme(resource.uri) self._resources.append(ResourceBinding(resource=resource)) def tools(self) -> Sequence[ToolBinding]: - return self._tools + """The bound tools. + + Raises: + ValueError: If a tool's `resource_uri` has no matching resource + registered on this instance — a tool advertising a + `_meta.ui.resourceUri` that 404s on `resources/read` is a + misconfiguration, caught when the server consumes the extension. + """ + registered = {binding.resource.uri for binding in self._resources} + for tool, uri in self._tools: + if uri not in registered: + raise ValueError( + f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + return [tool for tool, _ in self._tools] def resources(self) -> Sequence[ResourceBinding]: return self._resources @@ -188,7 +217,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> if settings is None: return False mime_types = settings.get("mimeTypes") - return mime_types is None or APP_MIME_TYPE in mime_types + return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index 0a151d50c9..e045e6f29d 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -12,9 +12,9 @@ purely additive extension (Apps) overrides `tools`/`resources`; an interceptive one overrides `methods`/`intercept_tool_call`. -This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so that -third-party extensions and helper modules like `mcp.server.apps` depend only on -the base class, never on the composition tier that consumes it. +This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so the +base class itself never drags in the composition tier that consumes it; +extensions remain importable without constructing an `MCPServer`. """ from __future__ import annotations @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any from mcp_types import CallToolRequestParams +from mcp_types.methods import SPEC_CLIENT_METHODS from pydantic import BaseModel from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext @@ -34,9 +35,13 @@ RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] -# Extension identifiers follow the `_meta` key grammar: a mandatory reverse-DNS -# prefix, a slash, then the extension name (SEP-2133 / the spec's _meta rules). -_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9.-]+/[A-Za-z0-9._-]+$") +# Extension identifiers follow the `_meta` key grammar with a mandatory prefix +# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a +# letter and ending with a letter or digit (hyphens interior), then `/`, then a +# name that starts and ends alphanumeric (`.`/`_`/`-` interior). +_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" +_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" +_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") def validate_extension_identifier(identifier: Any, *, owner: str) -> None: @@ -44,7 +49,7 @@ def validate_extension_identifier(identifier: Any, *, owner: str) -> None: SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. """ - if not isinstance(identifier, str) or not _IDENTIFIER_RE.match(identifier): + if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): raise TypeError( f"{owner}.identifier must be a `vendor-prefix/name` string " f"(reverse-DNS prefix required), got {identifier!r}" @@ -77,6 +82,15 @@ class MethodBinding: method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the spec's `(method, version)` boundary table. `None` (the default) admits the method at every version. + + Extension methods are additive: `method` must not name a spec-defined + request method (`tools/list`, `completion/complete`, ...) — those handlers + belong to the server, and an extension binding one would silently shadow or + be shadowed by it. Both constraints are enforced at construction. To + re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel` + for legacy clients), use the lowlevel `Server.add_request_handler` API + instead — the runner's per-version surface gate would never route such a + method to an extension handler anyway. """ method: str @@ -84,6 +98,18 @@ class MethodBinding: handler: RequestHandler protocol_versions: frozenset[str] | None = None + def __post_init__(self) -> None: + if self.method in SPEC_CLIENT_METHODS: + raise ValueError( + f"MethodBinding cannot bind spec method {self.method!r}; extension methods are " + "additive — use Extension.intercept_tool_call or Server.middleware to wrap core behaviour" + ) + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError( + f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + class Extension: """Base class for an opt-in MCP extension. Override only the methods you need. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 988159c2af..33348c0838 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -291,6 +291,11 @@ def _apply_extension(self, extension: Extension) -> None: for resource in extension.resources(): self.add_resource(resource.resource) for method in extension.methods(): + if self._lowlevel_server.get_request_handler(method.method) is not None: + raise ValueError( + f"Extension {identifier!r} binds method {method.method!r}, which is already " + "registered; extension methods are additive and cannot replace another handler" + ) handler = _version_gated(method) if method.protocol_versions is not None else method.handler self._lowlevel_server.add_request_handler(method.method, method.params_type, handler) diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index 0884e63a04..e2ec366b29 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -26,6 +26,7 @@ ResourceBinding, ToolBinding, compose_tool_call_interceptor, + validate_extension_identifier, ) from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource @@ -74,16 +75,18 @@ class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]): params: _PingParams +async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: + """The shared `com.example/ping` handler (dispatched by the reachability test).""" + return _PingResult(pong=True) + + class _MethodExt(Extension): """Override `methods()` to serve a new vendor request verb.""" identifier = "com.example/method" - def methods(self): - async def handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: - return _PingResult(pong=True) - - return [MethodBinding("com.example/ping", _PingParams, handler)] + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] class _ReplacingExt(Extension): @@ -356,6 +359,91 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) assert exc_info.value.code == METHOD_NOT_FOUND + assert exc_info.value.error.data == "com.example/pinned" + + +@pytest.mark.parametrize( + "identifier", + [ + "io.modelcontextprotocol/ui", + "com.example/my_ext", + "com.x-y.z2/n.a-b_c", + "example/x", + "a/b", + "com.example/9start", + ], +) +def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: + """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end, + hyphens interior), a slash, then a name that starts and ends alphanumeric.""" + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize( + "identifier", + [ + "noprefix", + "-foo/bar", + ".leading/x", + "a..b/x", + "foo-/x", + "9foo/x", + "foo/-bar", + "foo/bar-", + "foo/", + "/bar", + "foo/ba r", + "io.modelcontextprotocol/ui\n", + "", + None, + 42, + ], +) +def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: + """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels) + and malformed names are rejected, as are non-strings.""" + with pytest.raises(TypeError): + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize("method", ["tools/list", "completion/complete"]) +def test_method_binding_rejects_spec_methods(method: str) -> None: + """SDK-defined: extension methods are additive — binding a spec-defined request method + would silently shadow (or be shadowed by) the server's own handler, so it is rejected + when the binding is constructed.""" + with pytest.raises(ValueError): + MethodBinding(method, _PingParams, _pong_handler) + + +def test_method_binding_rejects_empty_protocol_versions() -> None: + """SDK-defined: an empty `protocol_versions` set would make the method unreachable at + every version; `None` is the universal-version spelling.""" + with pytest.raises(ValueError) as exc_info: + MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset()) + assert str(exc_info.value) == snapshot( + "MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class _OtherMethodExt(Extension): + """A second extension binding the same verb as `_MethodExt`.""" + + identifier = "com.example/other-method" + + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] + + +def test_colliding_extension_methods_are_rejected_at_registration() -> None: + """SDK-defined: two extensions binding the same method would silently last-write-win; + the collision is rejected when the second extension is applied.""" + with pytest.raises(ValueError) as exc_info: + MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()]) + assert str(exc_info.value) == snapshot( + "Extension 'com.example/other-method' binds method 'com.example/ping', which is already " + "registered; extension methods are additive and cannot replace another handler" + ) _NEEDS_EXT = "com.example/needed" diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 4c48c0246c..d280767ccf 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -7,6 +7,8 @@ validation). """ +from typing import Any + import mcp_types as types import pytest from inline_snapshot import snapshot @@ -24,6 +26,7 @@ ) from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import TextResource pytestmark = pytest.mark.anyio @@ -101,9 +104,12 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")]) -async def test_client_supports_apps_reads_lowlevel_request_context() -> None: - """SDK-defined: `client_supports_apps` accepts a lowlevel `ServerRequestContext` too, - reading the client's advertised extensions off `session.client_params`.""" +async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool: + """Run one probe `tools/call` and report what `client_supports_apps` saw server-side. + + Exercises the lowlevel `ServerRequestContext` form, which reads the client's + advertised extensions off `session.client_params`. + """ observed: list[bool] = [] async def list_tools( @@ -117,13 +123,29 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara return CallToolResult(content=[TextContent(text="ok")]) server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool) - - async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: - await supports.call_tool("probe", {}) - async with Client(server) as plain: - await plain.call_tool("probe", {}) - - assert observed == [True, False] + async with Client(server, extensions=extensions) as client: + await client.call_tool("probe", {}) + return observed[0] + + +@pytest.mark.parametrize( + ("extensions", "expected"), + [ + pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"), + pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"), + pytest.param(None, False, id="extension-not-declared"), + pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"), + pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"), + ], +) +async def test_client_supports_apps_from_lowlevel_request_context( + extensions: dict[str, dict[str, Any]] | None, expected: bool +) -> None: + """ext-apps: `client_supports_apps` is `True` only when the client declared the ui + extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a + required field, so its absence means unsupported (the reference SDK's check is + `uiCap?.mimeTypes?.includes(...)`).""" + assert await _observed_client_supports_apps(extensions) is expected def test_apps_tool_rejects_non_ui_resource_uri() -> None: @@ -151,6 +173,7 @@ async def test_apps_tool_stamps_visibility_when_given() -> None: """SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`.""" apps = Apps() apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget) + apps.add_html_resource("ui://v/app.html", "v") async with Client(MCPServer("v", extensions=[apps])) as client: result = await client.list_tools() @@ -165,6 +188,7 @@ async def test_apps_tool_merges_extra_meta_alongside_ui() -> None: (previously a `meta=` argument raised a duplicate-keyword TypeError).""" apps = Apps() apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget) + apps.add_html_resource("ui://m/app.html", "m") async with Client(MCPServer("m", extensions=[apps])) as client: result = await client.list_tools() @@ -200,23 +224,39 @@ async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() - ) -async def test_client_supports_apps_false_when_mime_type_not_offered() -> None: - """SDK-defined: a client advertising the extension but NOT the - `text/html;profile=mcp-app` MIME type does not count as Apps-capable.""" - observed: list[bool] = [] +def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None: + """SDK-defined: a tool whose `resource_uri` has no matching registered resource would + advertise a `_meta.ui.resourceUri` that 404s on `resources/read`; the misconfiguration + is rejected when the server consumes the extension.""" + apps = Apps() + apps.tool(resource_uri="ui://missing/app.html")(_widget) - async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: - assert params.name == "probe" - observed.append(client_supports_apps(ctx)) - return CallToolResult(content=[]) + with pytest.raises(ValueError) as exc_info: + MCPServer("broken", extensions=[apps]) + assert str(exc_info.value) == snapshot( + "Apps tool '_widget' binds resource_uri 'ui://missing/app.html', but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) - async def list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) - server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools) - async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}) as client: - await client.call_tool("probe", {}) +async def test_add_resource_registers_a_prebuilt_ui_resource() -> None: + """SDK-defined: `add_resource` is the escape hatch for pre-built `ui://` resources + that `add_html_resource` cannot express; it satisfies a tool's `resource_uri` binding.""" + apps = Apps() + apps.tool(resource_uri="ui://prebuilt/app.html")(_widget) + apps.add_resource( + TextResource(uri="ui://prebuilt/app.html", name="prebuilt", mime_type=APP_MIME_TYPE, text="p") + ) - assert observed == [False] + async with Client(MCPServer("p", extensions=[apps])) as client: + result = await client.read_resource("ui://prebuilt/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "p" + + +def test_add_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_resource` accepts only `ui://` URIs, like the other registrars.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x")) From efd373d25139525a3cbbc57a8dab66aa31f30c68 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:54:48 +0000 Subject: [PATCH 11/13] Add extensions and MCP Apps docs, an extensions story, and Apps polish - docs/advanced/extensions.md: using and writing extensions (identifier grammar, contributions, vendor methods, version pinning, the tools/call interceptor, client-side declaration), backed by runnable docs_src examples with tests proving every claim - docs/advanced/apps.md: the two-part Apps model, graceful degradation and the meaningful-content rule, CSP/permissions field tables, visibility semantics, the construction-time checks, and the add_resource escape hatch - examples/stories/extensions: a custom extension end to end (settings entry, contributed tool, vendor method gated on the client declaring the extension back) - Apps.tool() rejects a caller-supplied 'ui' meta key instead of silently clobbering it; add_resource defaults the app MIME type and rejects an explicit mismatch; resource _meta.ui pinned on both list entry and read content item --- docs/advanced/apps.md | 176 ++++++++++++++++++++++++ docs/advanced/extensions.md | 173 +++++++++++++++++++++++ docs_src/apps/__init__.py | 0 docs_src/apps/tutorial001.py | 30 ++++ docs_src/apps/tutorial002.py | 25 ++++ docs_src/extensions/__init__.py | 0 docs_src/extensions/tutorial001.py | 25 ++++ docs_src/extensions/tutorial002.py | 43 ++++++ docs_src/extensions/tutorial003.py | 34 +++++ examples/stories/extensions/README.md | 41 ++++++ examples/stories/extensions/__init__.py | 0 examples/stories/extensions/client.py | 54 ++++++++ examples/stories/extensions/server.py | 63 +++++++++ examples/stories/manifest.toml | 7 + mkdocs.yml | 2 + src/mcp/server/apps.py | 18 ++- tests/docs_src/test_apps.py | 78 +++++++++++ tests/docs_src/test_extensions.py | 75 ++++++++++ tests/server/test_apps.py | 44 +++++- 19 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 docs/advanced/apps.md create mode 100644 docs/advanced/extensions.md create mode 100644 docs_src/apps/__init__.py create mode 100644 docs_src/apps/tutorial001.py create mode 100644 docs_src/apps/tutorial002.py create mode 100644 docs_src/extensions/__init__.py create mode 100644 docs_src/extensions/tutorial001.py create mode 100644 docs_src/extensions/tutorial002.py create mode 100644 docs_src/extensions/tutorial003.py create mode 100644 examples/stories/extensions/README.md create mode 100644 examples/stories/extensions/__init__.py create mode 100644 examples/stories/extensions/client.py create mode 100644 examples/stories/extensions/server.py create mode 100644 tests/docs_src/test_apps.py create mode 100644 tests/docs_src/test_extensions.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md new file mode 100644 index 0000000000..2f7dc51fc4 --- /dev/null +++ b/docs/advanced/apps.md @@ -0,0 +1,176 @@ +# MCP Apps + +An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML +document the host renders as an interactive surface. + +Two parts, always two parts: + +1. **A tool** that does the work and returns data, like any other tool. +2. **A `ui://` resource** containing the HTML the host shows for it. + +The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches +it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's +result into that iframe via `postMessage`. Your server never sends or receives any +`ui/*` messages: that traffic is between the host and the iframe. You serve a tool +and an HTML document; the host does the theater. + +The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`). +If [Extensions](extensions.md) are new to you, skim that page first. One minute, +then come back. + +## A clock with a face + +```python title="server.py" hl_lines="17 20 23-24 28 30" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Four moves: + +* `Apps()`: one instance holds your UI-bound tools and their resources. +* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the + `_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title, + description, ...) passes through. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching + resource, served as `text/html;profile=mcp-app`. That exact MIME type is what + tells a host "this is an app, render it". +* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises + `io.modelcontextprotocol/ui` under `capabilities.extensions`. + +The HTML itself listens for the host's `postMessage` and shows the result. For real +apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`, +`getHostContext`, and `onhostcontextchanged` instead of raw message events. + +## Graceful degradation + +Not every client renders apps. The spec is blunt about what that means for you: + +> Tools **MUST** return a meaningful `content` array even when UI is available. + +The model reads `content`; the iframe is for humans. A UI-capable host still feeds +the text result to the model, and a text-only client gets *only* that. So the +canonical pattern is one tool, two answers: + +```python +@apps.tool(resource_uri="ui://clock/app.html") +def get_time(ctx: Context) -> str: + now = current_time() + if not client_supports_apps(ctx): + return f"The time is {now}." # a sentence for humans without the UI + return now # raw data the app renders +``` + +`client_supports_apps(ctx)` is `True` only when the client declared the +`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` +in its `mimeTypes` settings. The field is required, so a client that omits it +does not count. + +!!! warning + Never return a placeholder like `"[Rendered UI]"` as the only content. If the + fallback text is useless, the tool is useless to every text-only client and to + the model itself. Write the sentence. + +A client declares support like any extension capability: + +```python +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID + +async with Client(target, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + ... +``` + +## Locking the iframe down + +The resource side carries the security metadata: what the iframe may load, which +browser permissions it wants, how it would like to be framed: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` and `permissions` are **requests to the host**, not server behaviour. The host +builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it +may refuse. Feature-detect in your JS rather than assuming a grant. + +`ResourceCsp`, field by field (Python name, wire key, what the host does with it): + +| Python | Wire (`_meta.ui.csp`) | Controls | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets | +| `frame_domains` | `frameDomains` | `frame-src`: nested iframes | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `` may point at | + +`ResourcePermissions`: each field requests a browser permission for the iframe. + +| Python | Wire (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP and permissions live on the **resource**, never on the tool. The spec's tool + metadata has no slot for them, and hosts ignore them there. The SDK makes the + mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter. + +### Visibility + +`visibility=["app"]` on a tool says "this exists for the iframe, not the model": + +* `"model"`: the model may call it. +* `"app"`: the iframe may call it (via `callServerTool`). +* Omitted: both, which is the default. + +Filtering is the **host's** job. Your server lists app-only tools in `tools/list` +like any other; the host hides them from the model. Don't filter server-side. + +## The rules the SDK enforces + +All of these fail at startup, not in production: + +* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at + decoration/registration time. +* A tool bound to a URI with **no matching registered resource** is a `ValueError` + when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising + HTML that 404s on `resources/read` is a misconfiguration, so it refuses to + construct. +* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns + `_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys + merge fine alongside. + +Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd +rather you find out before a host does. + +## Beyond inline HTML + +`add_html_resource` covers the common case: a string of HTML. For anything else, +HTML on disk or generated content, build the resource yourself and hand it over: + +```python +from mcp.server.mcpserver.resources import FileResource + +apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=html_path)) +``` + +`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource +doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource +under any other MIME type is one no host will render. + +!!! tip + Targeting a pre-GA host that still reads the deprecated flat + `_meta["ui/resourceUri"]` key? Merge it yourself: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + The nested `ui` object is the spec shape; the flat key is on its way out. + +## See it run + +The `apps` story in `examples/stories/` is this page as a runnable pair: a server +with a UI-bound clock tool and a client that negotiates Apps, reads the tool's +`_meta.ui.resourceUri`, fetches the HTML, and calls the tool. + +```bash +uv run python -m stories.apps.client +``` diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000000..2535f52871 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,173 @@ +# 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 +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo", extensions=[Apps()]) +``` + +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 +class Stamps(Extension): + identifier = "com.example/stamps" +``` + +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="16 18-19 21-22 25" +--8<-- "docs_src/extensions/tutorial001.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. + +#### Try it + +```python +from mcp import Client + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + # [stamped] hello +``` + +### 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="13-19 23 32-40" +--8<-- "docs_src/extensions/tutorial002.py" +``` + +* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses + uniformly and your handler gets validated params, never a raw dict. +* `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. + +!!! tip + Calling a vendor method from the client goes through `client.session.send_request(...)` + today; `Client` only grows first-class methods for spec verbs. The + `custom_methods` story in `examples/stories/` shows the full round trip. + +### 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/tutorial003.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. + +## The client side + +A client declares the extensions it supports the same way the server does: + +```python +from mcp import Client + +async with Client(target, extensions={"com.example/search": {}}) as client: + ... +``` + +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. + +## 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. diff --git a/docs_src/apps/__init__.py b/docs_src/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py new file mode 100644 index 0000000000..6cbefdff7a --- /dev/null +++ b/docs_src/apps/tutorial001.py @@ -0,0 +1,30 @@ +from mcp.server.apps import Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context + +CLOCK_HTML = """\ + +Clock +

...

+ +""" + +apps = Apps() + + +@apps.tool(resource_uri="ui://clock/app.html", description="The current time.") +def get_time(ctx: Context) -> str: + now = "2026-06-26T12:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + +apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") + +mcp = MCPServer("clock", extensions=[apps]) diff --git a/docs_src/apps/tutorial002.py b/docs_src/apps/tutorial002.py new file mode 100644 index 0000000000..11393285b0 --- /dev/null +++ b/docs_src/apps/tutorial002.py @@ -0,0 +1,25 @@ +from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions +from mcp.server.mcpserver import MCPServer + +DASHBOARD_HTML = "Dashboard" + +apps = Apps() + + +@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"]) +def refresh_dashboard() -> str: + """Refresh the dashboard data.""" + return "refreshed" + + +apps.add_html_resource( + "ui://dashboard/app.html", + DASHBOARD_HTML, + title="Dashboard", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(clipboard_write={}), + domain="dashboard.example.com", + prefers_border=True, +) + +mcp = MCPServer("dashboard", extensions=[apps]) diff --git a/docs_src/extensions/__init__.py b/docs_src/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py new file mode 100644 index 0000000000..882e5e73e5 --- /dev/null +++ b/docs_src/extensions/tutorial001.py @@ -0,0 +1,25 @@ +from collections.abc import Sequence +from typing import Any + +from mcp.server.extension import Extension, ToolBinding +from mcp.server.mcpserver import MCPServer + + +def stamp(text: str) -> str: + """Stamp a message with the office seal.""" + return f"[stamped] {text}" + + +class Stamps(Extension): + """A purely additive extension: one tool, one capability entry.""" + + identifier = "com.example/stamps" + + def settings(self) -> dict[str, Any]: + return {"sealed": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=stamp)] + + +mcp = MCPServer("post-office", extensions=[Stamps()]) diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py new file mode 100644 index 0000000000..4fd955dc14 --- /dev/null +++ b/docs_src/extensions/tutorial002.py @@ -0,0 +1,43 @@ +from collections.abc import Sequence +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 10 + + +class SearchResult(types.Result): + items: list[str] + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Search(Extension): + """An extension that serves its own request method.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding( + "com.example/search", + SearchParams, + search, + protocol_versions=frozenset({"2026-07-28"}), + ) + ] + + +mcp = MCPServer("catalog", extensions=[Search()]) diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py new file mode 100644 index 0000000000..61ec6c76bc --- /dev/null +++ b/docs_src/extensions/tutorial003.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +from mcp_types import CallToolRequestParams + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +logger = logging.getLogger(__name__) + + +class AuditLog(Extension): + """Observe every tools/call without touching its result.""" + + identifier = "com.example/audit" + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + logger.info("tool %r called", params.name) + return await call_next(ctx) + + +mcp = MCPServer("audited", extensions=[AuditLog()]) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md new file mode 100644 index 0000000000..6d3da72c9f --- /dev/null +++ b/examples/stories/extensions/README.md @@ -0,0 +1,41 @@ +# extensions + +Writing your own extension (SEP-2133): one identifier bundles a settings entry +under `ServerCapabilities.extensions`, a contributed tool, and a vendor request +method gated on the client declaring the extension back. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.extensions.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.extensions.client --http +``` + +## What to look at + +- `server.py` `class Catalog(Extension)` — the whole extension: `settings()` + becomes the advertised capability entry, `tools()` contributes a regular tool, + `methods()` registers a vendor verb. The extension never holds the server; it + declares contributions and `MCPServer(extensions=[...])` consumes them. +- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method + rejects clients that did not declare the extension with `-32021` (missing + required client capability) and a machine-readable `requiredCapabilities` + payload. +- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side + half of the negotiation; on 2026-07-28 it travels in the per-request `_meta` + envelope. +- `client.py` `client.session.send_request(...)` — vendor methods have no + `Client`-level helper; the session escape hatch sends them. + +## Spec + +[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) +· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index) + +## See also + +`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb +registered on the lowlevel `Server` by hand, without an extension). diff --git a/examples/stories/extensions/__init__.py b/examples/stories/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py new file mode 100644 index 0000000000..d3aacc140f --- /dev/null +++ b/examples/stories/extensions/client.py @@ -0,0 +1,54 @@ +"""Discover an extension's capability entry, call its tool, then send its vendor method.""" + +from typing import Literal, cast + +import mcp_types as types +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 3 + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Declare the extension client-side so the server's `require_client_extension` + # gate on `com.example/search` passes. + async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, ( + client.server_capabilities.extensions + ) + + # The extension's tool is a regular tool: listed and callable like any other. + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["suggest"], listed + result = await client.call_tool("suggest", {"prefix": "mcp"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "mcp-suggestion", result.content[0].text + + # Vendor methods drop one layer to `client.session` (see custom_methods/); + # the cast is needed because `send_request` is typed against the spec union. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py new file mode 100644 index 0000000000..ed796be797 --- /dev/null +++ b/examples/stories/extensions/server.py @@ -0,0 +1,63 @@ +"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133). + +`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story +bundles the same idea as an `Extension`: declared contributions, a settings entry +under `ServerCapabilities.extensions`, and a `require_client_extension` gate on +the vendor method. +""" + +from collections.abc import Sequence +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding, ToolBinding +from mcp.server.mcpserver import MCPServer, require_client_extension +from stories._hosting import run_server_from_args + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = 3 + + +class SearchResult(types.Result): + items: list[str] + + +def suggest(prefix: str) -> str: + """Suggest a catalog entry for a prefix.""" + return f"{prefix}-suggestion" + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Catalog(Extension): + """One identifier, three contributions: settings, a tool, a vendor method.""" + + identifier = EXTENSION_ID + + def settings(self) -> dict[str, Any]: + return {"suggest": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=suggest)] + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/search", SearchParams, search)] + + +def build_server() -> MCPServer: + return MCPServer("extensions-example", extensions=[Catalog()]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 9816b568b8..c89f8a8c5c 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -56,6 +56,13 @@ lowlevel = false transports = ["in-memory", "http-asgi"] era = "dual-in-body" +[story.extensions] +# Same constraints as `apps`: MCPServer-tier extension API, capability map rides +# server/discover (modern only), client guards the capability assert by presence. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + [story.schema_validators] [story.middleware] diff --git a/mkdocs.yml b/mkdocs.yml index 93127a410a..e22e47e1d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ nav: - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - Middleware: advanced/middleware.md + - Extensions: advanced/extensions.md + - MCP Apps: advanced/apps.md - OpenTelemetry: advanced/opentelemetry.md - Authorization: advanced/authorization.md - OAuth clients: advanced/oauth-clients.md diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index b9278fde30..d5b9d9ed85 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -109,9 +109,12 @@ def tool( meta: Additional `_meta` keys to merge with the `ui` entry. Raises: - ValueError: If `resource_uri` does not use the `ui://` scheme. + ValueError: If `resource_uri` does not use the `ui://` scheme, or + `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`). """ _require_ui_scheme(resource_uri) + if meta and "ui" in meta: + raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key") ui: dict[str, Any] = {"resourceUri": resource_uri} if visibility is not None: ui["visibility"] = list(visibility) @@ -173,13 +176,20 @@ def add_resource(self, resource: Resource) -> None: """Register a pre-built `ui://` resource. The escape hatch for resources `add_html_resource` cannot express (e.g. a - `FileResource` serving HTML from disk). The resource should carry the - `text/html;profile=mcp-app` MIME type for hosts to render it. + `FileResource` serving HTML from disk). A resource without an explicit + `mime_type` is served as `text/html;profile=mcp-app` — hosts will not + render a `ui://` resource under any other MIME type, so an explicit + mismatch is rejected. Raises: - ValueError: If the resource URI does not use the `ui://` scheme. + ValueError: If the resource URI does not use the `ui://` scheme, or + its explicit `mime_type` is not `text/html;profile=mcp-app`. """ _require_ui_scheme(resource.uri) + if "mime_type" not in resource.model_fields_set: + resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE}) + elif resource.mime_type != APP_MIME_TYPE: + raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}") self._resources.append(ResourceBinding(resource=resource)) def tools(self) -> Sequence[ToolBinding]: diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py new file mode 100644 index 0000000000..4b9eff596f --- /dev/null +++ b/tests/docs_src/test_apps.py @@ -0,0 +1,78 @@ +"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any + +import pytest +from mcp_types import TextContent, TextResourceContents + +from docs_src.apps import tutorial001, tutorial002 +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_tool_carries_the_ui_resource_reference() -> None: + """tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool.""" + async with Client(tutorial001.mcp) as client: + listed = await client.list_tools() + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}} + + +async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: + """tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`, + the MIME type that tells a host "this is an app, render it".""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("ui://clock/app.html") + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial001.CLOCK_HTML + + +async def test_one_tool_two_answers() -> None: + """tutorial001: the canonical degradation pattern: raw data for a client that + negotiated Apps, a human sentence for one that did not.""" + async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client: + rich = await ui_client.call_tool("get_time", {}) + async with Client(tutorial001.mcp) as text_client: + plain = await text_client.call_tool("get_time", {}) + assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")] + assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] + + +async def test_capability_advertised_under_server_extensions() -> None: + """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {EXTENSION_ID: {}} + + +async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None: + """tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list + entry and the read content item, with the spec's camelCase wire keys.""" + expected: dict[str, Any] = { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"clipboardWrite": {}}, + "domain": "dashboard.example.com", + "prefersBorder": True, + } + } + async with Client(tutorial002.mcp) as client: + listed = await client.list_resources() + result = await client.read_resource("ui://dashboard/app.html") + assert listed.resources[0].meta == expected + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.meta == expected + + +async def test_an_app_only_tool_is_still_listed_and_callable() -> None: + """tutorial002: `visibility=["app"]` is metadata for the host; the server lists the + tool like any other and serves its calls; filtering is the host's job.""" + async with Client(tutorial002.mcp) as client: + listed = await client.list_tools() + result = await client.call_tool("refresh_dashboard", {}) + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}} + assert result.content == [TextContent(type="text", text="refreshed")] diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py new file mode 100644 index 0000000000..ad64b5e042 --- /dev/null +++ b/tests/docs_src/test_extensions.py @@ -0,0 +1,75 @@ +"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK.""" + +import logging +from typing import Literal, cast + +import mcp_types as types +import pytest +from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent + +from docs_src.extensions import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +class _SearchRequest(types.Request[tutorial002.SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: tutorial002.SearchParams + + +async def test_extension_settings_advertised_under_capabilities() -> None: + """tutorial001: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} + + +async def test_contributed_tool_is_listed_and_callable() -> None: + """tutorial001: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + async with Client(tutorial001.mcp) as client: + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["stamp"] + result = await client.call_tool("stamp", {"text": "hello"}) + assert result.content == [TextContent(type="text", text="[stamped] hello")] + + +async def test_vendor_method_served_to_a_declaring_client() -> None: + """tutorial002: a client that declared the extension gets the vendor method's result.""" + async with Client(tutorial002.mcp, extensions={tutorial002.EXTENSION_ID: {}}) as client: + request = _SearchRequest(params=tutorial002.SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"] + + +async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: + """tutorial002: `require_client_extension` answers a non-declaring client with `-32021` + and the machine-readable `requiredCapabilities` payload.""" + async with Client(tutorial002.mcp) as client: + request = _SearchRequest(params=tutorial002.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} + + +async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: + """tutorial002: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND + at any other wire version; for a legacy client it doesn't exist.""" + async with Client(tutorial002.mcp, mode="legacy", extensions={tutorial002.EXTENSION_ID: {}}) as client: + request = _SearchRequest(params=tutorial002.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) + assert exc_info.value.code == METHOD_NOT_FOUND + + +async def test_interceptor_observes_the_call_and_passes_the_result_through( + caplog: pytest.LogCaptureFixture, +) -> None: + """tutorial003: the interceptor logs the tool name and returns `call_next`'s result unchanged.""" + with caplog.at_level(logging.INFO, logger=tutorial003.logger.name): + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5} + messages = [record.getMessage() for record in caplog.records if record.name == tutorial003.logger.name] + assert messages == ["tool 'add' called"] diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index d280767ccf..65908309ad 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -209,10 +209,10 @@ async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() - ) async with Client(MCPServer("r", extensions=[apps])) as client: + listed = await client.list_resources() result = await client.read_resource("ui://r/app.html") - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].meta == snapshot( + expected_ui_meta = snapshot( { "ui": { "csp": {"connectDomains": ["https://api.example.com"]}, @@ -222,6 +222,11 @@ async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() - } } ) + # Hosts read `_meta.ui` from the read content item, with the list entry as + # fallback — the SDK stamps the same value in both places. + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].meta == expected_ui_meta + assert listed.resources[0].meta == expected_ui_meta def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None: @@ -260,3 +265,38 @@ def test_add_resource_rejects_non_ui_resource_uri() -> None: apps = Apps() with pytest.raises(ValueError): apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x")) + + +def test_apps_tool_rejects_a_ui_meta_key() -> None: + """SDK-defined: the decorator owns `_meta['ui']` — a caller-supplied `'ui'` entry would be + silently clobbered, so it is rejected at decoration time (use `resource_uri=`/`visibility=`).""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.tool(resource_uri="ui://c/app.html", meta={"ui": {"resourceUri": "ui://other.html"}}) + assert str(exc_info.value) == snapshot( + "Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key" + ) + + +async def test_add_resource_defaults_the_mime_type_to_the_app_mime() -> None: + """ext-apps: hosts only render `ui://` resources served as `text/html;profile=mcp-app`, + so a resource registered without an explicit `mime_type` gets it by default.""" + apps = Apps() + apps.add_resource(TextResource(uri="ui://d/app.html", name="d", text="d")) + + async with Client(MCPServer("d", extensions=[apps])) as client: + result = await client.read_resource("ui://d/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +def test_add_resource_rejects_an_explicit_non_app_mime_type() -> None: + """ext-apps: an explicit `mime_type` other than `text/html;profile=mcp-app` would make + the resource unrenderable; the mismatch is rejected at registration.""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.add_resource(TextResource(uri="ui://e/app.html", name="e", mime_type="text/html", text="x")) + assert str(exc_info.value) == snapshot( + "MCP Apps resources are served as 'text/html;profile=mcp-app', got 'text/html'" + ) From 16a85b7af3b775b8e34625ba1ac937e70f814159 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:59:08 +0000 Subject: [PATCH 12/13] Bound the client-controlled limit in the extension examples A request-sized parameter in example code should demonstrate input bounding: Field(ge=, le=) rejects an absurd limit before the handler allocates anything for it. --- docs/advanced/extensions.md | 6 ++++-- docs_src/extensions/tutorial002.py | 3 ++- examples/stories/extensions/server.py | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 2535f52871..a69fed7fb5 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -91,12 +91,14 @@ async def main() -> None: An extension can register **new request methods**: its own verbs, served next to the spec's: -```python title="server.py" hl_lines="13-19 23 32-40" +```python title="server.py" hl_lines="14-20 24 33-41" --8<-- "docs_src/extensions/tutorial002.py" ``` * `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses - uniformly and your handler gets validated params, never a raw dict. + 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. diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py index 4fd955dc14..8b5a40a271 100644 --- a/docs_src/extensions/tutorial002.py +++ b/docs_src/extensions/tutorial002.py @@ -2,6 +2,7 @@ from typing import Any import mcp_types as types +from pydantic import Field from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding @@ -12,7 +13,7 @@ class SearchParams(types.RequestParams): query: str - limit: int = 10 + limit: int = Field(default=10, ge=1, le=100) class SearchResult(types.Result): diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py index ed796be797..837c668dc5 100644 --- a/examples/stories/extensions/server.py +++ b/examples/stories/extensions/server.py @@ -10,6 +10,7 @@ from typing import Any import mcp_types as types +from pydantic import Field from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding, ToolBinding @@ -23,7 +24,7 @@ class SearchParams(types.RequestParams): """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" query: str - limit: int = 3 + limit: int = Field(default=3, ge=1, le=25) class SearchResult(types.Result): From 60841bc3eb76746c7aee6ee6a3a6f7ab59c7d6db Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:10:13 +0000 Subject: [PATCH 13/13] Back every docs code block with a tested docs_src file The extensions and MCP Apps pages had inline Python fragments that CI never executed. Every block now includes a docs_src module: the client programs are async main()s run by tests/docs_src, the identifier and usage fragments are real files, and the FileResource example serves a checked-in report.html. Pages re-include the same file with different hl_lines instead of repeating code. --- docs/advanced/apps.md | 34 ++++--------- docs/advanced/extensions.md | 72 +++++++++++----------------- docs_src/apps/report.html | 3 ++ docs_src/apps/tutorial001.py | 10 +++- docs_src/apps/tutorial003.py | 20 ++++++++ docs_src/extensions/tutorial001.py | 25 +--------- docs_src/extensions/tutorial002.py | 45 ++---------------- docs_src/extensions/tutorial003.py | 45 +++++++++--------- docs_src/extensions/tutorial004.py | 58 +++++++++++++++++++++++ docs_src/extensions/tutorial005.py | 34 +++++++++++++ tests/docs_src/test_apps.py | 26 +++++++++- tests/docs_src/test_extensions.py | 76 +++++++++++++++++++----------- 12 files changed, 262 insertions(+), 186 deletions(-) create mode 100644 docs_src/apps/report.html create mode 100644 docs_src/apps/tutorial003.py create mode 100644 docs_src/extensions/tutorial004.py create mode 100644 docs_src/extensions/tutorial005.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md index 2f7dc51fc4..87e260f014 100644 --- a/docs/advanced/apps.md +++ b/docs/advanced/apps.md @@ -20,7 +20,7 @@ then come back. ## A clock with a face -```python title="server.py" hl_lines="17 20 23-24 28 30" +```python title="server.py" hl_lines="18 21 29 31" --8<-- "docs_src/apps/tutorial001.py" ``` @@ -49,37 +49,23 @@ Not every client renders apps. The spec is blunt about what that means for you: The model reads `content`; the iframe is for humans. A UI-capable host still feeds the text result to the model, and a text-only client gets *only* that. So the -canonical pattern is one tool, two answers: - -```python -@apps.tool(resource_uri="ui://clock/app.html") -def get_time(ctx: Context) -> str: - now = current_time() - if not client_supports_apps(ctx): - return f"The time is {now}." # a sentence for humans without the UI - return now # raw data the app renders +canonical pattern is one tool, two answers. Look at `get_time` again: + +```python title="server.py" hl_lines="22-26" +--8<-- "docs_src/apps/tutorial001.py" ``` `client_supports_apps(ctx)` is `True` only when the client declared the `io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` in its `mimeTypes` settings. The field is required, so a client that omits it -does not count. +does not count. That is exactly what `main()` in the same file declares: the +client half of the negotiation, and the rich answer comes back. !!! warning Never return a placeholder like `"[Rendered UI]"` as the only content. If the fallback text is useless, the tool is useless to every text-only client and to the model itself. Write the sentence. -A client declares support like any extension capability: - -```python -from mcp import Client -from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID - -async with Client(target, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: - ... -``` - ## Locking the iframe down The resource side carries the security metadata: what the iframe may load, which @@ -149,10 +135,8 @@ rather you find out before a host does. `add_html_resource` covers the common case: a string of HTML. For anything else, HTML on disk or generated content, build the resource yourself and hand it over: -```python -from mcp.server.mcpserver.resources import FileResource - -apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=html_path)) +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" ``` `add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index a69fed7fb5..6ca1642288 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -11,11 +11,8 @@ it has one golden rule: **extensions are off by default**. Pass instances at construction: -```python -from mcp.server.apps import Apps -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("demo", extensions=[Apps()]) +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" ``` Done. The server now advertises `io.modelcontextprotocol/ui` under @@ -39,8 +36,7 @@ Subclass `Extension` and override only what you need. Every method has a default ### The identifier ```python -class Stamps(Extension): - identifier = "com.example/stamps" +--8<-- "docs_src/extensions/tutorial002.py" ``` The identifier is a `vendor-prefix/name` string following the spec's `_meta` key @@ -60,8 +56,8 @@ specified by the MCP project itself. The smallest useful extension is one tool and a settings map: -```python title="server.py" hl_lines="16 18-19 21-22 25" ---8<-- "docs_src/extensions/tutorial001.py" +```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 @@ -72,18 +68,10 @@ The smallest useful extension is one tool and a settings map: * The extension never receives the server. It declares contributions as data; `MCPServer` consumes them. There is no `self.server` to mutate. -#### Try it +And `main()` is the proof, an in-memory client straight against `mcp`: -```python -from mcp import Client - - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.extensions) - # {'com.example/stamps': {'sealed': True}} - result = await client.call_tool("stamp", {"text": "hello"}) - # [stamped] hello +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" ``` ### Serving your own methods @@ -91,8 +79,8 @@ async def main() -> None: An extension can register **new request methods**: its own verbs, served next to the spec's: -```python title="server.py" hl_lines="14-20 24 33-41" ---8<-- "docs_src/extensions/tutorial002.py" +```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 @@ -116,10 +104,23 @@ runtime: * An empty `protocol_versions` set raises too: a method that can never be served is a bug, not a configuration. -!!! tip - Calling a vendor method from the client goes through `client.session.send_request(...)` - today; `Client` only grows first-class methods for spec verbs. The - `custom_methods` story in `examples/stories/` shows the full round trip. +### 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` @@ -127,7 +128,7 @@ The one interceptive hook. Override `intercept_tool_call` to observe, short-circ or veto a tool call: ```python title="server.py" hl_lines="18-25" ---8<-- "docs_src/extensions/tutorial003.py" +--8<-- "docs_src/extensions/tutorial005.py" ``` * `params` is the validated `CallToolRequestParams`: you get `params.name` and @@ -143,23 +144,6 @@ or veto a tool call: The hook wraps `tools/call` and nothing else. For every-message concerns, use [Middleware](middleware.md). That is what it is for. -## The client side - -A client declares the extensions it supports the same way the server does: - -```python -from mcp import Client - -async with Client(target, extensions={"com.example/search": {}}) as client: - ... -``` - -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. - ## What an extension cannot do The contribution surface is **closed** on purpose: settings, tools, resources, diff --git a/docs_src/apps/report.html b/docs_src/apps/report.html new file mode 100644 index 0000000000..7c94deefec --- /dev/null +++ b/docs_src/apps/report.html @@ -0,0 +1,3 @@ + +Report +

Quarterly numbers render here.

diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py index 6cbefdff7a..79721c597b 100644 --- a/docs_src/apps/tutorial001.py +++ b/docs_src/apps/tutorial001.py @@ -1,4 +1,5 @@ -from mcp.server.apps import Apps, client_supports_apps +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -28,3 +29,10 @@ def get_time(ctx: Context) -> str: apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") mcp = MCPServer("clock", extensions=[apps]) + + +async def main() -> None: + async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + result = await client.call_tool("get_time", {}) + print(result.content) + # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/apps/tutorial003.py b/docs_src/apps/tutorial003.py new file mode 100644 index 0000000000..e3aed3ef78 --- /dev/null +++ b/docs_src/apps/tutorial003.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FileResource + +REPORT_HTML = Path(__file__).parent / "report.html" + +apps = Apps() + + +@apps.tool(resource_uri="ui://report/app.html") +def refresh_report() -> str: + """Refresh the report data.""" + return "report refreshed" + + +apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML)) + +mcp = MCPServer("report", extensions=[apps]) diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py index 882e5e73e5..1e5a1f9076 100644 --- a/docs_src/extensions/tutorial001.py +++ b/docs_src/extensions/tutorial001.py @@ -1,25 +1,4 @@ -from collections.abc import Sequence -from typing import Any - -from mcp.server.extension import Extension, ToolBinding +from mcp.server.apps import Apps from mcp.server.mcpserver import MCPServer - -def stamp(text: str) -> str: - """Stamp a message with the office seal.""" - return f"[stamped] {text}" - - -class Stamps(Extension): - """A purely additive extension: one tool, one capability entry.""" - - identifier = "com.example/stamps" - - def settings(self) -> dict[str, Any]: - return {"sealed": True} - - def tools(self) -> Sequence[ToolBinding]: - return [ToolBinding(fn=stamp)] - - -mcp = MCPServer("post-office", extensions=[Stamps()]) +mcp = MCPServer("demo", extensions=[Apps()]) diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py index 8b5a40a271..87b59bd23a 100644 --- a/docs_src/extensions/tutorial002.py +++ b/docs_src/extensions/tutorial002.py @@ -1,44 +1,5 @@ -from collections.abc import Sequence -from typing import Any +from mcp.server.extension import Extension -import mcp_types as types -from pydantic import Field -from mcp.server.context import ServerRequestContext -from mcp.server.extension import Extension, MethodBinding -from mcp.server.mcpserver import MCPServer, require_client_extension - -EXTENSION_ID = "com.example/search" - - -class SearchParams(types.RequestParams): - query: str - limit: int = Field(default=10, ge=1, le=100) - - -class SearchResult(types.Result): - items: list[str] - - -async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: - require_client_extension(ctx, EXTENSION_ID) - return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) - - -class Search(Extension): - """An extension that serves its own request method.""" - - identifier = EXTENSION_ID - - def methods(self) -> Sequence[MethodBinding]: - return [ - MethodBinding( - "com.example/search", - SearchParams, - search, - protocol_versions=frozenset({"2026-07-28"}), - ) - ] - - -mcp = MCPServer("catalog", extensions=[Search()]) +class Stamps(Extension): + identifier = "com.example/stamps" diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py index 61ec6c76bc..312371bee4 100644 --- a/docs_src/extensions/tutorial003.py +++ b/docs_src/extensions/tutorial003.py @@ -1,34 +1,35 @@ -import logging +from collections.abc import Sequence from typing import Any -from mcp_types import CallToolRequestParams - -from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.extension import Extension +from mcp import Client +from mcp.server.extension import Extension, ToolBinding from mcp.server.mcpserver import MCPServer -logger = logging.getLogger(__name__) +def stamp(text: str) -> str: + """Stamp a message with the office seal.""" + return f"[stamped] {text}" + + +class Stamps(Extension): + """A purely additive extension: one tool, one capability entry.""" -class AuditLog(Extension): - """Observe every tools/call without touching its result.""" + identifier = "com.example/stamps" - identifier = "com.example/audit" + def settings(self) -> dict[str, Any]: + return {"sealed": True} - async def intercept_tool_call( - self, - params: CallToolRequestParams, - ctx: ServerRequestContext[Any, Any], - call_next: CallNext, - ) -> HandlerResult: - logger.info("tool %r called", params.name) - return await call_next(ctx) + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=stamp)] -mcp = MCPServer("audited", extensions=[AuditLog()]) +mcp = MCPServer("post-office", extensions=[Stamps()]) -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + print(result.content) + # [TextContent(text='[stamped] hello')] diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py new file mode 100644 index 0000000000..4a0a022af3 --- /dev/null +++ b/docs_src/extensions/tutorial004.py @@ -0,0 +1,58 @@ +from collections.abc import Sequence +from typing import Any, Literal, cast + +import mcp_types as types +from pydantic import Field + +from mcp import Client +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = Field(default=10, ge=1, le=100) + + +class SearchResult(types.Result): + items: list[str] + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Search(Extension): + """An extension that serves its own request method.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding( + "com.example/search", + SearchParams, + search, + protocol_versions=frozenset({"2026-07-28"}), + ) + ] + + +mcp = MCPServer("catalog", extensions=[Search()]) + + +async def main() -> None: + async with Client(mcp, extensions={EXTENSION_ID: {}}) as client: + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + print(result.items) + # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py new file mode 100644 index 0000000000..61ec6c76bc --- /dev/null +++ b/docs_src/extensions/tutorial005.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +from mcp_types import CallToolRequestParams + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +logger = logging.getLogger(__name__) + + +class AuditLog(Extension): + """Observe every tools/call without touching its result.""" + + identifier = "com.example/audit" + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + logger.info("tool %r called", params.name) + return await call_next(ctx) + + +mcp = MCPServer("audited", extensions=[AuditLog()]) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py index 4b9eff596f..02375f97a3 100644 --- a/tests/docs_src/test_apps.py +++ b/tests/docs_src/test_apps.py @@ -5,7 +5,7 @@ import pytest from mcp_types import TextContent, TextResourceContents -from docs_src.apps import tutorial001, tutorial002 +from docs_src.apps import tutorial001, tutorial002, tutorial003 from mcp import Client from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID @@ -42,6 +42,13 @@ async def test_one_tool_two_answers() -> None: assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] +async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001: `main()` declares Apps support with the required `mimeTypes` and + receives the rich answer the page promises.""" + await tutorial001.main() + assert "2026-06-26T12:00:00Z" in capsys.readouterr().out + + async def test_capability_advertised_under_server_extensions() -> None: """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" async with Client(tutorial001.mcp) as client: @@ -70,9 +77,24 @@ async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> Non async def test_an_app_only_tool_is_still_listed_and_callable() -> None: """tutorial002: `visibility=["app"]` is metadata for the host; the server lists the - tool like any other and serves its calls; filtering is the host's job.""" + tool like any other and serves its calls. Filtering is the host's job.""" async with Client(tutorial002.mcp) as client: listed = await client.list_tools() result = await client.call_tool("refresh_dashboard", {}) assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}} assert result.content == [TextContent(type="text", text="refreshed")] + + +async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None: + """tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the + `text/html;profile=mcp-app` MIME type the resource didn't set explicitly.""" + async with Client(tutorial003.mcp) as client: + listed = await client.list_tools() + called = await client.call_tool("refresh_report", {}) + result = await client.read_resource("ui://report/app.html") + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}} + assert called.content == [TextContent(type="text", text="report refreshed")] + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial003.REPORT_HTML.read_text() diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py index ad64b5e042..ebe00e5a88 100644 --- a/tests/docs_src/test_extensions.py +++ b/tests/docs_src/test_extensions.py @@ -1,75 +1,97 @@ """`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK.""" import logging -from typing import Literal, cast +from typing import cast import mcp_types as types import pytest +from inline_snapshot import snapshot from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent -from docs_src.extensions import tutorial001, tutorial002, tutorial003 +from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 from mcp import Client, MCPError +from mcp.server.extension import Extension # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] -class _SearchRequest(types.Request[tutorial002.SearchParams, Literal["com.example/search"]]): - method: Literal["com.example/search"] = "com.example/search" - params: tutorial002.SearchParams +async def test_using_an_extension_advertises_its_capability() -> None: + """tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise + the extension under `capabilities.extensions`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}} + + +def test_a_prefixless_identifier_fails_at_class_definition() -> None: + """tutorial002 + the page's TypeError block: the identifier is validated when the + subclass is defined, with the exact message the page shows.""" + assert tutorial002.Stamps.identifier == "com.example/stamps" + with pytest.raises(TypeError) as exc_info: + type("Stamps", (Extension,), {"identifier": "stamps"}) + assert str(exc_info.value) == snapshot( + "Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'" + ) async def test_extension_settings_advertised_under_capabilities() -> None: - """tutorial001: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" - async with Client(tutorial001.mcp) as client: + """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + async with Client(tutorial003.mcp) as client: assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} async def test_contributed_tool_is_listed_and_callable() -> None: - """tutorial001: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" - async with Client(tutorial001.mcp) as client: + """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + async with Client(tutorial003.mcp) as client: listed = await client.list_tools() assert [tool.name for tool in listed.tools] == ["stamp"] result = await client.call_tool("stamp", {"text": "hello"}) assert result.content == [TextContent(type="text", text="[stamped] hello")] -async def test_vendor_method_served_to_a_declaring_client() -> None: - """tutorial002: a client that declared the extension gets the vendor method's result.""" - async with Client(tutorial002.mcp, extensions={tutorial002.EXTENSION_ID: {}}) as client: - request = _SearchRequest(params=tutorial002.SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) - assert result.items == ["mcp-0", "mcp-1", "mcp-2"] +async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` is the literal client program on the page; both printed + lines match the page's comments.""" + await tutorial003.main() + out = capsys.readouterr().out + assert "{'com.example/stamps': {'sealed': True}}" in out + assert "[stamped] hello" in out + + +async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004: `main()` declares the extension and gets the vendor method's result.""" + await tutorial004.main() + assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: - """tutorial002: `require_client_extension` answers a non-declaring client with `-32021` + """tutorial004: `require_client_extension` answers a non-declaring client with `-32021` and the machine-readable `requiredCapabilities` payload.""" - async with Client(tutorial002.mcp) as client: - request = _SearchRequest(params=tutorial002.SearchParams(query="mcp")) + async with Client(tutorial004.mcp) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: - """tutorial002: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND + """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND at any other wire version; for a legacy client it doesn't exist.""" - async with Client(tutorial002.mcp, mode="legacy", extensions={tutorial002.EXTENSION_ID: {}}) as client: - request = _SearchRequest(params=tutorial002.SearchParams(query="mcp")) + async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(cast("types.ClientRequest", request), tutorial002.SearchResult) + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) assert exc_info.value.code == METHOD_NOT_FOUND async def test_interceptor_observes_the_call_and_passes_the_result_through( caplog: pytest.LogCaptureFixture, ) -> None: - """tutorial003: the interceptor logs the tool name and returns `call_next`'s result unchanged.""" - with caplog.at_level(logging.INFO, logger=tutorial003.logger.name): - async with Client(tutorial003.mcp) as client: + """tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged.""" + with caplog.at_level(logging.INFO, logger=tutorial005.logger.name): + async with Client(tutorial005.mcp) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) assert result.structured_content == {"result": 5} - messages = [record.getMessage() for record in caplog.records if record.name == tutorial003.logger.name] + messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name] assert messages == ["tool 'add' called"]