Add a pluggable server extension API with MCP Apps (#3003)

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-06-29 11:58:05 +02:00 • committed by GitHub
commit 4b519782f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2598 additions and 24 deletions

242
src/mcp/server/apps.py Normal file
View file

@ -0,0 +1,242 @@
"""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 Resource, 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[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri)
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, or
`meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
"""
_require_ui_scheme(resource_uri)
if meta and "ui" in meta:
raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
ui: dict[str, Any] = {"resourceUri": resource_uri}
if visibility is not None:
ui["visibility"] = list(visibility)
def decorator(fn: _CallableT) -> _CallableT:
binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
self._tools.append((binding, resource_uri))
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.
"""
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
self.add_resource(
TextResource(
uri=uri,
name=name or uri,
title=title,
description=description,
mime_type=APP_MIME_TYPE,
meta={"ui": ui} if ui else None,
text=html,
)
)
def add_resource(self, resource: Resource) -> None:
"""Register a pre-built `ui://` resource.
The escape hatch for resources `add_html_resource` cannot express (e.g. a
`FileResource` serving HTML from disk). A resource without an explicit
`mime_type` is served as `text/html;profile=mcp-app` — hosts will not
render a `ui://` resource under any other MIME type, so an explicit
mismatch is rejected.
Raises:
ValueError: If the resource URI does not use the `ui://` scheme, or
its explicit `mime_type` is not `text/html;profile=mcp-app`.
"""
_require_ui_scheme(resource.uri)
if "mime_type" not in resource.model_fields_set:
resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
elif resource.mime_type != APP_MIME_TYPE:
raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
self._resources.append(ResourceBinding(resource=resource))
def tools(self) -> Sequence[ToolBinding]:
"""The bound tools.
Raises:
ValueError: If a tool's `resource_uri` has no matching resource
registered on this instance — a tool advertising a
`_meta.ui.resourceUri` that 404s on `resources/read` is a
misconfiguration, caught when the server consumes the extension.
"""
registered = {binding.resource.uri for binding in self._resources}
for tool, uri in self._tools:
if uri not in registered:
raise ValueError(
f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
"is registered; add it with add_html_resource() or add_resource()"
)
return [tool for tool, _ in self._tools]
def resources(self) -> Sequence[ResourceBinding]:
return self._resources
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 isinstance(mime_types, list | tuple) and 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}")

View file

@ -345,4 +345,14 @@ class Connection:
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

195
src/mcp/server/extension.py Normal file
View file

@ -0,0 +1,195 @@
"""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 the
base class itself never drags in the composition tier that consumes it;
extensions remain importable without constructing an `MCPServer`.
"""
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 mcp_types.methods import SPEC_CLIENT_METHODS
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 with a mandatory prefix
# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a
# letter and ending with a letter or digit (hyphens interior), then `/`, then a
# name that starts and ends alphanumeric (`.`/`_`/`-` interior).
_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?"
_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}")
def validate_extension_identifier(identifier: Any, *, owner: str) -> None:
"""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.fullmatch(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.
Extension methods are additive: `method` must not name a spec-defined
request method (`tools/list`, `completion/complete`, ...) — those handlers
belong to the server, and an extension binding one would silently shadow or
be shadowed by it. Both constraints are enforced at construction. To
re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel`
for legacy clients), use the lowlevel `Server.add_request_handler` API
instead — the runner's per-version surface gate would never route such a
method to an extension handler anyway.
"""
method: str
params_type: type[BaseModel]
handler: RequestHandler
protocol_versions: frozenset[str] | None = None
def __post_init__(self) -> None:
if self.method in SPEC_CLIENT_METHODS:
raise ValueError(
f"MethodBinding cannot bind spec method {self.method!r}; extension methods are "
"additive — use Extension.intercept_tool_call or Server.middleware to wrap core behaviour"
)
if self.protocol_versions is not None and not self.protocol_versions:
raise ValueError(
f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could "
"never be served; use None to admit every version"
)
class Extension:
"""Base class for an opt-in MCP extension. Override only the methods you need.
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

View file

@ -434,6 +434,10 @@ class Server(Generic[LifespanResultT]):
# 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 @@ class Server(Generic[LifespanResultT]):
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 @@ class Server(Generic[LifespanResultT]):
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 @@ class Server(Generic[LifespanResultT]):
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 @@ class Server(Generic[LifespanResultT]):
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

View file

@ -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 @@ __all__ = [
"Image",
"Audio",
"Icon",
"Extension",
"ToolBinding",
"ResourceBinding",
"MethodBinding",
"require_client_extension",
"ResourceSecurity",
"DEFAULT_RESOURCE_SECURITY",
]

View file

@ -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):

View file

@ -4,7 +4,7 @@ from __future__ import annotations
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 @@ import pydantic_core
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 @@ from mcp_types import (
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
MissingRequiredClientCapabilityErrorData,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
@ -54,7 +58,14 @@ from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
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 @@ class MCPServer(Generic[LifespanResultT]):
*,
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 @@ class MCPServer(Generic[LifespanResultT]):
# 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,44 @@ class MCPServer(Generic[LifespanResultT]):
"""
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():
if self._lowlevel_server.get_request_handler(method.method) is not None:
raise ValueError(
f"Extension {identifier!r} binds method {method.method!r}, which is already "
"registered; extension methods are additive and cannot replace another handler"
)
handler = _version_gated(method) if method.protocol_versions is not None else method.handler
self._lowlevel_server.add_request_handler(method.method, method.params_type, handler)
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 +1207,50 @@ class MCPServer(Generic[LifespanResultT]):
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),
)