Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ params = CallToolRequestParams(

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

`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`

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.
Expand Down Expand Up @@ -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)

Expand Down
24 changes: 19 additions & 5 deletions scripts/gen_surface_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -122,6 +134,8 @@
"InputSchema",
"OutputSchema",
"Result",
"ClientCapabilities",
"ServerCapabilities",
Comment thread
Kludex marked this conversation as resolved.
Resolved
}
),
}
Expand Down
4 changes: 4 additions & 0 deletions src/mcp-types/mcp_types/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

experimental: dict[str, dict[str, Any]] | None = None
"""Experimental, non-standard capabilities that the client supports."""
sampling: SamplingCapability | None = None
Expand Down Expand Up @@ -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")
Comment thread
Kludex marked this conversation as resolved.
Resolved

experimental: dict[str, dict[str, Any]] | None = None
"""Experimental, non-standard capabilities that the server supports."""

Expand Down
4 changes: 2 additions & 2 deletions src/mcp-types/mcp_types/_v2025_11_25/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class ClientCapabilities(WireModel):
"""

model_config = ConfigDict(
extra="ignore",
extra="allow",
)
elicitation: Elicitation | None = None
"""
Expand Down Expand Up @@ -1106,7 +1106,7 @@ class ServerCapabilities(WireModel):
"""

model_config = ConfigDict(
extra="ignore",
extra="allow",
)
completions: dict[str, Any] | None = None
"""
Expand Down
4 changes: 2 additions & 2 deletions src/mcp-types/mcp_types/_v2026_07_28/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2041,7 +2041,7 @@ class ServerCapabilities(WireModel):
"""

model_config = ConfigDict(
extra="ignore",
extra="allow",
)
completions: JSONObject | None = None
"""
Expand Down Expand Up @@ -2287,7 +2287,7 @@ class ClientCapabilities(WireModel):
"""

model_config = ConfigDict(
extra="ignore",
extra="allow",
)
elicitation: Elicitation | None = None
"""
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {})
17 changes: 17 additions & 0 deletions tests/server/lowlevel/test_server_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions tests/server/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions tests/types/test_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,21 @@ def test_serialize_server_result_drops_top_level_server_info_on_discover_but_kee
assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp}


@pytest.mark.parametrize("version", PRE_2026)
def test_initialize_round_trips_unknown_capabilities_at_pre_2026_versions(version: str):
"""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
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_extra == custom


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"})
Expand Down
Loading