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:
parent
fe9723853d
commit
4b519782f1
9 changed files with 2598 additions and 24 deletions
|
|
@ -217,6 +217,10 @@ class Client:
|
|||
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
|
||||
to drive the loop manually instead."""
|
||||
|
||||
extensions: dict[str, dict[str, Any]] | None = None
|
||||
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
|
||||
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
|
||||
|
||||
_entered: bool = field(init=False, default=False)
|
||||
_session: ClientSession | None = field(init=False, default=None)
|
||||
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
|
||||
|
|
@ -255,6 +259,7 @@ class Client:
|
|||
message_handler=self.message_handler,
|
||||
client_info=self.client_info,
|
||||
elicitation_callback=self.elicitation_callback,
|
||||
extensions=self.extensions,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> Client:
|
||||
|
|
|
|||
|
|
@ -224,12 +224,14 @@ class ClientSession:
|
|||
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 @@ class ClientSession:
|
|||
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:
|
||||
|
|
|
|||
242
src/mcp/server/apps.py
Normal file
242
src/mcp/server/apps.py
Normal 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}")
|
||||
|
|
@ -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
195
src/mcp/server/extension.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue