` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
+### Server extensions API (SEP-2133)
+
+`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
+reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
+(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension`
+and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
+(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
+`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level
+`identifier` is validated when the subclass is defined, one assigned in `__init__` when
+the extension is registered. Pass instances at construction:
+
+```python
+from mcp.server.mcpserver import MCPServer
+from mcp.server.apps import Apps
+
+mcp = MCPServer("demo", extensions=[Apps()])
+```
+
+The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
+it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
+`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only
+when the client's ui-extension settings list the `text/html;profile=mcp-app`
+MIME type, per the Apps spec's required `mimeTypes` field. Every
+`@apps.tool(resource_uri=...)` must have a matching resource registered on the
+same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a
+pre-built `Resource`); a tool bound to an unregistered URI raises at
+`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime.
+
+Extension methods are strictly additive: a `MethodBinding` cannot name a
+spec-defined request method, and registering one whose method collides with
+another handler raises at construction. A `MethodBinding` may set
+`protocol_versions` to scope an extension method to specific wire versions
+(`frozenset()` is rejected — use `None` to admit every version); a request at
+any other version is `METHOD_NOT_FOUND`. An
+extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
+to reject a request with the `-32021` (missing required client capability) error
+when the client did not declare the extension.
+
+Clients advertise extension support with the new `Client(extensions=...)` /
+`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
+The extensions capability map is negotiated over `server/discover` (modern path);
+a legacy `initialize` handshake does not carry it. Extensions are off by default
+and never alter behaviour unless registered.
+
### `McpError` renamed to `MCPError`
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
diff --git a/docs_src/apps/__init__.py b/docs_src/apps/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/apps/report.html b/docs_src/apps/report.html
new file mode 100644
index 000000000..7c94deefe
--- /dev/null
+++ b/docs_src/apps/report.html
@@ -0,0 +1,3 @@
+
+Report
+Quarterly numbers render here.
diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py
new file mode 100644
index 000000000..79721c597
--- /dev/null
+++ b/docs_src/apps/tutorial001.py
@@ -0,0 +1,38 @@
+from mcp import Client
+from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
+from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver.context import Context
+
+CLOCK_HTML = """\
+
+Clock
+...
+
+"""
+
+apps = Apps()
+
+
+@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
+def get_time(ctx: Context) -> str:
+ now = "2026-06-26T12:00:00Z"
+ if not client_supports_apps(ctx):
+ return f"The time is {now}."
+ return now
+
+
+apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
+
+mcp = MCPServer("clock", extensions=[apps])
+
+
+async def main() -> None:
+ async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
+ result = await client.call_tool("get_time", {})
+ print(result.content)
+ # [TextContent(text='2026-06-26T12:00:00Z')]
diff --git a/docs_src/apps/tutorial002.py b/docs_src/apps/tutorial002.py
new file mode 100644
index 000000000..11393285b
--- /dev/null
+++ b/docs_src/apps/tutorial002.py
@@ -0,0 +1,25 @@
+from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
+from mcp.server.mcpserver import MCPServer
+
+DASHBOARD_HTML = "Dashboard"
+
+apps = Apps()
+
+
+@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
+def refresh_dashboard() -> str:
+ """Refresh the dashboard data."""
+ return "refreshed"
+
+
+apps.add_html_resource(
+ "ui://dashboard/app.html",
+ DASHBOARD_HTML,
+ title="Dashboard",
+ csp=ResourceCsp(connect_domains=["https://api.example.com"]),
+ permissions=ResourcePermissions(clipboard_write={}),
+ domain="dashboard.example.com",
+ prefers_border=True,
+)
+
+mcp = MCPServer("dashboard", extensions=[apps])
diff --git a/docs_src/apps/tutorial003.py b/docs_src/apps/tutorial003.py
new file mode 100644
index 000000000..e3aed3ef7
--- /dev/null
+++ b/docs_src/apps/tutorial003.py
@@ -0,0 +1,20 @@
+from pathlib import Path
+
+from mcp.server.apps import Apps
+from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver.resources import FileResource
+
+REPORT_HTML = Path(__file__).parent / "report.html"
+
+apps = Apps()
+
+
+@apps.tool(resource_uri="ui://report/app.html")
+def refresh_report() -> str:
+ """Refresh the report data."""
+ return "report refreshed"
+
+
+apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
+
+mcp = MCPServer("report", extensions=[apps])
diff --git a/docs_src/extensions/__init__.py b/docs_src/extensions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py
new file mode 100644
index 000000000..1e5a1f907
--- /dev/null
+++ b/docs_src/extensions/tutorial001.py
@@ -0,0 +1,4 @@
+from mcp.server.apps import Apps
+from mcp.server.mcpserver import MCPServer
+
+mcp = MCPServer("demo", extensions=[Apps()])
diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py
new file mode 100644
index 000000000..87b59bd23
--- /dev/null
+++ b/docs_src/extensions/tutorial002.py
@@ -0,0 +1,5 @@
+from mcp.server.extension import Extension
+
+
+class Stamps(Extension):
+ identifier = "com.example/stamps"
diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py
new file mode 100644
index 000000000..312371bee
--- /dev/null
+++ b/docs_src/extensions/tutorial003.py
@@ -0,0 +1,35 @@
+from collections.abc import Sequence
+from typing import Any
+
+from mcp import Client
+from mcp.server.extension import Extension, ToolBinding
+from mcp.server.mcpserver import MCPServer
+
+
+def stamp(text: str) -> str:
+ """Stamp a message with the office seal."""
+ return f"[stamped] {text}"
+
+
+class Stamps(Extension):
+ """A purely additive extension: one tool, one capability entry."""
+
+ identifier = "com.example/stamps"
+
+ def settings(self) -> dict[str, Any]:
+ return {"sealed": True}
+
+ def tools(self) -> Sequence[ToolBinding]:
+ return [ToolBinding(fn=stamp)]
+
+
+mcp = MCPServer("post-office", extensions=[Stamps()])
+
+
+async def main() -> None:
+ async with Client(mcp) as client:
+ print(client.server_capabilities.extensions)
+ # {'com.example/stamps': {'sealed': True}}
+ result = await client.call_tool("stamp", {"text": "hello"})
+ print(result.content)
+ # [TextContent(text='[stamped] hello')]
diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py
new file mode 100644
index 000000000..4a0a022af
--- /dev/null
+++ b/docs_src/extensions/tutorial004.py
@@ -0,0 +1,58 @@
+from collections.abc import Sequence
+from typing import Any, Literal, cast
+
+import mcp_types as types
+from pydantic import Field
+
+from mcp import Client
+from mcp.server.context import ServerRequestContext
+from mcp.server.extension import Extension, MethodBinding
+from mcp.server.mcpserver import MCPServer, require_client_extension
+
+EXTENSION_ID = "com.example/search"
+
+
+class SearchParams(types.RequestParams):
+ query: str
+ limit: int = Field(default=10, ge=1, le=100)
+
+
+class SearchResult(types.Result):
+ items: list[str]
+
+
+class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
+ method: Literal["com.example/search"] = "com.example/search"
+ params: SearchParams
+
+
+async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
+ require_client_extension(ctx, EXTENSION_ID)
+ return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
+
+
+class Search(Extension):
+ """An extension that serves its own request method."""
+
+ identifier = EXTENSION_ID
+
+ def methods(self) -> Sequence[MethodBinding]:
+ return [
+ MethodBinding(
+ "com.example/search",
+ SearchParams,
+ search,
+ protocol_versions=frozenset({"2026-07-28"}),
+ )
+ ]
+
+
+mcp = MCPServer("catalog", extensions=[Search()])
+
+
+async def main() -> None:
+ async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
+ request = SearchRequest(params=SearchParams(query="mcp", limit=3))
+ result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
+ print(result.items)
+ # ['mcp-0', 'mcp-1', 'mcp-2']
diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py
new file mode 100644
index 000000000..61ec6c76b
--- /dev/null
+++ b/docs_src/extensions/tutorial005.py
@@ -0,0 +1,34 @@
+import logging
+from typing import Any
+
+from mcp_types import CallToolRequestParams
+
+from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
+from mcp.server.extension import Extension
+from mcp.server.mcpserver import MCPServer
+
+logger = logging.getLogger(__name__)
+
+
+class AuditLog(Extension):
+ """Observe every tools/call without touching its result."""
+
+ identifier = "com.example/audit"
+
+ async def intercept_tool_call(
+ self,
+ params: CallToolRequestParams,
+ ctx: ServerRequestContext[Any, Any],
+ call_next: CallNext,
+ ) -> HandlerResult:
+ logger.info("tool %r called", params.name)
+ return await call_next(ctx)
+
+
+mcp = MCPServer("audited", extensions=[AuditLog()])
+
+
+@mcp.tool()
+def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md
index b802525fa..dc180a0d3 100644
--- a/examples/stories/apps/README.md
+++ b/examples/stories/apps/README.md
@@ -1,14 +1,40 @@
# apps
-MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource
-that the host renders as an interactive surface. The story will register a
-`@ui` resource and return it from a tool.
+MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://`
+resource that the host renders as an interactive surface. The server opts in via
+the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by
+advertising the `text/html;profile=mcp-app` MIME type.
-**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
-The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
-cannot advertise Apps support and a client cannot negotiate it.
+## Run it
+
+```bash
+# stdio (default — the client spawns the server as a subprocess)
+uv run python -m stories.apps.client
+
+# HTTP — the client self-hosts the server on a free port, runs, then tears it down
+uv run python -m stories.apps.client --http
+```
+
+## What to look at
+
+- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension
+ advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions`
+ and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
+ never learns about "ui"; it applies a closed set of contributions.
+- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
+ the tool; `add_html_resource` registers the matching `ui://` resource at
+ `text/html;profile=mcp-app`.
+- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
+ client that did not negotiate Apps gets a text-only result.
+- `client.py` `Client(target, extensions={...})` — the client advertises Apps
+ support so the server returns the UI-enabled result, then reads the tool's
+ `_meta.ui.resourceUri` and fetches that resource.
## Spec
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
+
+## See also
+
+`custom_methods/` (registering a non-spec method without an extension).
diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py
new file mode 100644
index 000000000..8a238f469
--- /dev/null
+++ b/examples/stories/apps/client.py
@@ -0,0 +1,35 @@
+"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""
+
+from mcp_types import TextContent, TextResourceContents
+
+from mcp.client import Client
+from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
+from stories._harness import Target, run_client
+
+
+async def main(target: Target, *, mode: str = "auto") -> None:
+ # Advertise MCP Apps support so the server returns the UI-enabled result; a
+ # client that omits this gets the text-only fallback (graceful degradation).
+ async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
+ # The extensions capability map rides `server/discover` (modern only). On a
+ # legacy connection (today's stdio) it is absent, so assert it only when present.
+ if client.server_capabilities.extensions is not None:
+ assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions
+
+ listed = await client.list_tools()
+ tool = next(t for t in listed.tools if t.name == "get_time")
+ assert tool.meta is not None, tool
+ assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta
+
+ ui = await client.read_resource("ui://get-time/app.html")
+ contents = ui.contents[0]
+ assert isinstance(contents, TextResourceContents)
+ assert contents.mime_type == APP_MIME_TYPE, contents.mime_type
+
+ result = await client.call_tool("get_time", {})
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text
+
+
+if __name__ == "__main__":
+ run_client(main)
diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py
new file mode 100644
index 000000000..74d412e02
--- /dev/null
+++ b/examples/stories/apps/server.py
@@ -0,0 +1,43 @@
+"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface.
+
+`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The
+`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the
+tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool
+degrades gracefully: `client_supports_apps(ctx)` reports whether the client
+negotiated Apps, so it returns text-only output otherwise.
+"""
+
+from mcp.server.apps import Apps, client_supports_apps
+from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver.context import Context
+from stories._hosting import run_server_from_args
+
+RESOURCE_URI = "ui://get-time/app.html"
+CLOCK_HTML = """
+Current time
+…
+
+"""
+
+
+def build_server() -> MCPServer:
+ apps = Apps()
+
+ @apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
+ def get_time(ctx: Context) -> str:
+ now = "2026-06-26T00:00:00Z"
+ if not client_supports_apps(ctx):
+ return f"The time is {now}."
+ return now
+
+ apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
+ return MCPServer("apps-example", extensions=[apps])
+
+
+if __name__ == "__main__":
+ run_server_from_args(build_server)
diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md
new file mode 100644
index 000000000..6d3da72c9
--- /dev/null
+++ b/examples/stories/extensions/README.md
@@ -0,0 +1,41 @@
+# extensions
+
+Writing your own extension (SEP-2133): one identifier bundles a settings entry
+under `ServerCapabilities.extensions`, a contributed tool, and a vendor request
+method gated on the client declaring the extension back.
+
+## Run it
+
+```bash
+# stdio (default — the client spawns the server as a subprocess)
+uv run python -m stories.extensions.client
+
+# HTTP — the client self-hosts the server on a free port, runs, then tears it down
+uv run python -m stories.extensions.client --http
+```
+
+## What to look at
+
+- `server.py` `class Catalog(Extension)` — the whole extension: `settings()`
+ becomes the advertised capability entry, `tools()` contributes a regular tool,
+ `methods()` registers a vendor verb. The extension never holds the server; it
+ declares contributions and `MCPServer(extensions=[...])` consumes them.
+- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method
+ rejects clients that did not declare the extension with `-32021` (missing
+ required client capability) and a machine-readable `requiredCapabilities`
+ payload.
+- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side
+ half of the negotiation; on 2026-07-28 it travels in the per-request `_meta`
+ envelope.
+- `client.py` `client.session.send_request(...)` — vendor methods have no
+ `Client`-level helper; the session escape hatch sends them.
+
+## Spec
+
+[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
+· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index)
+
+## See also
+
+`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb
+registered on the lowlevel `Server` by hand, without an extension).
diff --git a/examples/stories/extensions/__init__.py b/examples/stories/extensions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py
new file mode 100644
index 000000000..d3aacc140
--- /dev/null
+++ b/examples/stories/extensions/client.py
@@ -0,0 +1,54 @@
+"""Discover an extension's capability entry, call its tool, then send its vendor method."""
+
+from typing import Literal, cast
+
+import mcp_types as types
+from mcp_types import TextContent
+
+from mcp.client import Client
+from stories._harness import Target, run_client
+
+EXTENSION_ID = "com.example/catalog"
+
+
+class SearchParams(types.RequestParams):
+ query: str
+ limit: int = 3
+
+
+class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
+ method: Literal["com.example/search"] = "com.example/search"
+ params: SearchParams
+
+
+class SearchResult(types.Result):
+ items: list[str]
+
+
+async def main(target: Target, *, mode: str = "auto") -> None:
+ # Declare the extension client-side so the server's `require_client_extension`
+ # gate on `com.example/search` passes.
+ async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client:
+ # The extensions capability map rides `server/discover` (modern only). On a
+ # legacy connection it is absent, so assert it only when present.
+ if client.server_capabilities.extensions is not None:
+ assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, (
+ client.server_capabilities.extensions
+ )
+
+ # The extension's tool is a regular tool: listed and callable like any other.
+ listed = await client.list_tools()
+ assert [tool.name for tool in listed.tools] == ["suggest"], listed
+ result = await client.call_tool("suggest", {"prefix": "mcp"})
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "mcp-suggestion", result.content[0].text
+
+ # Vendor methods drop one layer to `client.session` (see custom_methods/);
+ # the cast is needed because `send_request` is typed against the spec union.
+ request = SearchRequest(params=SearchParams(query="mcp", limit=3))
+ found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
+ assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found
+
+
+if __name__ == "__main__":
+ run_client(main)
diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py
new file mode 100644
index 000000000..837c668dc
--- /dev/null
+++ b/examples/stories/extensions/server.py
@@ -0,0 +1,64 @@
+"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133).
+
+`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story
+bundles the same idea as an `Extension`: declared contributions, a settings entry
+under `ServerCapabilities.extensions`, and a `require_client_extension` gate on
+the vendor method.
+"""
+
+from collections.abc import Sequence
+from typing import Any
+
+import mcp_types as types
+from pydantic import Field
+
+from mcp.server.context import ServerRequestContext
+from mcp.server.extension import Extension, MethodBinding, ToolBinding
+from mcp.server.mcpserver import MCPServer, require_client_extension
+from stories._hosting import run_server_from_args
+
+EXTENSION_ID = "com.example/catalog"
+
+
+class SearchParams(types.RequestParams):
+ """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly."""
+
+ query: str
+ limit: int = Field(default=3, ge=1, le=25)
+
+
+class SearchResult(types.Result):
+ items: list[str]
+
+
+def suggest(prefix: str) -> str:
+ """Suggest a catalog entry for a prefix."""
+ return f"{prefix}-suggestion"
+
+
+async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
+ require_client_extension(ctx, EXTENSION_ID)
+ return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
+
+
+class Catalog(Extension):
+ """One identifier, three contributions: settings, a tool, a vendor method."""
+
+ identifier = EXTENSION_ID
+
+ def settings(self) -> dict[str, Any]:
+ return {"suggest": True}
+
+ def tools(self) -> Sequence[ToolBinding]:
+ return [ToolBinding(fn=suggest)]
+
+ def methods(self) -> Sequence[MethodBinding]:
+ return [MethodBinding("com.example/search", SearchParams, search)]
+
+
+def build_server() -> MCPServer:
+ return MCPServer("extensions-example", extensions=[Catalog()])
+
+
+if __name__ == "__main__":
+ run_server_from_args(build_server)
diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml
index fb688d294..c89f8a8c5 100644
--- a/examples/stories/manifest.toml
+++ b/examples/stories/manifest.toml
@@ -48,6 +48,21 @@ status = "deprecated"
[story.custom_methods]
lowlevel = false
+[story.apps]
+# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant.
+# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
+# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
+lowlevel = false
+transports = ["in-memory", "http-asgi"]
+era = "dual-in-body"
+
+[story.extensions]
+# Same constraints as `apps`: MCPServer-tier extension API, capability map rides
+# server/discover (modern only), client guards the capability assert by presence.
+lowlevel = false
+transports = ["in-memory", "http-asgi"]
+era = "dual-in-body"
+
[story.schema_validators]
[story.middleware]
@@ -142,7 +157,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
[deferred]
caching = "client honouring + per-result override unlanded"
subscriptions = "#2901 — Client.listen / ServerEventBus"
-tasks = "extensions capability map + tasks runtime"
-apps = "#2896 — extensions capability map"
+tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
skills = "#2896 — SEP-2640"
events = "#2901 + #2896"
diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md
index ef15ae63f..d1956d1e3 100644
--- a/examples/stories/tasks/README.md
+++ b/examples/stories/tasks/README.md
@@ -1,16 +1,24 @@
# tasks
-The `io.modelcontextprotocol/tasks` extension: long-running work registered
-with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with
-`tasks/cancel`. The story will show a task that outlives the request that
-started it.
+Task-augmented execution: a requestor augments a `tools/call` with a `task`, the
+receiver returns a `CreateTaskResult` immediately, and the requestor polls
+`tasks/get` and retrieves the deferred result.
-**Status: not yet implemented.** The extension types exist but the `extensions`
-capability map is not yet surfaced on `MCPServer`, and the runtime trails the
-release. The TypeScript SDK deliberately removed its tasks example pending the
-same work.
+**Status: deferred.** Tasks ship in 2026-07-28 as
+[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md),
+an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the
+2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime
+needs to be built to the SEP — server-decided augmentation (ignoring the legacy
+`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the
+`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs`
+fields — so it lands in a separate PR with the conformance `tasks-*` scenarios
+wired in.
## Spec
-[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)
+[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
+
+## See also
+
+`apps/` (the additive half of the extension API).
diff --git a/mkdocs.yml b/mkdocs.yml
index 93127a410..e22e47e1d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -43,6 +43,8 @@ nav:
- URI templates: advanced/uri-templates.md
- Pagination: advanced/pagination.md
- Middleware: advanced/middleware.md
+ - Extensions: advanced/extensions.md
+ - MCP Apps: advanced/apps.md
- OpenTelemetry: advanced/opentelemetry.md
- Authorization: advanced/authorization.md
- OAuth clients: advanced/oauth-clients.md
diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py
index d6a6e4caa..d3290f308 100644
--- a/src/mcp/client/client.py
+++ b/src/mcp/client/client.py
@@ -217,6 +217,10 @@ async def main():
`read_resource` give up. Use `client.session.(..., allow_input_required=True)`
to drive the loop manually instead."""
+ extensions: dict[str, dict[str, Any]] | None = None
+ """SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
+ (identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
+
_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
@@ -255,6 +259,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
+ extensions=self.extensions,
)
async def __aenter__(self) -> Client:
diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py
index fa71d1330..3cebb569e 100644
--- a/src/mcp/client/session.py
+++ b/src/mcp/client/session.py
@@ -224,12 +224,14 @@ def __init__(
client_info: types.Implementation | None = None,
*,
sampling_capabilities: types.SamplingCapability | None = None,
+ extensions: dict[str, dict[str, Any]] | None = None,
dispatcher: Dispatcher[Any] | None = None,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO
self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities
+ self._extensions = extensions
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
@@ -369,7 +371,9 @@ def _build_capabilities(self) -> types.ClientCapabilities:
if self._list_roots_callback is not _default_list_roots_callback
else None
)
- return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
+ return types.ClientCapabilities(
+ sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots
+ )
async def initialize(self) -> types.InitializeResult:
if self._initialize_result is not None:
diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py
new file mode 100644
index 000000000..d5b9d9ed8
--- /dev/null
+++ b/src/mcp/server/apps.py
@@ -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}")
diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py
index 76917f896..4d9496fef 100644
--- a/src/mcp/server/connection.py
+++ b/src/mcp/server/connection.py
@@ -345,4 +345,14 @@ def check_capability(self, capability: ClientCapabilities) -> bool:
for k, v in capability.experimental.items():
if k not in have.experimental or have.experimental[k] != v:
return False
+ if capability.extensions is not None:
+ # SEP-2133: an extension is supported when the client declares its
+ # identifier. Settings are negotiated per-extension (the client may
+ # advertise more than the server asks for), so presence - not value
+ # equality - is the meaningful check.
+ if have.extensions is None:
+ return False
+ for identifier in capability.extensions:
+ if identifier not in have.extensions:
+ return False
return True
diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py
new file mode 100644
index 000000000..e045e6f29
--- /dev/null
+++ b/src/mcp/server/extension.py
@@ -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
diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py
index bbd2ff331..76a66b352 100644
--- a/src/mcp/server/lowlevel/server.py
+++ b/src/mcp/server/lowlevel/server.py
@@ -434,6 +434,10 @@ def __init__(
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
# v2 final.
self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]
+ # SEP-2133 extension settings advertised under `ServerCapabilities.extensions`
+ # (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`)
+ # populate it; `get_capabilities` reads it when no explicit map is passed.
+ self.extensions: dict[str, dict[str, Any]] = {}
logger.debug("Initializing server %r", name)
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
@@ -521,8 +525,15 @@ def create_initialization_options(
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
+ extensions: dict[str, dict[str, Any]] | None = None,
) -> InitializationOptions:
- """Create initialization options from this server instance."""
+ """Create initialization options from this server instance.
+
+ `extensions` advertises SEP-2133 extension support under
+ `ServerCapabilities.extensions`; keys are extension identifiers (e.g.
+ `io.modelcontextprotocol/ui`), values are per-extension settings.
+ Defaults to `self.extensions`, which higher layers populate.
+ """
return InitializationOptions(
server_name=self.name,
server_version=self.version if self.version else _package_version("mcp"),
@@ -531,6 +542,7 @@ def create_initialization_options(
capabilities=self.get_capabilities(
notification_options or NotificationOptions(),
experimental_capabilities or {},
+ extensions if extensions is not None else self.extensions,
),
instructions=self.instructions,
website_url=self.website_url,
@@ -541,8 +553,14 @@ def get_capabilities(
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
+ extensions: dict[str, dict[str, Any]] | None = None,
) -> types.ServerCapabilities:
- """Convert existing handlers to a ServerCapabilities object."""
+ """Convert existing handlers to a ServerCapabilities object.
+
+ `extensions` is the SEP-2133 extension map (identifier -> settings)
+ advertised under `ServerCapabilities.extensions`; it defaults to
+ `self.extensions`.
+ """
notification_options = notification_options or NotificationOptions()
prompts_capability = None
resources_capability = None
@@ -579,6 +597,7 @@ def get_capabilities(
tools=tools_capability,
logging=logging_capability,
experimental=experimental_capabilities,
+ extensions=extensions if extensions is not None else (self.extensions or None),
completions=completions_capability,
)
return capabilities
diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py
index 741f16beb..7a8da42fe 100644
--- a/src/mcp/server/mcpserver/__init__.py
+++ b/src/mcp/server/mcpserver/__init__.py
@@ -2,9 +2,11 @@
from mcp_types import Icon
+from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding
+
from .context import Context
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
-from .server import MCPServer
+from .server import MCPServer, require_client_extension
from .utilities.types import Audio, Image
__all__ = [
@@ -13,6 +15,11 @@
"Image",
"Audio",
"Icon",
+ "Extension",
+ "ToolBinding",
+ "ResourceBinding",
+ "MethodBinding",
+ "require_client_extension",
"ResourceSecurity",
"DEFAULT_RESOURCE_SECURITY",
]
diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py
index a25213e7b..e295e21e0 100644
--- a/src/mcp/server/mcpserver/resources/types.py
+++ b/src/mcp/server/mcpserver/resources/types.py
@@ -26,7 +26,7 @@ class TextResource(Resource):
async def read(self) -> str:
"""Read the text content."""
- return self.text # pragma: no cover
+ return self.text
class BinaryResource(Resource):
diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py
index 029512a78..33348c083 100644
--- a/src/mcp/server/mcpserver/server.py
+++ b/src/mcp/server/mcpserver/server.py
@@ -4,7 +4,7 @@
import base64
import inspect
-from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
+from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Generic, Literal, TypeVar, overload
@@ -13,10 +13,13 @@
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
+ METHOD_NOT_FOUND,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
Annotations,
BlobResourceContents,
CallToolRequestParams,
CallToolResult,
+ ClientCapabilities,
CompleteRequestParams,
CompleteResult,
Completion,
@@ -28,6 +31,7 @@
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
+ MissingRequiredClientCapabilityErrorData,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
@@ -54,7 +58,14 @@
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
-from mcp.server.context import ServerRequestContext
+from mcp.server.context import HandlerResult, ServerRequestContext
+from mcp.server.extension import (
+ Extension,
+ MethodBinding,
+ RequestHandler,
+ compose_tool_call_interceptor,
+ validate_extension_identifier,
+)
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT, Server
from mcp.server.lowlevel.server import lifespan as default_lifespan
@@ -148,6 +159,7 @@ def __init__(
*,
tools: list[Tool] | None = None,
resources: list[Resource] | None = None,
+ extensions: Sequence[Extension] | None = None,
debug: bool = False,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
warn_on_duplicate_resources: bool = True,
@@ -215,6 +227,11 @@ def __init__(
# Configure logging
configure_logging(self.settings.log_level)
+ self._extensions: list[Extension] = []
+ for extension in extensions or ():
+ self._apply_extension(extension)
+ self._install_extension_interceptor()
+
@property
def name(self) -> str:
return self._lowlevel_server.name
@@ -255,6 +272,44 @@ def session_manager(self) -> StreamableHTTPSessionManager:
"""
return self._lowlevel_server.session_manager
+ def _apply_extension(self, extension: Extension) -> None:
+ """Apply one opt-in extension's contributions through the public surface.
+
+ Registers its tools/resources/methods and advertises its settings under
+ `ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed
+ at construction, so this is private; the `tools/call` interceptor is
+ composed once afterwards by `_install_extension_interceptor`.
+ """
+ identifier = getattr(extension, "identifier", None)
+ validate_extension_identifier(identifier, owner=type(extension).__name__)
+ if any(e.identifier == identifier for e in self._extensions):
+ raise ValueError(f"Extension {identifier!r} is already registered")
+ self._extensions.append(extension)
+
+ for tool in extension.tools():
+ self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs)
+ for resource in extension.resources():
+ self.add_resource(resource.resource)
+ for method in extension.methods():
+ 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 @@ async def get_prompt(
except Exception as e:
logger.exception(f"Error getting prompt {name}")
raise ValueError(str(e)) from e
+
+
+def _version_gated(method: MethodBinding) -> RequestHandler:
+ """Wrap a method handler so a request at a disallowed protocol version is rejected.
+
+ The low-level `_request_handlers` dict is keyed by method only, so per-version
+ scoping is enforced here rather than at the runner's boundary table.
+ """
+ versions = method.protocol_versions
+ assert versions is not None
+
+ async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResult:
+ if ctx.protocol_version not in versions:
+ raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method.method)
+ return await method.handler(ctx, params)
+
+ return gated
+
+
+def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: str) -> None:
+ """Assert the connected client declared support for `identifier`.
+
+ Call this from an extension's handler or `intercept_tool_call` before
+ offering extension-specific behaviour. Raises `MCPError` with the
+ `-32021` (missing required client capability) code and a
+ `requiredCapabilities` payload when the client did not declare the
+ extension, per SEP-2133.
+
+ Args:
+ ctx: The current request context.
+ identifier: The extension identifier the client must have declared.
+
+ Raises:
+ MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client
+ did not advertise `identifier`.
+ """
+ client_params = ctx.session.client_params
+ declared = client_params.capabilities.extensions if client_params else None
+ if not declared or identifier not in declared:
+ data = MissingRequiredClientCapabilityErrorData(
+ required_capabilities=ClientCapabilities(extensions={identifier: {}})
+ )
+ raise MCPError(
+ code=MISSING_REQUIRED_CLIENT_CAPABILITY,
+ message=f"Client did not declare required extension {identifier!r}",
+ data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py
new file mode 100644
index 000000000..02375f97a
--- /dev/null
+++ b/tests/docs_src/test_apps.py
@@ -0,0 +1,100 @@
+"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK."""
+
+from typing import Any
+
+import pytest
+from mcp_types import TextContent, TextResourceContents
+
+from docs_src.apps import tutorial001, tutorial002, tutorial003
+from mcp import Client
+from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+
+async def test_the_tool_carries_the_ui_resource_reference() -> None:
+ """tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool."""
+ async with Client(tutorial001.mcp) as client:
+ listed = await client.list_tools()
+ assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}}
+
+
+async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
+ """tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`,
+ the MIME type that tells a host "this is an app, render it"."""
+ async with Client(tutorial001.mcp) as client:
+ result = await client.read_resource("ui://clock/app.html")
+ contents = result.contents[0]
+ assert isinstance(contents, TextResourceContents)
+ assert contents.mime_type == APP_MIME_TYPE
+ assert contents.text == tutorial001.CLOCK_HTML
+
+
+async def test_one_tool_two_answers() -> None:
+ """tutorial001: the canonical degradation pattern: raw data for a client that
+ negotiated Apps, a human sentence for one that did not."""
+ async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client:
+ rich = await ui_client.call_tool("get_time", {})
+ async with Client(tutorial001.mcp) as text_client:
+ plain = await text_client.call_tool("get_time", {})
+ assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")]
+ assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")]
+
+
+async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
+ """tutorial001: `main()` declares Apps support with the required `mimeTypes` and
+ receives the rich answer the page promises."""
+ await tutorial001.main()
+ assert "2026-06-26T12:00:00Z" in capsys.readouterr().out
+
+
+async def test_capability_advertised_under_server_extensions() -> None:
+ """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`."""
+ async with Client(tutorial001.mcp) as client:
+ assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
+
+
+async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None:
+ """tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list
+ entry and the read content item, with the spec's camelCase wire keys."""
+ expected: dict[str, Any] = {
+ "ui": {
+ "csp": {"connectDomains": ["https://api.example.com"]},
+ "permissions": {"clipboardWrite": {}},
+ "domain": "dashboard.example.com",
+ "prefersBorder": True,
+ }
+ }
+ async with Client(tutorial002.mcp) as client:
+ listed = await client.list_resources()
+ result = await client.read_resource("ui://dashboard/app.html")
+ assert listed.resources[0].meta == expected
+ contents = result.contents[0]
+ assert isinstance(contents, TextResourceContents)
+ assert contents.meta == expected
+
+
+async def test_an_app_only_tool_is_still_listed_and_callable() -> None:
+ """tutorial002: `visibility=["app"]` is metadata for the host; the server lists the
+ tool like any other and serves its calls. Filtering is the host's job."""
+ async with Client(tutorial002.mcp) as client:
+ listed = await client.list_tools()
+ result = await client.call_tool("refresh_dashboard", {})
+ assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}}
+ assert result.content == [TextContent(type="text", text="refreshed")]
+
+
+async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None:
+ """tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the
+ `text/html;profile=mcp-app` MIME type the resource didn't set explicitly."""
+ async with Client(tutorial003.mcp) as client:
+ listed = await client.list_tools()
+ called = await client.call_tool("refresh_report", {})
+ result = await client.read_resource("ui://report/app.html")
+ assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}}
+ assert called.content == [TextContent(type="text", text="report refreshed")]
+ contents = result.contents[0]
+ assert isinstance(contents, TextResourceContents)
+ assert contents.mime_type == APP_MIME_TYPE
+ assert contents.text == tutorial003.REPORT_HTML.read_text()
diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py
new file mode 100644
index 000000000..ebe00e5a8
--- /dev/null
+++ b/tests/docs_src/test_extensions.py
@@ -0,0 +1,97 @@
+"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK."""
+
+import logging
+from typing import cast
+
+import mcp_types as types
+import pytest
+from inline_snapshot import snapshot
+from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
+
+from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
+from mcp import Client, MCPError
+from mcp.server.extension import Extension
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+
+async def test_using_an_extension_advertises_its_capability() -> None:
+ """tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise
+ the extension under `capabilities.extensions`."""
+ async with Client(tutorial001.mcp) as client:
+ assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}}
+
+
+def test_a_prefixless_identifier_fails_at_class_definition() -> None:
+ """tutorial002 + the page's TypeError block: the identifier is validated when the
+ subclass is defined, with the exact message the page shows."""
+ assert tutorial002.Stamps.identifier == "com.example/stamps"
+ with pytest.raises(TypeError) as exc_info:
+ type("Stamps", (Extension,), {"identifier": "stamps"})
+ assert str(exc_info.value) == snapshot(
+ "Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'"
+ )
+
+
+async def test_extension_settings_advertised_under_capabilities() -> None:
+ """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`."""
+ async with Client(tutorial003.mcp) as client:
+ assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}}
+
+
+async def test_contributed_tool_is_listed_and_callable() -> None:
+ """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable."""
+ async with Client(tutorial003.mcp) as client:
+ listed = await client.list_tools()
+ assert [tool.name for tool in listed.tools] == ["stamp"]
+ result = await client.call_tool("stamp", {"text": "hello"})
+ assert result.content == [TextContent(type="text", text="[stamped] hello")]
+
+
+async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
+ """tutorial003: `main()` is the literal client program on the page; both printed
+ lines match the page's comments."""
+ await tutorial003.main()
+ out = capsys.readouterr().out
+ assert "{'com.example/stamps': {'sealed': True}}" in out
+ assert "[stamped] hello" in out
+
+
+async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
+ """tutorial004: `main()` declares the extension and gets the vendor method's result."""
+ await tutorial004.main()
+ assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out
+
+
+async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None:
+ """tutorial004: `require_client_extension` answers a non-declaring client with `-32021`
+ and the machine-readable `requiredCapabilities` payload."""
+ async with Client(tutorial004.mcp) as client:
+ request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
+ with pytest.raises(MCPError) as exc_info:
+ await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
+ assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
+ assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
+
+
+async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
+ """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
+ at any other wire version; for a legacy client it doesn't exist."""
+ async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client:
+ request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
+ with pytest.raises(MCPError) as exc_info:
+ await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
+ assert exc_info.value.code == METHOD_NOT_FOUND
+
+
+async def test_interceptor_observes_the_call_and_passes_the_result_through(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged."""
+ with caplog.at_level(logging.INFO, logger=tutorial005.logger.name):
+ async with Client(tutorial005.mcp) as client:
+ result = await client.call_tool("add", {"a": 2, "b": 3})
+ assert result.structured_content == {"result": 5}
+ messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
+ assert messages == ["tool 'add' called"]
diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py
new file mode 100644
index 000000000..e2ec366b2
--- /dev/null
+++ b/tests/server/mcpserver/test_extension.py
@@ -0,0 +1,485 @@
+"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring).
+
+These exercise the closed set of extension contribution kinds - tools,
+resources, request methods, and the single `tools/call` interceptor - through
+the highest-level public surface (in-memory `Client`), plus the
+`compose_tool_call_interceptor` helper directly.
+"""
+
+from typing import Any, Literal, cast
+
+import mcp_types as types
+import pytest
+from inline_snapshot import snapshot
+from mcp_types import (
+ METHOD_NOT_FOUND,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
+ CallToolResult,
+ TextContent,
+)
+
+from mcp.client.client import Client
+from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
+from mcp.server.extension import (
+ Extension,
+ MethodBinding,
+ ResourceBinding,
+ ToolBinding,
+ compose_tool_call_interceptor,
+ validate_extension_identifier,
+)
+from mcp.server.mcpserver import Context, MCPServer, require_client_extension
+from mcp.server.mcpserver.resources import TextResource
+from mcp.shared.exceptions import MCPError
+
+pytestmark = pytest.mark.anyio
+
+_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}}
+
+
+class _AdditiveExt(Extension):
+ """Override `tools()`/`resources()` only - a purely additive extension."""
+
+ identifier = "com.example/additive"
+
+ def tools(self):
+ def ping() -> str:
+ """Reply with pong."""
+ return "pong"
+
+ return [ToolBinding(fn=ping, meta=_TOOL_META)]
+
+ def resources(self):
+ return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))]
+
+
+class _SettingsExt(Extension):
+ """Override `settings()` so the extension advertises a non-empty settings map."""
+
+ identifier = "com.example/settings"
+
+ def settings(self) -> dict[str, Any]:
+ return {"feature": {"enabled": True}}
+
+
+class _PingParams(types.RequestParams):
+ pass
+
+
+class _PingResult(types.Result):
+ pong: bool
+
+
+class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
+ method: Literal["com.example/ping"] = "com.example/ping"
+ params: _PingParams
+
+
+async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
+ """The shared `com.example/ping` handler (dispatched by the reachability test)."""
+ return _PingResult(pong=True)
+
+
+class _MethodExt(Extension):
+ """Override `methods()` to serve a new vendor request verb."""
+
+ identifier = "com.example/method"
+
+ def methods(self) -> list[MethodBinding]:
+ return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
+
+
+class _ReplacingExt(Extension):
+ """Override `intercept_tool_call()` to short-circuit with a fixed result."""
+
+ identifier = "com.example/replacing"
+
+ async def intercept_tool_call(
+ self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
+ ) -> HandlerResult:
+ return CallToolResult(content=[TextContent(type="text", text="intercepted")])
+
+
+class _PassThroughExt(Extension):
+ """Override `intercept_tool_call()` but always delegate to `call_next` unchanged."""
+
+ identifier = "com.example/passthrough"
+
+ async def intercept_tool_call(
+ self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
+ ) -> HandlerResult:
+ return await call_next(ctx)
+
+
+class _DefaultExt(Extension):
+ """Override nothing - relies on the base `intercept_tool_call` default (pass through)."""
+
+ identifier = "com.example/default"
+
+
+class _RecordingExt(Extension):
+ """Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through."""
+
+ def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None:
+ self.identifier = identifier
+ self._log = log
+
+ async def intercept_tool_call(
+ self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
+ ) -> HandlerResult:
+ self._log.append((self.identifier, params.name))
+ return await call_next(ctx)
+
+
+def _echo(value: str) -> str:
+ """Echo the input value (shared tool body across interceptor tests)."""
+ return value
+
+
+async def test_additive_extension_registers_its_tool_and_resource() -> None:
+ """SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both
+ through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's
+ `_meta` round-trips equal to the exact dict the binding carried (identity can't
+ hold - the value is JSON-serialized over the transport)."""
+ server = MCPServer("test", extensions=[_AdditiveExt()])
+
+ async with Client(server) as client:
+ tools = await client.list_tools()
+ resources = await client.list_resources()
+ called = await client.call_tool("ping", {})
+
+ assert [t.name for t in tools.tools] == ["ping"]
+ assert tools.tools[0].meta == _TOOL_META
+ assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
+ assert resources == snapshot(
+ types.ListResourcesResult(
+ resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
+ )
+ )
+
+
+async def test_extension_settings_advertised_under_server_capabilities() -> None:
+ """SDK-defined: `settings()` rides `server/discover` and lands under
+ `server_capabilities.extensions[identifier]` on the modern (`auto`) path."""
+ server = MCPServer("test", extensions=[_SettingsExt()])
+
+ async with Client(server, mode="auto") as client:
+ extensions = client.server_capabilities.extensions
+
+ assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
+
+
+async def test_extension_settings_dropped_on_legacy_handshake() -> None:
+ """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
+ field, so a legacy `initialize` handshake drops the advertised extension even
+ though the modern `auto` path carries it."""
+ server = MCPServer("test", extensions=[_SettingsExt()])
+
+ async with Client(server, mode="legacy") as client:
+ assert client.server_capabilities.extensions is None
+
+
+def test_duplicate_extension_identifier_raises() -> None:
+ """SDK-defined: registering two extensions with the same `identifier` is a
+ construction error."""
+ with pytest.raises(ValueError):
+ MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
+
+
+async def test_extension_method_reachable_via_session_send_request() -> None:
+ """SDK-defined: an `Extension` overriding `methods()` wires a new request verb
+ onto the low-level server, reachable through `client.session.send_request`."""
+ server = MCPServer("test", extensions=[_MethodExt()])
+
+ async with Client(server) as client:
+ request = _PingRequest(params=_PingParams())
+ result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult)
+
+ assert result == snapshot(_PingResult(pong=True))
+
+
+async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
+ """SDK-defined: an extension whose `intercept_tool_call` delegates to
+ `call_next` does not alter the underlying tool's `CallToolResult`."""
+ server = MCPServer("test", extensions=[_PassThroughExt()])
+ server.tool(name="echo")(_echo)
+
+ async with Client(server) as client:
+ result = await client.call_tool("echo", {"value": "hi"})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
+
+
+async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
+ """SDK-defined: an extension that returns from `intercept_tool_call` without
+ calling `call_next` replaces the tool's result wholesale (the tool never runs)."""
+ server = MCPServer("test", extensions=[_ReplacingExt()])
+ server.tool(name="echo", structured_output=False)(_echo)
+
+ async with Client(server) as client:
+ result = await client.call_tool("echo", {"value": "hi"})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
+
+
+def test_plain_extension_installs_no_tool_call_interceptor() -> None:
+ """SDK-defined: an extension that does not override `intercept_tool_call` adds no
+ middleware - the composed interceptor exists only when at least one extension
+ overrides it."""
+ baseline = len(MCPServer("test")._lowlevel_server.middleware)
+ server = MCPServer("test", extensions=[_AdditiveExt()])
+
+ assert len(server._lowlevel_server.middleware) == baseline
+
+
+def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
+ """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
+ one additional `tools/call` middleware."""
+ baseline = len(MCPServer("test")._lowlevel_server.middleware)
+ server = MCPServer("test", extensions=[_ReplacingExt()])
+
+ assert len(server._lowlevel_server.middleware) == baseline + 1
+
+
+async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
+ """SDK-defined: an extension that does not override `intercept_tool_call` runs the
+ base-class default (pass through) when another extension forces the composed
+ middleware to exist, leaving the tool result untouched."""
+ server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()])
+ server.tool(name="echo")(_echo)
+
+ async with Client(server) as client:
+ result = await client.call_tool("echo", {"value": "hi"})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
+
+
+async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
+ """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
+ two passing-through interceptors record in registration order, each seeing the
+ validated `tools/call` params (the real tool name)."""
+ log: list[tuple[str, str]] = []
+ server = MCPServer(
+ "test",
+ extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)],
+ )
+ server.tool(name="echo")(_echo)
+
+ async with Client(server) as client:
+ await client.call_tool("echo", {"value": "hi"})
+
+ assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
+
+
+async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
+ """SDK-defined: the composed middleware is a no-op for any method other than
+ `tools/call` - it forwards to `call_next` without touching the interceptors."""
+ sentinel = types.EmptyResult()
+
+ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
+ return sentinel
+
+ middleware = compose_tool_call_interceptor([_ReplacingExt()])
+ ctx = ServerRequestContext(
+ session=cast("Any", None),
+ lifespan_context={},
+ protocol_version="2026-07-28",
+ method="tasks/get",
+ params={"taskId": "t-1"},
+ )
+
+ result = await middleware(ctx, call_next)
+
+ assert result is sentinel
+
+
+def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
+ """SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the
+ subclass is defined (a bare name with no prefix is a TypeError)."""
+ with pytest.raises(TypeError):
+ type("_BadExt", (Extension,), {"identifier": "noprefix"})
+
+
+def test_extension_without_identifier_is_rejected_at_registration() -> None:
+ """SDK-defined: a subclass that never sets `identifier` (neither class-level nor in
+ `__init__`) is rejected when the server applies it."""
+
+ class _NoIdExt(Extension):
+ pass
+
+ with pytest.raises(TypeError):
+ MCPServer("test", extensions=[_NoIdExt()])
+
+
+class _VersionPinnedParams(types.RequestParams):
+ pass
+
+
+class _VersionPinnedResult(types.Result):
+ ok: bool
+
+
+class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]):
+ method: Literal["com.example/pinned"] = "com.example/pinned"
+ params: _VersionPinnedParams
+
+
+class _VersionPinnedExt(Extension):
+ """A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`."""
+
+ identifier = "com.example/pinned"
+
+ def methods(self):
+ async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult:
+ return _VersionPinnedResult(ok=True)
+
+ return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))]
+
+
+async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
+ """SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version
+ in the set."""
+ server = MCPServer("test", extensions=[_VersionPinnedExt()])
+
+ async with Client(server, mode="2026-07-28") as client:
+ request = _VersionPinnedRequest(params=_VersionPinnedParams())
+ result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
+
+ assert result == snapshot(_VersionPinnedResult(ok=True))
+
+
+async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
+ """SDK-defined: the same method at a version outside `protocol_versions` is rejected with
+ METHOD_NOT_FOUND, mirroring the spec's per-version boundary."""
+ server = MCPServer("test", extensions=[_VersionPinnedExt()])
+
+ async with Client(server, mode="legacy") as client:
+ request = _VersionPinnedRequest(params=_VersionPinnedParams())
+ with pytest.raises(MCPError) as exc_info:
+ await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
+
+ assert exc_info.value.code == METHOD_NOT_FOUND
+ assert exc_info.value.error.data == "com.example/pinned"
+
+
+@pytest.mark.parametrize(
+ "identifier",
+ [
+ "io.modelcontextprotocol/ui",
+ "com.example/my_ext",
+ "com.x-y.z2/n.a-b_c",
+ "example/x",
+ "a/b",
+ "com.example/9start",
+ ],
+)
+def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
+ """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
+ hyphens interior), a slash, then a name that starts and ends alphanumeric."""
+ validate_extension_identifier(identifier, owner="T")
+
+
+@pytest.mark.parametrize(
+ "identifier",
+ [
+ "noprefix",
+ "-foo/bar",
+ ".leading/x",
+ "a..b/x",
+ "foo-/x",
+ "9foo/x",
+ "foo/-bar",
+ "foo/bar-",
+ "foo/",
+ "/bar",
+ "foo/ba r",
+ "io.modelcontextprotocol/ui\n",
+ "",
+ None,
+ 42,
+ ],
+)
+def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
+ """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
+ and malformed names are rejected, as are non-strings."""
+ with pytest.raises(TypeError):
+ validate_extension_identifier(identifier, owner="T")
+
+
+@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
+def test_method_binding_rejects_spec_methods(method: str) -> None:
+ """SDK-defined: extension methods are additive — binding a spec-defined request method
+ would silently shadow (or be shadowed by) the server's own handler, so it is rejected
+ when the binding is constructed."""
+ with pytest.raises(ValueError):
+ MethodBinding(method, _PingParams, _pong_handler)
+
+
+def test_method_binding_rejects_empty_protocol_versions() -> None:
+ """SDK-defined: an empty `protocol_versions` set would make the method unreachable at
+ every version; `None` is the universal-version spelling."""
+ with pytest.raises(ValueError) as exc_info:
+ MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
+ assert str(exc_info.value) == snapshot(
+ "MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
+ "never be served; use None to admit every version"
+ )
+
+
+class _OtherMethodExt(Extension):
+ """A second extension binding the same verb as `_MethodExt`."""
+
+ identifier = "com.example/other-method"
+
+ def methods(self) -> list[MethodBinding]:
+ return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
+
+
+def test_colliding_extension_methods_are_rejected_at_registration() -> None:
+ """SDK-defined: two extensions binding the same method would silently last-write-win;
+ the collision is rejected when the second extension is applied."""
+ with pytest.raises(ValueError) as exc_info:
+ MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
+ assert str(exc_info.value) == snapshot(
+ "Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
+ "registered; extension methods are additive and cannot replace another handler"
+ )
+
+
+_NEEDS_EXT = "com.example/needed"
+
+
+class _RequiresExt(Extension):
+ """A tool that requires the client to have declared `com.example/needed`."""
+
+ identifier = _NEEDS_EXT
+
+ def tools(self):
+ def guarded(ctx: Context) -> str:
+ require_client_extension(ctx.request_context, _NEEDS_EXT)
+ return "ok"
+
+ return [ToolBinding(fn=guarded)]
+
+
+async def test_require_client_extension_passes_when_client_declared_it() -> None:
+ """SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
+ server = MCPServer("test", extensions=[_RequiresExt()])
+
+ async with Client(server, extensions={_NEEDS_EXT: {}}) as client:
+ result = await client.call_tool("guarded", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
+
+
+async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
+ """SDK-defined: `require_client_extension` raises the -32021 missing-required-capability
+ error when the client did not advertise the id."""
+ server = MCPServer("test", extensions=[_RequiresExt()])
+
+ async with Client(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("guarded", {})
+
+ assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
+ assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}})
diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py
new file mode 100644
index 000000000..65908309a
--- /dev/null
+++ b/tests/server/test_apps.py
@@ -0,0 +1,302 @@
+"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133).
+
+The headline property is SEP-2133 graceful degradation: a UI-bound tool returns
+rich output to a client that negotiated Apps and text-only output to one that did
+not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp,
+the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme
+validation).
+"""
+
+from typing import Any
+
+import mcp_types as types
+import pytest
+from inline_snapshot import snapshot
+from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
+
+from mcp.client.client import Client
+from mcp.server import Server, ServerRequestContext
+from mcp.server.apps import (
+ APP_MIME_TYPE,
+ EXTENSION_ID,
+ Apps,
+ ResourceCsp,
+ ResourcePermissions,
+ client_supports_apps,
+)
+from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver.context import Context
+from mcp.server.mcpserver.resources import TextResource
+
+pytestmark = pytest.mark.anyio
+
+
+def _clock_server() -> MCPServer:
+ apps = Apps()
+
+ @apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.")
+ def get_time(ctx: Context) -> str:
+ if not client_supports_apps(ctx):
+ return "The time is 2026-06-26T00:00:00Z."
+ return "2026-06-26T00:00:00Z"
+
+ apps.add_html_resource("ui://clock/app.html", "Clock", title="Clock")
+ return MCPServer("clock", extensions=[apps])
+
+
+async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None:
+ """SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the
+ advertised tool, observed end-to-end through `list_tools`."""
+ async with Client(_clock_server()) as client:
+ result = await client.list_tools()
+ assert [(t.name, t.meta) for t in result.tools] == snapshot(
+ [("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})]
+ )
+
+
+async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
+ """SDK-defined: `add_html_resource` registers the `ui://` resource served as
+ `text/html;profile=mcp-app`, observed through `read_resource`."""
+ async with Client(_clock_server()) as client:
+ result = await client.read_resource("ui://clock/app.html")
+ assert result == snapshot(
+ ReadResourceResult(
+ contents=[
+ TextResourceContents(
+ uri="ui://clock/app.html",
+ mime_type="text/html;profile=mcp-app",
+ text="Clock",
+ )
+ ]
+ )
+ )
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].mime_type == APP_MIME_TYPE
+
+
+async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None:
+ """SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client
+ sees `EXTENSION_ID` under `server_capabilities.extensions`."""
+ async with Client(_clock_server(), mode="auto") as client:
+ assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})
+
+
+async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None:
+ """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field,
+ so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'`
+ (server/discover) does. This pins the divergence rather than fixing it."""
+ async with Client(_clock_server(), mode="legacy") as client:
+ assert client.server_capabilities.extensions is None
+
+
+async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:
+ """SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the
+ rich (UI) path, while one that did not gets the text-only fallback. The same tool,
+ branching on `client_supports_apps(ctx)`, drives both halves."""
+ server = _clock_server()
+
+ async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
+ rich = await supports.call_tool("get_time", {})
+ async with Client(server) as plain:
+ fallback = await plain.call_tool("get_time", {})
+
+ assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")])
+ assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
+
+
+async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool:
+ """Run one probe `tools/call` and report what `client_supports_apps` saw server-side.
+
+ Exercises the lowlevel `ServerRequestContext` form, which reads the client's
+ advertised extensions off `session.client_params`.
+ """
+ observed: list[bool] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "probe"
+ observed.append(client_supports_apps(ctx))
+ return CallToolResult(content=[TextContent(text="ok")])
+
+ server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
+ async with Client(server, extensions=extensions) as client:
+ await client.call_tool("probe", {})
+ return observed[0]
+
+
+@pytest.mark.parametrize(
+ ("extensions", "expected"),
+ [
+ pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"),
+ pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"),
+ pytest.param(None, False, id="extension-not-declared"),
+ pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"),
+ pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"),
+ ],
+)
+async def test_client_supports_apps_from_lowlevel_request_context(
+ extensions: dict[str, dict[str, Any]] | None, expected: bool
+) -> None:
+ """ext-apps: `client_supports_apps` is `True` only when the client declared the ui
+ extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a
+ required field, so its absence means unsupported (the reference SDK's check is
+ `uiCap?.mimeTypes?.includes(...)`)."""
+ assert await _observed_client_supports_apps(extensions) is expected
+
+
+def test_apps_tool_rejects_non_ui_resource_uri() -> None:
+ """SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a
+ programmer error raised at decoration time."""
+ apps = Apps()
+ with pytest.raises(ValueError):
+ apps.tool(resource_uri="https://example.com/app.html")
+
+
+def test_add_html_resource_rejects_non_ui_resource_uri() -> None:
+ """SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is
+ a programmer error raised at registration time."""
+ apps = Apps()
+ with pytest.raises(ValueError):
+ apps.add_html_resource("https://example.com/app.html", "x")
+
+
+def _widget() -> str:
+ """A UI-bound tool body (shared so its one covered call serves both meta tests)."""
+ return "x"
+
+
+async def test_apps_tool_stamps_visibility_when_given() -> None:
+ """SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`."""
+ apps = Apps()
+ apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget)
+ apps.add_html_resource("ui://v/app.html", "v")
+
+ async with Client(MCPServer("v", extensions=[apps])) as client:
+ result = await client.list_tools()
+ called = await client.call_tool("_widget", {})
+
+ assert result.tools[0].meta == snapshot({"ui": {"resourceUri": "ui://v/app.html", "visibility": ["app"]}})
+ assert called.content == snapshot([TextContent(text="x")])
+
+
+async def test_apps_tool_merges_extra_meta_alongside_ui() -> None:
+ """SDK-defined: `@apps.tool(meta=...)` merges extra `_meta` keys with the `ui` entry
+ (previously a `meta=` argument raised a duplicate-keyword TypeError)."""
+ apps = Apps()
+ apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget)
+ apps.add_html_resource("ui://m/app.html", "m")
+
+ async with Client(MCPServer("m", extensions=[apps])) as client:
+ result = await client.list_tools()
+
+ assert result.tools[0].meta == snapshot({"com.example/k": 1, "ui": {"resourceUri": "ui://m/app.html"}})
+
+
+async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() -> None:
+ """SDK-defined: `csp`/`permissions` populate the resource's `_meta.ui` per ext-apps."""
+ apps = Apps()
+ apps.add_html_resource(
+ "ui://r/app.html",
+ "r",
+ csp=ResourceCsp(connect_domains=["https://api.example.com"]),
+ permissions=ResourcePermissions(camera={}),
+ domain="r.example.com",
+ prefers_border=True,
+ )
+
+ async with Client(MCPServer("r", extensions=[apps])) as client:
+ listed = await client.list_resources()
+ result = await client.read_resource("ui://r/app.html")
+
+ expected_ui_meta = snapshot(
+ {
+ "ui": {
+ "csp": {"connectDomains": ["https://api.example.com"]},
+ "permissions": {"camera": {}},
+ "domain": "r.example.com",
+ "prefersBorder": True,
+ }
+ }
+ )
+ # Hosts read `_meta.ui` from the read content item, with the list entry as
+ # fallback — the SDK stamps the same value in both places.
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].meta == expected_ui_meta
+ assert listed.resources[0].meta == expected_ui_meta
+
+
+def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None:
+ """SDK-defined: a tool whose `resource_uri` has no matching registered resource would
+ advertise a `_meta.ui.resourceUri` that 404s on `resources/read`; the misconfiguration
+ is rejected when the server consumes the extension."""
+ apps = Apps()
+ apps.tool(resource_uri="ui://missing/app.html")(_widget)
+
+ with pytest.raises(ValueError) as exc_info:
+ MCPServer("broken", extensions=[apps])
+ assert str(exc_info.value) == snapshot(
+ "Apps tool '_widget' binds resource_uri 'ui://missing/app.html', but no such resource "
+ "is registered; add it with add_html_resource() or add_resource()"
+ )
+
+
+async def test_add_resource_registers_a_prebuilt_ui_resource() -> None:
+ """SDK-defined: `add_resource` is the escape hatch for pre-built `ui://` resources
+ that `add_html_resource` cannot express; it satisfies a tool's `resource_uri` binding."""
+ apps = Apps()
+ apps.tool(resource_uri="ui://prebuilt/app.html")(_widget)
+ apps.add_resource(
+ TextResource(uri="ui://prebuilt/app.html", name="prebuilt", mime_type=APP_MIME_TYPE, text="p")
+ )
+
+ async with Client(MCPServer("p", extensions=[apps])) as client:
+ result = await client.read_resource("ui://prebuilt/app.html")
+
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "p"
+
+
+def test_add_resource_rejects_non_ui_resource_uri() -> None:
+ """SDK-defined: `add_resource` accepts only `ui://` URIs, like the other registrars."""
+ apps = Apps()
+ with pytest.raises(ValueError):
+ apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x"))
+
+
+def test_apps_tool_rejects_a_ui_meta_key() -> None:
+ """SDK-defined: the decorator owns `_meta['ui']` — a caller-supplied `'ui'` entry would be
+ silently clobbered, so it is rejected at decoration time (use `resource_uri=`/`visibility=`)."""
+ apps = Apps()
+ with pytest.raises(ValueError) as exc_info:
+ apps.tool(resource_uri="ui://c/app.html", meta={"ui": {"resourceUri": "ui://other.html"}})
+ assert str(exc_info.value) == snapshot(
+ "Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key"
+ )
+
+
+async def test_add_resource_defaults_the_mime_type_to_the_app_mime() -> None:
+ """ext-apps: hosts only render `ui://` resources served as `text/html;profile=mcp-app`,
+ so a resource registered without an explicit `mime_type` gets it by default."""
+ apps = Apps()
+ apps.add_resource(TextResource(uri="ui://d/app.html", name="d", text="d"))
+
+ async with Client(MCPServer("d", extensions=[apps])) as client:
+ result = await client.read_resource("ui://d/app.html")
+
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].mime_type == APP_MIME_TYPE
+
+
+def test_add_resource_rejects_an_explicit_non_app_mime_type() -> None:
+ """ext-apps: an explicit `mime_type` other than `text/html;profile=mcp-app` would make
+ the resource unrenderable; the mismatch is rejected at registration."""
+ apps = Apps()
+ with pytest.raises(ValueError) as exc_info:
+ apps.add_resource(TextResource(uri="ui://e/app.html", name="e", mime_type="text/html", text="x"))
+ assert str(exc_info.value) == snapshot(
+ "MCP Apps resources are served as 'text/html;profile=mcp-app', got 'text/html'"
+ )
diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py
new file mode 100644
index 000000000..90f24be2b
--- /dev/null
+++ b/tests/server/test_extensions_capability.py
@@ -0,0 +1,134 @@
+"""Tests for the SEP-2133 extensions capability negotiation plumbing.
+
+The extension-map negotiation is independent of any concrete extension (Apps,
+Tasks): the lowlevel `Server` advertises `self.extensions` under
+`ServerCapabilities.extensions`, a client mirrors its own support under
+`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the
+server-side query. These tests pin that plumbing end-to-end and at the unit
+level. Per-extension contribution wiring lives in `test_extension.py`; this file
+covers only the capability advertisement and negotiation.
+"""
+
+import mcp_types as types
+import pytest
+from inline_snapshot import snapshot
+
+from mcp.client.client import Client
+from mcp.server import Server, ServerRequestContext
+from mcp.server.extension import Extension
+from mcp.server.mcpserver import MCPServer
+
+pytestmark = pytest.mark.anyio
+
+_EXTENSION_ID = "com.example/x"
+_OTHER_EXTENSION_ID = "com.example/other"
+
+
+class _Extension(Extension):
+ identifier = _EXTENSION_ID
+
+ def settings(self) -> dict[str, object]:
+ return {"k": 1}
+
+
+def test_get_capabilities_omits_extensions_when_none_registered() -> None:
+ """SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises
+ `ServerCapabilities.extensions` as `None`, not an empty map."""
+ server = Server("bare")
+ assert server.get_capabilities().extensions is None
+
+
+def test_get_capabilities_advertises_populated_self_extensions() -> None:
+ """SDK-defined: `get_capabilities` reads `self.extensions` (the map higher
+ layers populate) and advertises it under `ServerCapabilities.extensions`."""
+ server = Server("with-ext")
+ settings = {"k": 1}
+ server.extensions = {_EXTENSION_ID: settings}
+ assert server.get_capabilities().extensions == {_EXTENSION_ID: settings}
+
+
+async def test_modern_connection_carries_the_advertised_extensions_map() -> None:
+ """SDK-defined: over a modern (`server/discover`) connection the client reads
+ the server's advertised extension map from `server_capabilities`."""
+ server = MCPServer("host", extensions=[_Extension()])
+ async with Client(server, mode="auto") as client:
+ assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})
+
+
+async def test_legacy_handshake_drops_the_extensions_map() -> None:
+ """Pinned gap: the handshake-era `initialize` result is serialized against the
+ 2025 wire schema, which has no `extensions` field, so a legacy handshake cannot
+ carry it; the client sees `None` even though the server advertised one."""
+ server = MCPServer("host", extensions=[_Extension()])
+ async with Client(server, mode="legacy") as client:
+ assert client.server_capabilities.extensions is None
+
+
+async def test_server_accepts_capability_for_client_advertised_extension() -> None:
+ """SDK-defined: a client advertising `extensions={id: ...}` makes the
+ server-side `check_client_capability` return True when queried for that id.
+ Observed inside a tool handler."""
+ queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
+ supported: list[bool] = []
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
+ assert params.name == "probe"
+ supported.append(ctx.session.check_client_capability(queried))
+ return types.CallToolResult(content=[])
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
+
+ server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
+ async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
+ await client.call_tool("probe", {})
+
+ assert supported == [True]
+
+
+async def test_server_rejects_capability_for_undeclared_extension() -> None:
+ """SDK-defined: when the client advertises one extension, a server query for a
+ *different* identifier returns False - presence, not value, is the check."""
+ queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}})
+ supported: list[bool] = []
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
+ assert params.name == "probe"
+ supported.append(ctx.session.check_client_capability(queried))
+ return types.CallToolResult(content=[])
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
+
+ server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
+ async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
+ await client.call_tool("probe", {})
+
+ assert supported == [False]
+
+
+async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None:
+ """SDK-defined: a client that declares no extensions makes any server
+ `check_client_capability` query for an extension return False."""
+ queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
+ supported: list[bool] = []
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
+ assert params.name == "probe"
+ supported.append(ctx.session.check_client_capability(queried))
+ return types.CallToolResult(content=[])
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
+
+ server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
+ async with Client(server) as client:
+ await client.call_tool("probe", {})
+
+ assert supported == [False]