diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index e8793b8cb1..876cabee33 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -25,9 +25,11 @@ 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 +The capability map rides `server/discover` on **2026-07-28** connections and the +`initialize` handshake on **2025-11-25 and earlier** connections. Both client and +server advertisements preserve `capabilities.extensions`, including extension +settings, on every supported legacy version. Peers that do not support an extension +can ignore its advertisement: 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..4eca31f4df 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -3,8 +3,8 @@ Runs `datamodel-code-generator` over each `schema/PINNED.json` entry and writes the result to `src/mcp-types/mcp_types/_v/__init__.py` (the underscore marks these as internal validators, not public API) with only -the fixes the raw output needs: a small JSON pre-patch for the known -`number`-as-`integer` schema.json defect, a header, full URLs for the spec's +the fixes the raw output needs: JSON pre-patches for schema defects and legacy +extension advertisements, a header, full URLs for the spec's site-absolute doc links, plain type aliases, and per-version epilogue aliases. Run with `uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`. """ @@ -239,6 +239,17 @@ def build(entry: dict[str, str]) -> str: version = entry["protocol_version"] schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8")) patch_schema(schema, SCHEMA_PATCHES.get(version, [])) + if version == "2025-11-25": + # Extensions predate their formal 2026 field; legacy peers negotiate them + # through initialize (modelcontextprotocol/modelcontextprotocol#3364). + for role in ("Client", "Server"): + properties = schema["$defs"][f"{role}Capabilities"]["properties"] + assert "extensions" not in properties + properties["extensions"] = { + "type": "object", + "additionalProperties": {"type": "object", "additionalProperties": True}, + "description": f"MCP extensions that the {role.lower()} supports, including on legacy connections.", + } make_server_info_opaque(schema) if "JSONValue" in schema["$defs"]: # A single recursive alias avoids mutually recursive alias evaluation in type checkers. diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index e91fdcc3b9..645a53f1e2 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -405,7 +405,7 @@ class ClientCapabilities(MCPModel): roots: RootsCapability | None = None """Present if the client supports listing roots.""" extensions: dict[str, dict[str, Any]] | None = None - """MCP extensions the client supports (2026-07-28). Keys are extension + """MCP extensions the client supports, including on legacy connections. Keys are extension identifiers; values are per-extension settings (empty object = no settings).""" tasks: ClientTasksCapability | None = None """Present if the client supports task-augmented requests (2025-11-25 only).""" @@ -505,7 +505,7 @@ class ServerCapabilities(MCPModel): """Present if the server offers autocompletion suggestions for prompts and resources.""" extensions: dict[str, dict[str, Any]] | None = None - """MCP extensions the server supports (2026-07-28). Keys are extension + """MCP extensions the server supports, including on legacy connections. Keys are extension identifiers; values are per-extension settings (empty object = no settings).""" tasks: ServerTasksCapability | None = None 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..8d5b00b28c 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 + """ + MCP extensions that the client supports, including on legacy connections. + """ 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 + """ + MCP extensions that the server supports, including on legacy connections. + """ class SetLevelRequestParams(WireModel): diff --git a/tests/client/test_client.py b/tests/client/test_client.py index c63248798e..b8e56a1ec7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -117,6 +117,7 @@ async def test_client_is_initialized(app: MCPServer): prompts=PromptsCapability(list_changed=False), resources=ResourcesCapability(subscribe=False, list_changed=False), tools=ToolsCapability(list_changed=False), + extensions={}, ) ) assert client.server_info is not None diff --git a/tests/interaction/lowlevel/test_initialize.py b/tests/interaction/lowlevel/test_initialize.py index 1ebc9e2681..82b755741f 100644 --- a/tests/interaction/lowlevel/test_initialize.py +++ b/tests/interaction/lowlevel/test_initialize.py @@ -147,6 +147,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar resources=ResourcesCapability(subscribe=True, list_changed=False), tools=ToolsCapability(list_changed=False), completions=CompletionsCapability(), + extensions={}, ) ) @@ -157,7 +158,7 @@ async def test_initialize_minimal_server_advertises_no_capabilities(connect: Con async with connect(Server("bare")) as client: capabilities = client.server_capabilities - assert capabilities == snapshot(ServerCapabilities()) + assert capabilities == snapshot(ServerCapabilities(extensions={})) @requirement("lifecycle:initialize:client-info") diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index 5aa05b1e3d..f7d6da163a 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -176,14 +176,13 @@ 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.""" - server = MCPServer("test", extensions=[_SettingsExt()]) +async def test_extension_settings_preserved_on_legacy_handshake() -> None: + """SDK compatibility: a legacy initialize handshake preserves nested extension settings.""" + extension = _SettingsExt() + server = MCPServer("test", extensions=[extension]) async with Client(server, mode="legacy") as client: - assert client.server_capabilities.extensions is None + assert client.server_capabilities.extensions == {extension.identifier: extension.settings()} def test_duplicate_extension_identifier_raises() -> None: diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 8d8bf74b74..89f00268b9 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -83,12 +83,10 @@ 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_in_capabilities() -> None: + """SDK compatibility: a legacy initialize handshake advertises MCP Apps with empty settings.""" async with Client(_clock_server(), mode="legacy") as client: - assert client.server_capabilities.extensions is None + assert client.server_capabilities.extensions == {EXTENSION_ID: {}} 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..bc6b243224 100644 --- a/tests/server/test_extensions_capability.py +++ b/tests/server/test_extensions_capability.py @@ -56,16 +56,16 @@ 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.""" - server = MCPServer("host", extensions=[_Extension()]) +async def test_legacy_handshake_carries_the_extensions_map() -> None: + """SDK compatibility: a legacy initialize handshake preserves the server's extension settings.""" + extension = _Extension() + 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 == {extension.identifier: extension.settings()} -async def test_server_accepts_capability_for_client_advertised_extension() -> None: +@pytest.mark.parametrize("mode", ["auto", "legacy"]) +async def test_server_accepts_capability_for_client_advertised_extension(mode: str) -> 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.""" @@ -83,7 +83,7 @@ async def list_tools( 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=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client: + async with Client(server, mode=mode, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client: await client.call_tool("probe", {}) assert supported == [True] diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index e93346b134..b9a6092756 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -11,7 +11,7 @@ import pydantic import pytest from mcp_types import methods -from mcp_types.version import KNOWN_PROTOCOL_VERSIONS +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, KNOWN_PROTOCOL_VERSIONS from pydantic import BaseModel # Transcribed from each schema's ClientRequest/ServerRequest/ClientNotification/ @@ -470,6 +470,74 @@ def test_discovery_capabilities_preserve_nested_json_values() -> None: assert methods.serialize_server_result("server/discover", "2026-07-28", raw) == raw +@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS) +@pytest.mark.parametrize( + "extensions", + [ + {}, + {"io.modelcontextprotocol/ui": {}}, + {"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}}, + ], +) +def test_initialize_round_trip_preserves_server_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None: + """SDK compatibility: legacy initialize responses carry extension advertisements, including opaque settings.""" + raw = { + "protocolVersion": version, + "capabilities": {"tools": {}, "extensions": extensions}, + "serverInfo": {"name": "server", "version": "1"}, + } + serialized = methods.serialize_server_result("initialize", version, raw) + assert serialized == raw + parsed = methods.parse_server_result("initialize", version, serialized) + assert isinstance(parsed, types.InitializeResult) + assert parsed.capabilities.extensions == extensions + + +@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS) +@pytest.mark.parametrize( + "extensions", + [ + {}, + {"io.modelcontextprotocol/ui": {}}, + {"com.example/settings": {"nested": [None, True, 1, 1.5, "text", {"child": [False]}]}}, + ], +) +def test_initialize_round_trip_preserves_client_extensions(version: str, extensions: dict[str, dict[str, Any]]) -> None: + """SDK compatibility: the legacy request wire model preserves the same extension settings as the public parser.""" + raw = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": version, + "capabilities": {"extensions": extensions}, + "clientInfo": {"name": "client", "version": "1"}, + }, + } + surface = methods.CLIENT_REQUESTS[("initialize", version)] + serialized = surface.model_validate(raw).model_dump(by_alias=True, mode="json", exclude_none=True) + assert serialized == raw + parsed = methods.parse_client_request("initialize", version, serialized["params"]) + assert isinstance(parsed, types.InitializeRequest) + assert parsed.params.capabilities.extensions == extensions + + +@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS) +@pytest.mark.parametrize("capabilities", [{}, {"extensions": None}]) +def test_initialize_omits_unset_server_extensions(version: str, capabilities: dict[str, Any]) -> None: + """SDK-defined: absent or null extension advertisements remain omitted on the legacy wire.""" + raw = { + "protocolVersion": version, + "capabilities": capabilities, + "serverInfo": {"name": "server", "version": "1"}, + } + serialized = methods.serialize_server_result("initialize", version, raw) + assert serialized == {**raw, "capabilities": {}} + parsed = methods.parse_server_result("initialize", version, serialized) + assert isinstance(parsed, types.InitializeResult) + assert parsed.capabilities.extensions is None + + def test_elicit_request_surface_accepts_loose_property_schemas(): """Older python-sdk emits `anyOf` for `Optional` form fields; the surface gate must let it through.""" params = {