From fe8a1c72df31ad893fdad257aa53d7f48759dd3c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 27 Oct 2026 10:30:56 +0000 Subject: [PATCH] Carry `capabilities.extensions` on legacy `initialize` handshakes --- docs/advanced/extensions.md | 8 ++++---- scripts/gen_surface_types.py | 16 ++++++++++++++-- .../mcp_types/_v2025_11_25/__init__.py | 8 ++++++++ src/mcp/server/lowlevel/server.py | 2 +- tests/server/mcpserver/test_extension.py | 11 ++++++----- tests/server/test_apps.py | 9 ++++----- tests/server/test_extensions_capability.py | 9 ++++----- tests/types/test_methods.py | 17 +++++++++++++++++ 8 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index e8793b8cb1..e0ba0669d6 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -25,10 +25,10 @@ Done. The server now advertises `io.modelcontextprotocol/ui` under Extensions are fixed at construction. There is no `add_extension` to call later: a server's capability map should not change while clients are connected to it. -The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy -`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see -the extension. Design for that: an extension *augments* a server, it must not be the -only way the server is usable. +The capability map rides `server/discover` on **2026-07-28** connections and the +`initialize` result on legacy ones. A client that doesn't know an extension ignores +it. Design for that: an extension *augments* a server, it must not be the only way +the server is usable. ## Writing your own diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index d77eedc480..230e704682 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -31,6 +31,12 @@ # gets its typed `$ref` stripped by `make_server_info_opaque` below. SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo" +LEGACY_EXTENSIONS_FIELD = { + "additionalProperties": {"additionalProperties": True, "properties": {}, "type": "object"}, + "description": "Optional MCP extensions that the peer supports, keyed by extension identifier.", + "type": "object", +} + # schema.ts -> schema.json renders TypeScript `number` as JSON Schema # `integer` at these sites; patch the JSON before codegen so floats validate. # Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits @@ -68,6 +74,10 @@ {"additionalProperties": True, "properties": {}, "type": "object"}, {"anyOf": [{"additionalProperties": True, "properties": {}, "type": "object"}, {"type": "boolean"}]}, ), + # Extensions are negotiated through `initialize` before 2026-07-28 too (e.g. MCP Apps); + # carry the 2026-07-28 field so the result sieve doesn't drop a server's acceptance. + ("$defs/ClientCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD), + ("$defs/ServerCapabilities/properties/extensions", None, LEGACY_EXTENSIONS_FIELD), ], "2026-07-28": [ ("$defs/NumberSchema/properties/default/type", "number", ["integer", "number"]), @@ -149,6 +159,8 @@ def load_pinned() -> list[dict[str, str]]: def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None: """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value. + An `old` of `None` asserts the key is absent, so a patch can add a property. + Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys that themselves contain a slash (the reserved `io.modelcontextprotocol/*` `_meta` keys) are addressable. @@ -158,8 +170,8 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> node: Any = schema for part in parts: node = node[int(part) if part.isdigit() else part] - if node[leaf] != old: - raise SystemExit(f"schema patch {path}: expected {old!r}, found {node[leaf]!r}") + if node.get(leaf) != old: + raise SystemExit(f"schema patch {path}: expected {old!r}, found {node.get(leaf)!r}") node[leaf] = new diff --git a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py index 5ffd15f0ec..1210a80aff 100644 --- a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py +++ b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py @@ -218,6 +218,10 @@ class ClientCapabilities(WireModel): """ Present if the client supports task-augmented requests. """ + extensions: dict[str, dict[str, Any]] | None = None + """ + Optional MCP extensions that the peer supports, keyed by extension identifier. + """ class Argument(WireModel): @@ -1132,6 +1136,10 @@ class ServerCapabilities(WireModel): """ Present if the server offers any tools to call. """ + extensions: dict[str, dict[str, Any]] | None = None + """ + Optional MCP extensions that the peer supports, keyed by extension identifier. + """ class SetLevelRequestParams(WireModel): diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6ce537182e..cbcf103d01 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -557,7 +557,7 @@ def create_initialization_options( capabilities=self.get_capabilities( notification_options or NotificationOptions(), experimental_capabilities, - extensions if extensions is not None else self.extensions, + extensions, ), instructions=self.instructions, website_url=self.website_url, diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index 5aa05b1e3d..0d665a3d6e 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -176,14 +176,15 @@ async def test_extension_settings_advertised_under_server_capabilities() -> None 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.""" +async def test_extension_settings_advertised_on_legacy_handshake() -> None: + """SDK-defined: a legacy `initialize` handshake carries the advertised extension + settings, as the modern `auto` path does.""" server = MCPServer("test", extensions=[_SettingsExt()]) async with Client(server, mode="legacy") as client: - assert client.server_capabilities.extensions is None + extensions = client.server_capabilities.extensions + + assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}}) def test_duplicate_extension_identifier_raises() -> None: diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 8d8bf74b74..2ad5410f8b 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -83,12 +83,11 @@ async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> N 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 def test_legacy_handshake_carries_apps_extension_under_server_capabilities() -> None: + """MCP Apps is negotiated through `initialize` on handshake-era versions, so a + `mode='legacy'` client also sees `EXTENSION_ID` under `server_capabilities.extensions`.""" async with Client(_clock_server(), mode="legacy") as client: - assert client.server_capabilities.extensions is None + assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}}) async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None: diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py index 49c81f58f4..017ac517e6 100644 --- a/tests/server/test_extensions_capability.py +++ b/tests/server/test_extensions_capability.py @@ -56,13 +56,12 @@ async def test_modern_connection_carries_the_advertised_extensions_map() -> None 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.""" +async def test_legacy_handshake_carries_the_advertised_extensions_map() -> None: + """SDK-defined: over a legacy (`initialize`) connection the client also reads + the server's advertised extension map from `server_capabilities`.""" server = MCPServer("host", extensions=[_Extension()]) async with Client(server, mode="legacy") as client: - assert client.server_capabilities.extensions is None + assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}}) async def test_server_accepts_capability_for_client_advertised_extension() -> None: diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index e93346b134..da67e343b2 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -934,6 +934,23 @@ def test_serialize_server_result_drops_2026_only_keys_at_pre_2026_versions(metho assert "cacheScope" not in sieved +@pytest.mark.parametrize("version", PRE_2026) +def test_initialize_round_trips_capability_extensions_at_pre_2026_versions(version: str): + extensions = {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}} + capabilities = {"tools": {}, "extensions": extensions} + result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}} + sieved = methods.serialize_server_result("initialize", version, result) + assert sieved["capabilities"] == capabilities + parsed = methods.parse_server_result("initialize", version, sieved) + assert isinstance(parsed, types.InitializeResult) + assert parsed.capabilities.extensions == extensions + client_info = {"name": "C", "version": "1"} + params = {"protocolVersion": version, "capabilities": {"extensions": extensions}, "clientInfo": client_info} + request = methods.parse_client_request("initialize", version, params) + assert isinstance(request, types.InitializeRequest) + assert request.params.capabilities.extensions == extensions + + def test_serialize_server_result_keeps_2026_only_keys_at_2026_07_28(): dumped = MONOLITH_RESULT_FIXTURES["tools/list"].model_dump(by_alias=True, mode="json", exclude_none=True) sieved = methods.serialize_server_result("tools/list", "2026-07-28", dumped)