Skip to content

Commit bb9f793

Browse files
committed
Address extension review: identifier grammar, additive-only methods, Apps checks
- Validate extension identifiers against the spec's _meta key grammar (per-label structure, fullmatch so a trailing newline cannot pass) - Reject MethodBindings that name spec-defined request methods, collide with an already-registered handler, or pin an empty version set; the runner's per-version surface gate would never route those anyway - client_supports_apps requires mimeTypes to list text/html;profile=mcp-app, matching the reference implementation; a missing key means unsupported - Require every @apps.tool resource_uri to resolve to a resource registered on the Apps instance, failing at construction instead of 404ing on resources/read; add Apps.add_resource for pre-built ui:// resources - Document the new construction-time errors in the migration guide
1 parent 0f440b1 commit bb9f793

6 files changed

Lines changed: 257 additions & 59 deletions

File tree

docs/migration.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,9 @@ reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
414414
(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()`
416416
(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
418-
construction:
417+
`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level
418+
`identifier` is validated when the subclass is defined, one assigned in `__init__` when
419+
the extension is registered. Pass instances at construction:
419420

420421
```python
421422
from mcp.server.mcpserver import MCPServer
@@ -426,11 +427,20 @@ mcp = MCPServer("demo", extensions=[Apps()])
426427

427428
The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
428429
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
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
430+
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only
431+
when the client's ui-extension settings list the `text/html;profile=mcp-app`
432+
MIME type, per the Apps spec's required `mimeTypes` field. Every
433+
`@apps.tool(resource_uri=...)` must have a matching resource registered on the
434+
same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a
435+
pre-built `Resource`); a tool bound to an unregistered URI raises at
436+
`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime.
437+
438+
Extension methods are strictly additive: a `MethodBinding` cannot name a
439+
spec-defined request method, and registering one whose method collides with
440+
another handler raises at construction. A `MethodBinding` may set
441+
`protocol_versions` to scope an extension method to specific wire versions
442+
(`frozenset()` is rejected — use `None` to admit every version); a request at
443+
any other version is `METHOD_NOT_FOUND`. An
434444
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
435445
to reject a request with the `-32021` (missing required client capability) error
436446
when the client did not declare the extension.

src/mcp/server/apps.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def get_time(ctx: Context) -> str:
3838
from mcp.server.context import ServerRequestContext
3939
from mcp.server.extension import Extension, ResourceBinding, ToolBinding
4040
from mcp.server.mcpserver.context import Context
41-
from mcp.server.mcpserver.resources import TextResource
41+
from mcp.server.mcpserver.resources import Resource, TextResource
4242

4343
EXTENSION_ID = "io.modelcontextprotocol/ui"
4444
"""The MCP Apps extension identifier (the shipped TS/C# constant)."""
@@ -85,7 +85,7 @@ class Apps(Extension):
8585
identifier = EXTENSION_ID
8686

8787
def __init__(self) -> None:
88-
self._tools: list[ToolBinding] = []
88+
self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri)
8989
self._resources: list[ResourceBinding] = []
9090

