Skip to content

Commit 0f440b1

Browse files
committed
Address extension-API review: layering, enforcement, version gating, Apps fixes
Framework: - Move the Extension base class from mcp/server/mcpserver/extension.py to mcp/server/extension.py so helper-tier modules (apps.py) and third-party extensions depend on the base, not the composition tier. - Enforce a vendor-prefix/name identifier via __init_subclass__ (and at apply time for per-instance identifiers), failing at class-definition rather than late with AttributeError. - Add MethodBinding.protocol_versions so an extension method can be scoped to specific wire versions; out-of-range requests get METHOD_NOT_FOUND. - Add require_client_extension(ctx, identifier) raising the -32021 missing required client capability error with a requiredCapabilities payload. Apps: - client_supports_apps now checks the client advertised the text/html;profile=mcp-app MIME type, not just the extension key. - Add a visibility kwarg to @apps.tool (_meta.ui.visibility). - Let add_html_resource set csp/permissions/domain/prefers_border on the resource _meta via typed ResourceCsp/ResourcePermissions models. - Fix the meta= double-keyword TypeError by making meta an explicit param merged with the ui entry instead of passing through **tool_kwargs.
1 parent cb2c456 commit 0f440b1

8 files changed

Lines changed: 416 additions & 42 deletions

File tree

docs/migration.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,10 @@ For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may
411411

412412
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
413413
reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
414-
(the 2026-07-28 capability map). An extension subclasses `mcp.server.mcpserver.Extension`
414+
(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension`
415415
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
416-
(additive) and `intercept_tool_call()` (wraps `tools/call`). Pass instances at
416+
(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
417+
`vendor-prefix/name` string, enforced when the subclass is defined. Pass instances at
417418
construction:
418419

419420
```python
@@ -425,7 +426,14 @@ mcp = MCPServer("demo", extensions=[Apps()])
425426

426427
The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
427428
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
428-
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback.
429+
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the
430+
client advertised the `text/html;profile=mcp-app` MIME type).
431+
432+
A `MethodBinding` may set `protocol_versions` to scope an extension method to
433+
specific wire versions; a request at any other version is `METHOD_NOT_FOUND`. An
434+
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
435+
to reject a request with the `-32021` (missing required client capability) error
436+
when the client did not declare the extension.
429437

430438
Clients advertise extension support with the new `Client(extensions=...)` /
431439
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.

src/mcp/server/apps.py

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served
55
with the `text/html;profile=mcp-app` MIME type) that the host renders in a
66
sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps
7-
and SEP-2133 for the extension framework.
7+
and the ext-apps spec for the wire format, and SEP-2133 for the extension framework.
88
99
This is a self-contained, additive `Extension`: it contributes tools and
1010
resources and advertises the capability, but does not intercept any core method.
@@ -22,17 +22,22 @@ def get_time(ctx: Context) -> str:
2222
2323
Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should
2424
still return meaningful text for clients that did not negotiate Apps. Use
25-
`client_supports_apps(ctx)` to branch on the client's advertised support.
25+
`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK
26+
keeps Apps in-core under `mcp.server.apps` rather than a separate package; the
27+
TypeScript and C# SDKs ship it as a standalone package.)
2628
"""
2729

2830
from __future__ import annotations
2931

3032
from collections.abc import Callable, Sequence
31-
from typing import Any, TypeVar
33+
from typing import Any, Literal, TypeVar
34+
35+
from pydantic import BaseModel, ConfigDict
36+
from pydantic.alias_generators import to_camel
3237

3338
from mcp.server.context import ServerRequestContext
39+
from mcp.server.extension import Extension, ResourceBinding, ToolBinding
3440
from mcp.server.mcpserver.context import Context
35-
from mcp.server.mcpserver.extension import Extension, ResourceBinding, ToolBinding
3641
from mcp.server.mcpserver.resources import TextResource
3742

3843
EXTENSION_ID = "io.modelcontextprotocol/ui"
@@ -41,9 +46,34 @@ def get_time(ctx: Context) -> str:
4146
APP_MIME_TYPE = "text/html;profile=mcp-app"
4247
"""MIME type for a `ui://` app resource."""
4348

49+
Visibility = Literal["model", "app"]
50+
"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`)."""
51+
4452
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
4553

4654

55+
class ResourcePermissions(BaseModel):
56+
"""Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`)."""
57+
58+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
59+
60+
camera: dict[str, Any] | None = None
61+
microphone: dict[str, Any] | None = None
62+
geolocation: dict[str, Any] | None = None
63+
clipboard_write: dict[str, Any] | None = None
64+
65+
66+
class ResourceCsp(BaseModel):
67+
"""Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`)."""
68+
69+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
70+
71+
connect_domains: list[str] | None = None
72+
resource_domains: list[str] | None = None
73+
frame_domains: list[str] | None = None
74+
base_uri_domains: list[str] | None = None
75+
76+
4777
class Apps(Extension):
4878
"""The MCP Apps extension: bind tools to `ui://` UI resources.
4979
@@ -58,23 +88,36 @@ def __init__(self) -> None:
5888
self._tools: list[ToolBinding] = []
5989
self._resources: list[ResourceBinding] = []
6090

61-
def tool(self, *, resource_uri: str, **tool_kwargs: Any) -> Callable[[_CallableT], _CallableT]:
91+
def tool(
92+
self,
93+
*,
94+
resource_uri: str,
95+
visibility: Sequence[Visibility] | None = None,
96+
meta: dict[str, Any] | None = None,
97+
**tool_kwargs: Any,
98+
) -> Callable[[_CallableT], _CallableT]:
6299
"""Decorator registering a tool bound to a `ui://` resource.
63100
64-
Stamps `_meta.ui.resourceUri` on the tool. `tool_kwargs` are forwarded to
65-
`MCPServer.add_tool` (name, title, description, annotations, ...).
101+
Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
102+
tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
103+
description, annotations, ...); pass `meta=` to merge extra `_meta` keys
104+
alongside the `ui` entry.
66105
67106
Args:
68107
resource_uri: The `ui://` URI of the UI resource this tool renders.
108+
visibility: Where the tool is surfaced (`["model", "app"]`).
109+
meta: Additional `_meta` keys to merge with the `ui` entry.
69110
70111
Raises:
71112
ValueError: If `resource_uri` does not use the `ui://` scheme.
72113
"""
73114
_require_ui_scheme(resource_uri)
115+
ui: dict[str, Any] = {"resourceUri": resource_uri}
116+
if visibility is not None:
117+
ui["visibility"] = list(visibility)
74118

75119
def decorator(fn: _CallableT) -> _CallableT:
76-
meta = {"ui": {"resourceUri": resource_uri}}
77-
self._tools.append(ToolBinding(fn=fn, meta=meta, kwargs=tool_kwargs))
120+
self._tools.append(ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs))
78121
return fn
79122

80123
return decorator
@@ -87,9 +130,16 @@ def add_html_resource(
87130
name: str | None = None,
88131
title: str | None = None,
89132
description: str | None = None,
133+
csp: ResourceCsp | None = None,
134+
permissions: ResourcePermissions | None = None,
135+
domain: str | None = None,
136+
prefers_border: bool | None = None,
90137
) -> None:
91138
"""Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.
92139
140+
`csp`, `permissions`, `domain`, and `prefers_border` populate the
141+
resource's `_meta.ui` per the ext-apps spec.
142+
93143
Args:
94144
uri: The `ui://` URI; a tool references it via `resource_uri`.
95145
html: The HTML document the host renders.
@@ -98,12 +148,22 @@ def add_html_resource(
98148
ValueError: If `uri` does not use the `ui://` scheme.
99149
"""
100150
_require_ui_scheme(uri)
151+
ui: dict[str, Any] = {}
152+
if csp is not None:
153+
ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
154+
if permissions is not None:
155+
ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
156+
if domain is not None:
157+
ui["domain"] = domain
158+
if prefers_border is not None:
159+
ui["prefersBorder"] = prefers_border
101160
resource = TextResource(
102161
uri=uri,
103162
name=name or uri,
104163
title=title,
105164
description=description,
106165
mime_type=APP_MIME_TYPE,
166+
meta={"ui": ui} if ui else None,
107167
text=html,
108168
)
109169
self._resources.append(ResourceBinding(resource=resource))
@@ -118,12 +178,17 @@ def resources(self) -> Sequence[ResourceBinding]:
118178
def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
119179
"""Whether the connected client negotiated MCP Apps support.
120180
121-
Returns `False` when the client did not advertise the extension (or sent no
122-
capabilities), so a UI-enabled tool can fall back to text-only output.
181+
Returns `True` only when the client advertised the extension AND listed the
182+
`text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool
183+
can fall back to text-only output otherwise.
123184
"""
124185
capabilities = _client_capabilities(ctx)
125186
extensions = capabilities.extensions if capabilities else None
126-
return bool(extensions and EXTENSION_ID in extensions)
187+
settings = extensions.get(EXTENSION_ID) if extensions else None
188+
if settings is None:
189+
return False
190+
mime_types = settings.get("mimeTypes")
191+
return mime_types is None or APP_MIME_TYPE in mime_types
127192

128193

129194
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,55 @@
1-
"""Pluggable extension interface for `MCPServer` (SEP-2133).
1+
"""Pluggable extension interface for MCP servers (SEP-2133).
22
33
An extension is a self-contained, opt-in bundle of MCP behaviour, identified by
4-
a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed at
5-
construction - `MCPServer(..., extensions=[Apps(), Tasks(store)])` - and the
6-
server applies a *closed* set of contribution kinds: tools, resources, new
7-
request methods, and one `tools/call` interceptor. The server never hands itself
8-
to an extension; the extension declares what it adds, and the server consumes it.
9-
10-
The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class
11-
whose methods have sensible defaults, so an extension overrides only what it
12-
needs. A purely additive extension (Apps) overrides `tools`/`resources`; an
13-
interceptive one (Tasks) overrides `methods`/`intercept_tool_call`.
4+
a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed to
5+
`MCPServer(extensions=[...])`, and the server applies a *closed* set of
6+
contribution kinds: tools, resources, new request methods, and one `tools/call`
7+
interceptor. The server never hands itself to an extension; the extension
8+
declares what it adds, and the server consumes it.
9+
10+
The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class whose
11+
methods have sensible defaults, so an extension overrides only what it needs. A
12+
purely additive extension (Apps) overrides `tools`/`resources`; an interceptive
13+
one overrides `methods`/`intercept_tool_call`.
14+
15+
This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so that
16+
third-party extensions and helper modules like `mcp.server.apps` depend only on
17+
the base class, never on the composition tier that consumes it.
1418
"""
1519

1620
from __future__ import annotations
1721

22+
import re
1823
from collections.abc import Awaitable, Callable, Sequence
1924
from dataclasses import dataclass, field
20-
from typing import Any
25+
from typing import TYPE_CHECKING, Any
2126

2227
from mcp_types import CallToolRequestParams
2328
from pydantic import BaseModel
2429

2530
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
26-
from mcp.server.mcpserver.resources import Resource
31+
32+
if TYPE_CHECKING:
33+
from mcp.server.mcpserver.resources import Resource
2734

2835
RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]]
2936

37+
# Extension identifiers follow the `_meta` key grammar: a mandatory reverse-DNS
38+
# prefix, a slash, then the extension name (SEP-2133 / the spec's _meta rules).
39+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9.-]+/[A-Za-z0-9._-]+$")
40+
41+
42+
def validate_extension_identifier(identifier: Any, *, owner: str) -> None:
43+
"""Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string.
44+
45+
SEP-2133 requires extension identifiers to carry a reverse-DNS prefix.
46+
"""
47+
if not isinstance(identifier, str) or not _IDENTIFIER_RE.match(identifier):
48+
raise TypeError(
49+
f"{owner}.identifier must be a `vendor-prefix/name` string "
50+
f"(reverse-DNS prefix required), got {identifier!r}"
51+
)
52+
3053

3154
@dataclass(frozen=True)
3255
class ToolBinding:
@@ -49,25 +72,41 @@ class MethodBinding:
4972
"""A new request method an extension serves, e.g. `tasks/get`.
5073
5174
`params_type` validates incoming params before `handler` runs; it should
52-
subclass `RequestParams` so `_meta` parses uniformly.
75+
subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`,
76+
when set, restricts the method to those wire versions - a request for the
77+
method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the
78+
spec's `(method, version)` boundary table. `None` (the default) admits the
79+
method at every version.
5380
"""
5481

5582
method: str
5683
params_type: type[BaseModel]
5784
handler: RequestHandler
85+
protocol_versions: frozenset[str] | None = None
5886

5987

6088
class Extension:
6189
"""Base class for an opt-in MCP extension. Override only the methods you need.
6290
6391
Subclass and set `identifier`, then override the contribution methods that
6492
apply. Every method has a default, so a minimal extension overrides nothing
65-
but `identifier` and one of `tools`/`resources`/`methods`.
93+
but `identifier` and one of `tools`/`resources`/`methods`. `identifier` is
94+
enforced at subclass-definition time.
6695
"""
6796

6897
#: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`.
6998
identifier: str
7099

100+
def __init_subclass__(cls, **kwargs: Any) -> None:
101+
super().__init_subclass__(**kwargs)
102+
# Validate a class-level `identifier` at definition time. A subclass may
103+
# instead assign `identifier` in `__init__` (per-instance ids); that case
104+
# is validated when the extension is applied, since no class attribute
105+
# exists to inspect here.
106+
identifier = cls.__dict__.get("identifier")
107+
if identifier is not None:
108+
validate_extension_identifier(identifier, owner=cls.__name__)
109+
71110
def settings(self) -> dict[str, Any]:
72111
"""Per-extension settings advertised at `capabilities.extensions[identifier]`.
73112

src/mcp/server/mcpserver/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from mcp_types import Icon
44

5+
from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding
6+
57
from .context import Context
6-
from .extension import Extension, MethodBinding, ResourceBinding, ToolBinding
78
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
8-
from .server import MCPServer
9+
from .server import MCPServer, require_client_extension
910
from .utilities.types import Audio, Image
1011

1112
__all__ = [
@@ -18,6 +19,7 @@
1819
"ToolBinding",
1920
"ResourceBinding",
2021
"MethodBinding",
22+
"require_client_extension",
2123
"ResourceSecurity",
2224
"DEFAULT_RESOURCE_SECURITY",
2325
]

0 commit comments

Comments
 (0)