diff --git a/docs/migration.md b/docs/migration.md index 42d420bf0..c19ed2f31 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -407,6 +407,40 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. +### Server extensions API (SEP-2133) + +`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a +reverse-DNS identifier and advertise it under `ServerCapabilities.extensions` +(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension` +and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()` +(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a +`vendor-prefix/name` string, enforced when the subclass is defined. Pass instances at +construction: + +```python +from mcp.server.mcpserver import MCPServer +from mcp.server.apps import Apps + +mcp = MCPServer("demo", extensions=[Apps()]) +``` + +The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`): +it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and +`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the +client advertised the `text/html;profile=mcp-app` MIME type). + +A `MethodBinding` may set `protocol_versions` to scope an extension method to +specific wire versions; a request at any other version is `METHOD_NOT_FOUND`. An +extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)` +to reject a request with the `-32021` (missing required client capability) error +when the client did not declare the extension. + +Clients advertise extension support with the new `Client(extensions=...)` / +`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`. +The extensions capability map is negotiated over `server/discover` (modern path); +a legacy `initialize` handshake does not carry it. Extensions are off by default +and never alter behaviour unless registered. + ### `McpError` renamed to `MCPError` The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index b802525fa..dc180a0d3 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -1,14 +1,40 @@ # apps -MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource -that the host renders as an interactive surface. The story will register a -`@ui` resource and return it from a tool. +MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://` +resource that the host renders as an interactive surface. The server opts in via +the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by +advertising the `text/html;profile=mcp-app` MIME type. -**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)). -The `extensions` capability map is not yet surfaced on `MCPServer`, so a server -cannot advertise Apps support and a client cannot negotiate it. +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.apps.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.apps.client --http +``` + +## What to look at + +- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension + advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` + and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself + never learns about "ui"; it applies a closed set of contributions. +- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on + the tool; `add_html_resource` registers the matching `ui://` resource at + `text/html;profile=mcp-app`. +- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a + client that did not negotiate Apps gets a text-only result. +- `client.py` `Client(target, extensions={...})` — the client advertises Apps + support so the server returns the UI-enabled result, then reads the tool's + `_meta.ui.resourceUri` and fetches that resource. ## Spec [MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py new file mode 100644 index 000000000..8a238f469 --- /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 000000000..74d412e02 --- /dev/null +++ b/examples/stories/apps/server.py @@ -0,0 +1,43 @@ +"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface. + +`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The +`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the +tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool +degrades gracefully: `client_supports_apps(ctx)` reports whether the client +negotiated Apps, so it returns text-only output otherwise. +""" + +from mcp.server.apps import Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from stories._hosting import run_server_from_args + +RESOURCE_URI = "ui://get-time/app.html" +CLOCK_HTML = """ +Current time +

+ +""" + + +def build_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + now = "2026-06-26T00:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock") + return MCPServer("apps-example", extensions=[apps]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index fb688d294..9816b568b 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -48,6 +48,14 @@ status = "deprecated" [story.custom_methods] lowlevel = false +[story.apps] +# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant. +# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so +# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + [story.schema_validators] [story.middleware] @@ -142,7 +150,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [deferred] caching = "client honouring + per-result override unlanded" subscriptions = "#2901 — Client.listen / ServerEventBus" -tasks = "extensions capability map + tasks runtime" -apps = "#2896 — extensions capability map" +tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index ef15ae63f..d1956d1e3 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,16 +1,24 @@ # tasks -The `io.modelcontextprotocol/tasks` extension: long-running work registered -with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with -`tasks/cancel`. The story will show a task that outlives the request that -started it. +Task-augmented execution: a requestor augments a `tools/call` with a `task`, the +receiver returns a `CreateTaskResult` immediately, and the requestor polls +`tasks/get` and retrieves the deferred result. -**Status: not yet implemented.** The extension types exist but the `extensions` -capability map is not yet surfaced on `MCPServer`, and the runtime trails the -release. The TypeScript SDK deliberately removed its tasks example pending the -same work. +**Status: deferred.** Tasks ship in 2026-07-28 as +[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md), +an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the +2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime +needs to be built to the SEP — server-decided augmentation (ignoring the legacy +`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the +`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` +fields — so it lands in a separate PR with the conformance `tasks-*` scenarios +wired in. ## Spec -[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +[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). diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d6a6e4caa..d3290f308 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 fa71d1330..3cebb569e 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/apps.py b/src/mcp/server/apps.py new file mode 100644 index 000000000..e9e9786f2 --- /dev/null +++ b/src/mcp/server/apps.py @@ -0,0 +1,203 @@ +"""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 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. +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. (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, 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.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.""" + +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. + + 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, + 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` (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: + self._tools.append(ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, 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, + 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. + + Raises: + ValueError: If `uri` does not use the `ui://` scheme. + """ + _require_ui_scheme(uri) + ui: dict[str, Any] = {} + if csp is not None: + ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) + 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)) + + 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 `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 + 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: + 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/src/mcp/server/connection.py b/src/mcp/server/connection.py index 76917f896..4d9496fef 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/extension.py b/src/mcp/server/extension.py new file mode 100644 index 000000000..0a151d50c --- /dev/null +++ b/src/mcp/server/extension.py @@ -0,0 +1,169 @@ +"""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 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 TYPE_CHECKING, Any + +from mcp_types import CallToolRequestParams +from pydantic import BaseModel + +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext + +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: + """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. `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: + """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`. `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]`. + + 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/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index bbd2ff331..76a66b352 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 741f16beb..7a8da42fe 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -2,9 +2,11 @@ from mcp_types import Icon +from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding + from .context import Context 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__ = [ @@ -13,6 +15,11 @@ "Image", "Audio", "Icon", + "Extension", + "ToolBinding", + "ResourceBinding", + "MethodBinding", + "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index a25213e7b..e295e21e0 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 029512a78..988159c2a 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,7 @@ import base64 import inspect -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 @@ -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,7 +58,14 @@ 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 @@ -148,6 +159,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, @@ -215,6 +227,11 @@ def __init__( # Configure logging configure_logging(self.settings.log_level) + self._extensions: list[Extension] = [] + for extension in extensions or (): + self._apply_extension(extension) + self._install_extension_interceptor() + @property def name(self) -> str: return self._lowlevel_server.name @@ -255,6 +272,39 @@ def session_manager(self) -> StreamableHTTPSessionManager: """ return self._lowlevel_server.session_manager + def _apply_extension(self, extension: Extension) -> None: + """Apply one opt-in extension's contributions through the public surface. + + 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`. + """ + 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(): + 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(): + 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() + + 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._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) + @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... @@ -1152,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 new file mode 100644 index 000000000..0884e63a0 --- /dev/null +++ b/tests/server/mcpserver/test_extension.py @@ -0,0 +1,397 @@ +"""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 ( + 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.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 + +_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()]) + + +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` 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 len(server._lowlevel_server.middleware) == baseline + + +def test_overriding_extension_installs_one_tool_call_interceptor() -> None: + """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 len(server._lowlevel_server.middleware) == baseline + 1 + + +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 + + +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 new file mode 100644 index 000000000..4c48c0246 --- /dev/null +++ b/tests/server/test_apps.py @@ -0,0 +1,222 @@ +"""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, + ResourceCsp, + ResourcePermissions, + 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") + + +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 new file mode 100644 index 000000000..90f24be2b --- /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.extension import Extension +from mcp.server.mcpserver import MCPServer + +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]