9191
def tool(
@@ -117,7 +117,8 @@ def tool(
117117
ui["visibility"] = list(visibility)
118118

119119
def decorator(fn: _CallableT) -> _CallableT:
120-
self._tools.append(ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs))
120+
binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
121+
self._tools.append((binding, resource_uri))
121122
return fn
122123

123124
return decorator
@@ -147,7 +148,6 @@ def add_html_resource(
147148
Raises:
148149
ValueError: If `uri` does not use the `ui://` scheme.
149150
"""
150-
_require_ui_scheme(uri)
151151
ui: dict[str, Any] = {}
152152
if csp is not None:
153153
ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
@@ -157,19 +157,48 @@ def add_html_resource(
157157
ui["domain"] = domain
158158
if prefers_border is not None:
159159
ui["prefersBorder"] = prefers_border
160-
resource = TextResource(
161-
uri=uri,
162-
name=name or uri,
163-
title=title,
164-
description=description,
165-
mime_type=APP_MIME_TYPE,
166-
meta={"ui": ui} if ui else None,
167-
text=html,
160+
self.add_resource(
161+
TextResource(
162+
uri=uri,
163+
name=name or uri,
164+
title=title,
165+
description=description,
166+
mime_type=APP_MIME_TYPE,
167+
meta={"ui": ui} if ui else None,
168+
text=html,
169+
)
168170
)
171+
172+
def add_resource(self, resource: Resource) -> None:
173+
"""Register a pre-built `ui://` resource.
174+
175+
The escape hatch for resources `add_html_resource` cannot express (e.g. a
176+
`FileResource` serving HTML from disk). The resource should carry the
177+
`text/html;profile=mcp-app` MIME type for hosts to render it.
178+
179+
Raises:
180+
ValueError: If the resource URI does not use the `ui://` scheme.
181+
"""
182+
_require_ui_scheme(resource.uri)
169183
self._resources.append(ResourceBinding(resource=resource))
170184

171185
def tools(self) -> Sequence[ToolBinding]:
172-
return self._tools
186+
"""The bound tools.
187+
188+
Raises:
189+
ValueError: If a tool's `resource_uri` has no matching resource
190+
registered on this instance — a tool advertising a
191+
`_meta.ui.resourceUri` that 404s on `resources/read` is a
192+
misconfiguration, caught when the server consumes the extension.
193+
"""
194+
registered = {binding.resource.uri for binding in self._resources}
195+
for tool, uri in self._tools:
196+
if uri not in registered:
197+
raise ValueError(
198+
f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
199+
"is registered; add it with add_html_resource() or add_resource()"
200+
)
201+
return [tool for tool, _ in self._tools]
173202

174203
def resources(self) -> Sequence[ResourceBinding]:
175204
return self._resources
@@ -188,7 +217,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) ->
188217
if settings is None:
189218
return False
190219
mime_types = settings.get("mimeTypes")
191-
return mime_types is None or APP_MIME_TYPE in mime_types
220+
return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types
192221

193222

194223
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:

src/mcp/server/extension.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
purely additive extension (Apps) overrides `tools`/`resources`; an interceptive
1313
one overrides `methods`/`intercept_tool_call`.
1414
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.
15+
This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so the
16+
base class itself never drags in the composition tier that consumes it;
17+
extensions remain importable without constructing an `MCPServer`.
1818
"""
1919

2020
from __future__ import annotations
@@ -25,6 +25,7 @@
2525
from typing import TYPE_CHECKING, Any
2626

2727
from mcp_types import CallToolRequestParams
28+
from mcp_types.methods import SPEC_CLIENT_METHODS
2829
from pydantic import BaseModel
2930

3031
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
@@ -34,17 +35,21 @@
3435

3536
RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]]
3637

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._-]+$")
38+
# Extension identifiers follow the `_meta` key grammar with a mandatory prefix
39+
# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a
40+
# letter and ending with a letter or digit (hyphens interior), then `/`, then a
41+
# name that starts and ends alphanumeric (`.`/`_`/`-` interior).
42+
_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
43+
_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?"
44+
_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}")
4045

4146

