Skip to content

Commit 82adc27

Browse files
authored
Keep unknown keys on client and server capabilities (#3812)
1 parent af4c6f4 commit 82adc27

10 files changed

Lines changed: 66 additions & 12 deletions

File tree

‎docs/client/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Everything else on this page is identical across all four. Headers, subprocesses
4242
Four read-only properties, populated the moment you enter the block:
4343

4444
* `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.
45-
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
45+
* `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`.
4646
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
4747
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
4848

‎docs/migration.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ params = CallToolRequestParams(
365365

366366
If you relied on extra fields round-tripping through MCP types, move that data into `_meta`.
367367

368+
`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.
369+
368370
### Resource URI type changed from `AnyUrl` to `str`
369371

370372
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
26532655

26542656
### Client validates inbound traffic against the protocol schema
26552657

2656-
`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).
2658+
`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.
26572659

26582660
### Unknown request methods now return `-32601` (Method not found)
26592661

‎scripts/gen_surface_types.py‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,24 @@
106106
}
107107

108108
# Classes the spec defines as open key-value bags: `_meta` content, the
109-
# JSON-Schema-document fields on `Tool`, and the schemas with explicit
110-
# `additionalProperties: {}`. These keep `extra="allow"` so the sieve preserves
111-
# arbitrary keys; every other class ignores extras. Per-version because codegen
112-
# reuses class names across versions for unrelated schemas (e.g. `Data`).
109+
# JSON-Schema-document fields on `Tool`, the capability objects (documented as
110+
# "not a closed set"), and the schemas with explicit `additionalProperties: {}`.
111+
# These keep `extra="allow"` so the sieve preserves arbitrary keys; every other
112+
# class ignores extras. Per-version because codegen reuses class names across
113+
# versions for unrelated schemas (e.g. `Data`).
113114
OPEN_CLASSES: dict[str, frozenset[str]] = {
114-
"2025-11-25": frozenset({"Meta", "InputSchema", "OutputSchema", "Result", "GetTaskPayloadResult", "Data"}),
115+
"2025-11-25": frozenset(
116+
{
117+
"Meta",
118+
"InputSchema",
119+
"OutputSchema",
120+
"Result",
121+
"GetTaskPayloadResult",
122+
"Data",
123+
"ClientCapabilities",
124+
"ServerCapabilities",
125+
}
126+
),
115127
"2026-07-28": frozenset(
116128
{
117129
"MetaObject",
@@ -122,6 +134,8 @@
122134
"InputSchema",
123135
"OutputSchema",
124136
"Result",
137+
"ClientCapabilities",
138+
"ServerCapabilities",
125139
}
126140
),
127141
}

‎src/mcp-types/mcp_types/_types.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ class ClientCapabilities(MCPModel):
393393
`initialize` through 2025-11-25; per-request in `_meta` on 2026-07-28.
394394
"""
395395

396+
model_config = ConfigDict(extra="allow")
397+
396398
experimental: dict[str, dict[str, Any]] | None = None
397399
"""Experimental, non-standard capabilities that the client supports."""
398400
sampling: SamplingCapability | None = None
@@ -485,6 +487,8 @@ class ServerTasksCapability(MCPModel):
485487
class ServerCapabilities(MCPModel):
486488
"""Capabilities that a server may support. Not a closed set."""
487489

490+
model_config = ConfigDict(extra="allow")
491+
488492
experimental: dict[str, dict[str, Any]] | None = None
489493
"""Experimental, non-standard capabilities that the server supports."""
490494

‎src/mcp-types/mcp_types/_v2025_11_25/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ class ClientCapabilities(WireModel):
196196
"""
197197

198198
model_config = ConfigDict(
199-
extra="ignore",
199+
extra="allow",
200200
)
201201
elicitation: Elicitation | None = None
202202
"""
@@ -1106,7 +1106,7 @@ class ServerCapabilities(WireModel):
11061106
"""
11071107

11081108
model_config = ConfigDict(
1109-
extra="ignore",
1109+
extra="allow",
11101110
)
11111111
completions: dict[str, Any] | None = None
11121112
"""

‎src/mcp-types/mcp_types/_v2026_07_28/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,7 +2041,7 @@ class ServerCapabilities(WireModel):
20412041
"""
20422042

20432043
model_config = ConfigDict(
2044-
extra="ignore",
2044+
extra="allow",
20452045
)
20462046
completions: JSONObject | None = None
20472047
"""
@@ -2287,7 +2287,7 @@ class ClientCapabilities(WireModel):
22872287
"""
22882288

22892289
model_config = ConfigDict(
2290-
extra="ignore",
2290+
extra="allow",
22912291
)
22922292
elicitation: Elicitation | None = None
22932293
"""

‎src/mcp/server/connection.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,4 +490,4 @@ def check_capability(self, capability: ClientCapabilities) -> bool:
490490
for identifier in capability.extensions:
491491
if identifier not in have.extensions:
492492
return False
493-
return True
493+
return set(capability.model_extra or {}) <= set(have.model_extra or {})

‎tests/server/lowlevel/test_server_discover.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,23 @@ async def custom_discover(
215215
assert result is custom
216216

217217

218+
@pytest.mark.anyio
219+
async def test_a_custom_capability_reaches_the_client() -> None:
220+
"""Spec-mandated: capabilities are "not a closed set", so a key the SDK doesn't model, like
221+
MCP Events' `events`, survives the wire sieve and lands in the client's `model_extra`."""
222+
server = Server("events-server")
223+
events = {"listChanged": False}
224+
225+
async def discover(ctx: ServerRequestContext[Any], params: types.RequestParams) -> types.DiscoverResult:
226+
capabilities = types.ServerCapabilities.model_validate({"events": events})
227+
return types.DiscoverResult(supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=capabilities)
228+
229+
server.add_request_handler("server/discover", types.RequestParams, discover)
230+
231+
async with Client(server) as client:
232+
assert client.server_capabilities.model_extra == {"events": events}
233+
234+
218235
async def _listen_stub(
219236
ctx: ServerRequestContext[Any], params: types.SubscriptionsListenRequestParams
220237
) -> types.SubscriptionsListenResult:

‎tests/server/test_connection.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,8 @@ def test_client_params_assignment_keeps_capabilities_in_lockstep():
402402
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
403403
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
404404
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"a": {}}), True),
405+
(ClientCapabilities(), ClientCapabilities.model_validate({"events": {}}), False),
406+
(ClientCapabilities.model_validate({"events": {}}), ClientCapabilities.model_validate({"events": {}}), True),
405407
],
406408
)
407409
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):

‎tests/types/test_methods.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,21 @@ def test_serialize_server_result_drops_top_level_server_info_on_discover_but_kee
10081008
assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp}
10091009

10101010

1011+
@pytest.mark.parametrize("version", PRE_2026)
1012+
def test_initialize_round_trips_unknown_capabilities_at_pre_2026_versions(version: str):
1013+
"""Spec-mandated: capabilities are "not a closed set", so unknown top-level keys survive the
1014+
`initialize` sieve on both the server result and the client params."""
1015+
custom: dict[str, Any] = {"events": {"enabled": True}}
1016+
capabilities: dict[str, Any] = {"tools": {}, **custom}
1017+
result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}}
1018+
sieved = methods.serialize_server_result("initialize", version, result)
1019+
assert sieved["capabilities"] == capabilities
1020+
params = {"protocolVersion": version, "capabilities": custom, "clientInfo": {"name": "C", "version": "1"}}
1021+
request = methods.parse_client_request("initialize", version, params)
1022+
assert isinstance(request, types.InitializeRequest)
1023+
assert request.params.capabilities.model_extra == custom
1024+
1025+
10111026
def test_serialize_server_result_drops_an_unknown_nested_tool_field():
10121027
tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1}
10131028
sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"})

0 commit comments

Comments
 (0)