From 153661c9ab028bbaafc952f796ad87f9186b1eb0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 3 Nov 2026 13:15:31 +0100 Subject: [PATCH 1/3] Keep unknown keys on client and server capabilities --- scripts/gen_surface_types.py | 24 +++++++++++++++---- src/mcp-types/mcp_types/_types.py | 4 ++++ .../mcp_types/_v2025_11_25/__init__.py | 4 ++-- .../mcp_types/_v2026_07_28/__init__.py | 4 ++-- tests/types/test_methods.py | 24 +++++++++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index 230e704682..fff6837165 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -106,12 +106,24 @@ } # Classes the spec defines as open key-value bags: `_meta` content, the -# JSON-Schema-document fields on `Tool`, and the schemas with explicit -# `additionalProperties: {}`. These keep `extra="allow"` so the sieve preserves -# arbitrary keys; every other class ignores extras. Per-version because codegen -# reuses class names across versions for unrelated schemas (e.g. `Data`). +# JSON-Schema-document fields on `Tool`, the capability objects (documented as +# "not a closed set"), and the schemas with explicit `additionalProperties: {}`. +# These keep `extra="allow"` so the sieve preserves arbitrary keys; every other +# class ignores extras. Per-version because codegen reuses class names across +# versions for unrelated schemas (e.g. `Data`). OPEN_CLASSES: dict[str, frozenset[str]] = { - "2025-11-25": frozenset({"Meta", "InputSchema", "OutputSchema", "Result", "GetTaskPayloadResult", "Data"}), + "2025-11-25": frozenset( + { + "Meta", + "InputSchema", + "OutputSchema", + "Result", + "GetTaskPayloadResult", + "Data", + "ClientCapabilities", + "ServerCapabilities", + } + ), "2026-07-28": frozenset( { "MetaObject", @@ -122,6 +134,8 @@ "InputSchema", "OutputSchema", "Result", + "ClientCapabilities", + "ServerCapabilities", } ), } diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index e91fdcc3b9..c00eb8e061 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -393,6 +393,8 @@ class ClientCapabilities(MCPModel): `initialize` through 2025-11-25; per-request in `_meta` on 2026-07-28. """ + model_config = ConfigDict(extra="allow") + experimental: dict[str, dict[str, Any]] | None = None """Experimental, non-standard capabilities that the client supports.""" sampling: SamplingCapability | None = None @@ -485,6 +487,8 @@ class ServerTasksCapability(MCPModel): class ServerCapabilities(MCPModel): """Capabilities that a server may support. Not a closed set.""" + model_config = ConfigDict(extra="allow") + experimental: dict[str, dict[str, Any]] | None = None """Experimental, non-standard capabilities that the server supports.""" 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 1210a80aff..6444417884 100644 --- a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py +++ b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py @@ -196,7 +196,7 @@ class ClientCapabilities(WireModel): """ model_config = ConfigDict( - extra="ignore", + extra="allow", ) elicitation: Elicitation | None = None """ @@ -1106,7 +1106,7 @@ class ServerCapabilities(WireModel): """ model_config = ConfigDict( - extra="ignore", + extra="allow", ) completions: dict[str, Any] | None = None """ diff --git a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py index 869700f272..ed237d7ad8 100644 --- a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py @@ -2041,7 +2041,7 @@ class ServerCapabilities(WireModel): """ model_config = ConfigDict( - extra="ignore", + extra="allow", ) completions: JSONObject | None = None """ @@ -2287,7 +2287,7 @@ class ClientCapabilities(WireModel): """ model_config = ConfigDict( - extra="ignore", + extra="allow", ) elicitation: Elicitation | None = None """ diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index da67e343b2..41dc090d7f 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -1008,6 +1008,30 @@ def test_serialize_server_result_drops_top_level_server_info_on_discover_but_kee assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp} +def test_discover_round_trips_unknown_server_capabilities(): + capabilities = types.ServerCapabilities.model_validate({"tools": {}, "events": {"listChanged": False}}) + result = types.DiscoverResult(supported_versions=["2026-07-28"], capabilities=capabilities) + dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True) + sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped) + assert sieved["capabilities"] == {"tools": {}, "events": {"listChanged": False}} + parsed = methods.parse_server_result("server/discover", "2026-07-28", sieved) + assert isinstance(parsed, types.DiscoverResult) + assert parsed.capabilities.model_dump(by_alias=True, exclude_none=True) == sieved["capabilities"] + + +@pytest.mark.parametrize("version", PRE_2026) +def test_initialize_round_trips_unknown_capabilities_at_pre_2026_versions(version: str): + capabilities: dict[str, Any] = {"tools": {}, "events": {}} + result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}} + sieved = methods.serialize_server_result("initialize", version, result) + assert sieved["capabilities"] == capabilities + client_info = {"name": "C", "version": "1"} + params = {"protocolVersion": version, "capabilities": {"events": {"enabled": True}}, "clientInfo": client_info} + request = methods.parse_client_request("initialize", version, params) + assert isinstance(request, types.InitializeRequest) + assert request.params.capabilities.model_dump(exclude_none=True) == {"events": {"enabled": True}} + + def test_serialize_server_result_drops_an_unknown_nested_tool_field(): tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1} sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"}) From 1f08231322137a9150da93cbd873f7c67ef9bd7e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 3 Nov 2026 13:27:08 +0100 Subject: [PATCH 2/3] Check custom capabilities and document them --- docs/client/index.md | 2 +- docs/migration.md | 4 +++- src/mcp/server/connection.py | 2 +- tests/server/lowlevel/test_server_discover.py | 17 +++++++++++++++ tests/server/test_connection.py | 2 ++ tests/types/test_methods.py | 21 ++++++------------- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/client/index.md b/docs/client/index.md index 2edf48e11f..c4c392bf5c 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -42,7 +42,7 @@ Everything else on this page is identical across all four. Headers, subprocesses Four read-only properties, populated the moment you enter the block: * `client.server_info`: the server's identity, or `None` for a 2026-era server that does not report one (python-sdk servers do by default). `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. -* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. +* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. Capabilities the SDK doesn't model, such as MCP Events' `events`, are in `client.server_capabilities.model_extra`. * `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. * `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. diff --git a/docs/migration.md b/docs/migration.md index 91379bd0f6..f1ed7acc99 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -365,6 +365,8 @@ params = CallToolRequestParams( If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. +The exceptions are `ClientCapabilities` and `ServerCapabilities`: the spec defines them as an open set, so unknown top-level keys are kept in `model_extra` and sent on the wire. + ### Resource URI type changed from `AnyUrl` to `str` The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. @@ -2653,7 +2655,7 @@ Validation runs when the result is serialized onto the wire, not when the model ### Client validates inbound traffic against the protocol schema -`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras). +`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras. Capabilities are an open set, so unknown capability keys are kept. ### Unknown request methods now return `-32601` (Method not found) diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 99ba2da481..bdd38f40b7 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -490,4 +490,4 @@ def check_capability(self, capability: ClientCapabilities) -> bool: for identifier in capability.extensions: if identifier not in have.extensions: return False - return True + return set(capability.model_extra or {}) <= set(have.model_extra or {}) diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 98fffabde0..2bc65d7a03 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -215,6 +215,23 @@ async def custom_discover( assert result is custom +@pytest.mark.anyio +async def test_a_custom_capability_reaches_the_client() -> None: + """Spec-mandated: capabilities are "not a closed set", so a key the SDK doesn't model, like + MCP Events' `events`, survives the wire sieve and lands in the client's `model_extra`.""" + server = Server("events-server") + events = {"listChanged": False} + + async def discover(ctx: ServerRequestContext[Any], params: types.RequestParams) -> types.DiscoverResult: + capabilities = types.ServerCapabilities.model_validate({"events": events}) + return types.DiscoverResult(supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=capabilities) + + server.add_request_handler("server/discover", types.RequestParams, discover) + + async with Client(server) as client: + assert client.server_capabilities.model_extra == {"events": events} + + async def _listen_stub( ctx: ServerRequestContext[Any], params: types.SubscriptionsListenRequestParams ) -> types.SubscriptionsListenResult: diff --git a/tests/server/test_connection.py b/tests/server/test_connection.py index 683473d620..2f4841b458 100644 --- a/tests/server/test_connection.py +++ b/tests/server/test_connection.py @@ -402,6 +402,8 @@ def test_client_params_assignment_keeps_capabilities_in_lockstep(): (ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False), (ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False), (ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"a": {}}), True), + (ClientCapabilities(), ClientCapabilities.model_validate({"events": {}}), False), + (ClientCapabilities.model_validate({"events": {}}), ClientCapabilities.model_validate({"events": {}}), True), ], ) def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool): diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 41dc090d7f..27acedf604 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -1008,28 +1008,19 @@ def test_serialize_server_result_drops_top_level_server_info_on_discover_but_kee assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp} -def test_discover_round_trips_unknown_server_capabilities(): - capabilities = types.ServerCapabilities.model_validate({"tools": {}, "events": {"listChanged": False}}) - result = types.DiscoverResult(supported_versions=["2026-07-28"], capabilities=capabilities) - dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True) - sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped) - assert sieved["capabilities"] == {"tools": {}, "events": {"listChanged": False}} - parsed = methods.parse_server_result("server/discover", "2026-07-28", sieved) - assert isinstance(parsed, types.DiscoverResult) - assert parsed.capabilities.model_dump(by_alias=True, exclude_none=True) == sieved["capabilities"] - - @pytest.mark.parametrize("version", PRE_2026) def test_initialize_round_trips_unknown_capabilities_at_pre_2026_versions(version: str): - capabilities: dict[str, Any] = {"tools": {}, "events": {}} + """Spec-mandated: capabilities are "not a closed set", so unknown top-level keys survive the + `initialize` sieve on both the server result and the client params.""" + custom: dict[str, Any] = {"events": {"enabled": True}} + capabilities: dict[str, Any] = {"tools": {}, **custom} result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}} sieved = methods.serialize_server_result("initialize", version, result) assert sieved["capabilities"] == capabilities - client_info = {"name": "C", "version": "1"} - params = {"protocolVersion": version, "capabilities": {"events": {"enabled": True}}, "clientInfo": client_info} + params = {"protocolVersion": version, "capabilities": custom, "clientInfo": {"name": "C", "version": "1"}} request = methods.parse_client_request("initialize", version, params) assert isinstance(request, types.InitializeRequest) - assert request.params.capabilities.model_dump(exclude_none=True) == {"events": {"enabled": True}} + assert request.params.capabilities.model_extra == custom def test_serialize_server_result_drops_an_unknown_nested_tool_field(): From d6ecadf9edf1fd889a872a5348905241e1d43d4e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 3 Nov 2026 13:37:16 +0100 Subject: [PATCH 3/3] Clarify capabilities are not the only open MCP types --- docs/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index f1ed7acc99..c7b2d4c8d5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -365,7 +365,7 @@ params = CallToolRequestParams( If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. -The exceptions are `ClientCapabilities` and `ServerCapabilities`: the spec defines them as an open set, so unknown top-level keys are kept in `model_extra` and sent on the wire. +`ClientCapabilities` and `ServerCapabilities` are exceptions: the spec defines them as an open set, so unknown top-level keys are kept in `model_extra` and sent on the wire. ### Resource URI type changed from `AnyUrl` to `str`