From 2c7fc6eb33f8b0fc9ffc83fbc788f25e7d1e5de8 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 20:17:05 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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