4247
def validate_extension_identifier(identifier: Any, *, owner: str) -> None:
4348
"""Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string.
4449
4550
SEP-2133 requires extension identifiers to carry a reverse-DNS prefix.
4651
"""
47-
if not isinstance(identifier, str) or not _IDENTIFIER_RE.match(identifier):
52+
if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier):
4853
raise TypeError(
4954
f"{owner}.identifier must be a `vendor-prefix/name` string "
5055
f"(reverse-DNS prefix required), got {identifier!r}"
@@ -77,13 +82,34 @@ class MethodBinding:
7782
method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the
7883
spec's `(method, version)` boundary table. `None` (the default) admits the
7984
method at every version.
85+
86+
Extension methods are additive: `method` must not name a spec-defined
87+
request method (`tools/list`, `completion/complete`, ...) — those handlers
88+
belong to the server, and an extension binding one would silently shadow or
89+
be shadowed by it. Both constraints are enforced at construction. To
90+
re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel`
91+
for legacy clients), use the lowlevel `Server.add_request_handler` API
92+
instead — the runner's per-version surface gate would never route such a
93+
method to an extension handler anyway.
8094
"""
8195

8296
method: str
8397
params_type: type[BaseModel]
8498
handler: RequestHandler
8599
protocol_versions: frozenset[str] | None = None
86100

101+
def __post_init__(self) -> None:
102+
if self.method in SPEC_CLIENT_METHODS:
103+
raise ValueError(
104+
f"MethodBinding cannot bind spec method {self.method!r}; extension methods are "
105+
"additive — use Extension.intercept_tool_call or Server.middleware to wrap core behaviour"
106+
)
107+
if self.protocol_versions is not None and not self.protocol_versions:
108+
raise ValueError(
109+
f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could "
110+
"never be served; use None to admit every version"
111+
)
112+
87113

88114
class Extension:
89115
"""Base class for an opt-in MCP extension. Override only the methods you need.

src/mcp/server/mcpserver/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ def _apply_extension(self, extension: Extension) -> None:
291291
for resource in extension.resources():
292292
self.add_resource(resource.resource)
293293
for method in extension.methods():
294+
if self._lowlevel_server.get_request_handler(method.method) is not None:
295+
raise ValueError(
296+
f"Extension {identifier!r} binds method {method.method!r}, which is already "
297+
"registered; extension methods are additive and cannot replace another handler"
298+
)
294299
handler = _version_gated(method) if method.protocol_versions is not None else method.handler
295300
self._lowlevel_server.add_request_handler(method.method, method.params_type, handler)
296301

tests/server/mcpserver/test_extension.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ResourceBinding,
2727
ToolBinding,
2828
compose_tool_call_interceptor,
29+
validate_extension_identifier,
2930
)
3031
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
3132
from mcp.server.mcpserver.resources import TextResource
@@ -74,16 +75,18 @@ class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
7475
params: _PingParams
7576

7677

78+
async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
79+
"""The shared `com.example/ping` handler (dispatched by the reachability test)."""
80+
return _PingResult(pong=True)
81+
82+
7783
class _MethodExt(Extension):
7884
"""Override `methods()` to serve a new vendor request verb."""
7985

8086
identifier = "com.example/method"
8187

82-
def methods(self):
83-
async def handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
84-
return _PingResult(pong=True)
85-
86-
return [MethodBinding("com.example/ping", _PingParams, handler)]
88+
def methods(self) -> list[MethodBinding]:
89+
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
8790

8891

8992
class _ReplacingExt(Extension):
@@ -356,6 +359,91 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version
356359
await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
357360

358361
assert exc_info.value.code == METHOD_NOT_FOUND
362+
assert exc_info.value.error.data == "com.example/pinned"
363+
364+
365+
@pytest.mark.parametrize(
366+
"identifier",
367+
[
368+
"io.modelcontextprotocol/ui",
369+
"com.example/my_ext",
370+
"com.x-y.z2/n.a-b_c",
371+
"example/x",
372+
"a/b",
373+
"com.example/9start",
374+
],
375+
)
376+
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
377+
"""Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
378+
hyphens interior), a slash, then a name that starts and ends alphanumeric."""
379+
validate_extension_identifier(identifier, owner="T")
380+
381+
382+
@pytest.mark.parametrize(
383+
"identifier",
384+
[
385+
"noprefix",
386+
"-foo/bar",
387+
".leading/x",
388+
"a..b/x",
389+
"foo-/x",
390+
"9foo/x",
391+
"foo/-bar",
392+
"foo/bar-",
393+
"foo/",
394+
"/bar",
395+
"foo/ba r",
396+
"io.modelcontextprotocol/ui\n",
397+
"",
398+
None,
399+
42,
400+
],
401+
)
402+
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
403+
"""Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
404+
and malformed names are rejected, as are non-strings."""
405+
with pytest.raises(TypeError):
406+
validate_extension_identifier(identifier, owner="T")
407+
408+
409+
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
410+
def test_method_binding_rejects_spec_methods(method: str) -> None:
411+
"""SDK-defined: extension methods are additive — binding a spec-defined request method
412+
would silently shadow (or be shadowed by) the server's own handler, so it is rejected
413+
when the binding is constructed."""
414+
with pytest.raises(ValueError):
415+
MethodBinding(method, _PingParams, _pong_handler)
416+
417+
418+
def test_method_binding_rejects_empty_protocol_versions() -> None:
419+
"""SDK-defined: an empty `protocol_versions` set would make the method unreachable at
420+
every version; `None` is the universal-version spelling."""
421+
with pytest.raises(ValueError) as exc_info:
422+
MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
423+
assert str(exc_info.value) == snapshot(
424+
"MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
425+
"never be served; use None to admit every version"
426+
)
427+
428+
429+
class _OtherMethodExt(Extension):
430+
"""A second extension binding the same verb as `_MethodExt`."""
431+
432+
identifier = "com.example/other-method"
433+
434+
def methods(self) -> list[MethodBinding]:
435+
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
436+
437+
438+
def test_colliding_extension_methods_are_rejected_at_registration() -> None:
439+
"""SDK-defined: two extensions binding the same method would silently last-write-win;
440+
the collision is rejected when the second extension is applied."""
441+
with pytest.raises(ValueError) as exc_info:
442+
MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
443+
assert str(exc_info.value) == snapshot(
444+
"Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
445+
"registered; extension methods are additive and cannot replace another handler"
446+
)
359447

360448

361449
_NEEDS_EXT = "com.example/needed"

0 commit comments

Comments
 (0)