Skip to content

Commit 1f08231

Browse files
committed
Check custom capabilities and document them
1 parent 153661c commit 1f08231

6 files changed

Lines changed: 30 additions & 18 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+
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.
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

‎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: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,28 +1008,19 @@ 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-
def test_discover_round_trips_unknown_server_capabilities():
1012-
capabilities = types.ServerCapabilities.model_validate({"tools": {}, "events": {"listChanged": False}})
1013-
result = types.DiscoverResult(supported_versions=["2026-07-28"], capabilities=capabilities)
1014-
dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True)
1015-
sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped)
1016-
assert sieved["capabilities"] == {"tools": {}, "events": {"listChanged": False}}
1017-
parsed = methods.parse_server_result("server/discover", "2026-07-28", sieved)
1018-
assert isinstance(parsed, types.DiscoverResult)
1019-
assert parsed.capabilities.model_dump(by_alias=True, exclude_none=True) == sieved["capabilities"]
1020-
1021-
10221011
@pytest.mark.parametrize("version", PRE_2026)
10231012
def test_initialize_round_trips_unknown_capabilities_at_pre_2026_versions(version: str):
1024-
capabilities: dict[str, Any] = {"tools": {}, "events": {}}
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}
10251017
result = {"protocolVersion": version, "capabilities": capabilities, "serverInfo": {"name": "S", "version": "1"}}
10261018
sieved = methods.serialize_server_result("initialize", version, result)
10271019
assert sieved["capabilities"] == capabilities
1028-
client_info = {"name": "C", "version": "1"}
1029-
params = {"protocolVersion": version, "capabilities": {"events": {"enabled": True}}, "clientInfo": client_info}
1020+
params = {"protocolVersion": version, "capabilities": custom, "clientInfo": {"name": "C", "version": "1"}}
10301021
request = methods.parse_client_request("initialize", version, params)
10311022
assert isinstance(request, types.InitializeRequest)
1032-
assert request.params.capabilities.model_dump(exclude_none=True) == {"events": {"enabled": True}}
1023+
assert request.params.capabilities.model_extra == custom
10331024

10341025

10351026
def test_serialize_server_result_drops_an_unknown_nested_tool_field():

0 commit comments

Comments
 (0)