Skip to content

Commit 81f6fe8

Browse files
author
pilot-heavy-worker
committed
Keep unknown server and client capability keys
ServerCapabilities and ClientCapabilities are not closed sets, but the wire sieve dropped keys such as a draft events object during server/discover. Allow extras on those models and still remove names that belong only to another schema era. Fixes #3640
1 parent 2118f14 commit 81f6fe8

6 files changed

Lines changed: 470 additions & 13 deletions

File tree

‎scripts/gen_surface_types.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,26 @@
9696
}
9797

9898
# Classes the spec defines as open key-value bags: `_meta` content, the
99-
# JSON-Schema-document fields on `Tool`, and the schemas with explicit
100-
# `additionalProperties: {}`. These keep `extra="allow"` so the sieve preserves
101-
# arbitrary keys; every other class ignores extras. Per-version because codegen
99+
# JSON-Schema-document fields on `Tool`, the schemas with explicit
100+
# `additionalProperties: {}`, and the capability objects whose descriptions say
101+
# they are not a closed set. These keep `extra="allow"` so the sieve preserves
102+
# arbitrary keys; every other class ignores extras. Capability names that are
103+
# real fields on another schema era are removed by `serialize_server_result`
104+
# (`tasks` at 2026-07-28, `extensions` before it). Per-version because codegen
102105
# reuses class names across versions for unrelated schemas (e.g. `Data`).
103106
OPEN_CLASSES: dict[str, frozenset[str]] = {
104-
"2025-11-25": frozenset({"Meta", "InputSchema", "OutputSchema", "Result", "GetTaskPayloadResult", "Data"}),
107+
"2025-11-25": frozenset(
108+
{
109+
"Meta",
110+
"InputSchema",
111+
"OutputSchema",
112+
"Result",
113+
"GetTaskPayloadResult",
114+
"Data",
115+
"ClientCapabilities",
116+
"ServerCapabilities",
117+
}
118+
),
105119
"2026-07-28": frozenset(
106120
{
107121
"MetaObject",
@@ -112,6 +126,8 @@
112126
"InputSchema",
113127
"OutputSchema",
114128
"Result",
129+
"ClientCapabilities",
130+
"ServerCapabilities",
115131
}
116132
),
117133
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,13 @@ class ClientTasksCapability(MCPModel):
389389
class ClientCapabilities(MCPModel):
390390
"""Capabilities a client may support.
391391
392-
Not a closed set: any client can define additional capabilities. Sent once in
392+
Not a closed set: any client can define additional capabilities. Unknown keys
393+
are retained. Nested known capability objects stay closed. Sent once in
393394
`initialize` through 2025-11-25; per-request in `_meta` on 2026-07-28.
394395
"""
395396

397+
model_config = ConfigDict(extra="allow")
398+
396399
experimental: dict[str, dict[str, Any]] | None = None
397400
"""Experimental, non-standard capabilities that the client supports."""
398401
sampling: SamplingCapability | None = None
@@ -483,7 +486,15 @@ class ServerTasksCapability(MCPModel):
483486

484487

485488
class ServerCapabilities(MCPModel):
486-
"""Capabilities that a server may support. Not a closed set."""
489+
"""Capabilities that a server may support. Not a closed set.
490+
491+
Unknown keys are retained (a draft `events` object, or any other
492+
self-hosted capability). Nested known capability objects stay closed.
493+
Names that belong only to another protocol era are removed when a result
494+
is serialized for that era.
495+
"""
496+
497+
model_config = ConfigDict(extra="allow")
487498

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

‎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
"""
@@ -1102,7 +1102,7 @@ class ServerCapabilities(WireModel):
11021102
"""
11031103

11041104
model_config = ConfigDict(
1105-
extra="ignore",
1105+
extra="allow",
11061106
)
11071107
completions: dict[str, Any] | None = None
11081108
"""

‎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-types/mcp_types/methods.py‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,47 @@ def parse_server_notification(
631631
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
632632

633633

634+
def _wire_names(model: type[BaseModel]) -> frozenset[str]:
635+
"""Wire keys of `model`'s declared fields (alias when one is set)."""
636+
return frozenset((field.alias or field.serialization_alias or name) for name, field in model.model_fields.items())
637+
638+
639+
# Pre-2026 versions share the 2025-11-25 surface (see the module docstring).
640+
_SERVER_CAPABILITY_MODEL: Final[Mapping[str, type[BaseModel]]] = MappingProxyType(
641+
{"2025-11-25": v2025.ServerCapabilities, "2026-07-28": v2026.ServerCapabilities}
642+
)
643+
_ALL_SERVER_CAPABILITY_NAMES: Final[frozenset[str]] = _wire_names(v2025.ServerCapabilities) | _wire_names(
644+
v2026.ServerCapabilities
645+
)
646+
# Results whose top-level `capabilities` object is `ServerCapabilities`.
647+
_CAPABILITY_RESULT_METHODS: Final[frozenset[str]] = frozenset({"initialize", "server/discover"})
648+
649+
650+
def _capability_era(version: str) -> str:
651+
"""Schema era for capability shape. Every pre-2026 version uses 2025-11-25."""
652+
return "2026-07-28" if version >= "2026-07-28" else "2025-11-25"
653+
654+
655+
def _drop_cross_era_server_capability_keys(method: str, version: str, data: dict[str, Any]) -> None:
656+
"""Drop capability names declared only on another schema era.
657+
658+
`ServerCapabilities` is an open set, so the wire model keeps unknown keys.
659+
A name that is a real field on the other era is still version vocabulary and
660+
must not leak: `tasks` on 2026-07-28, `extensions` on every earlier version.
661+
Truly unknown keys, such as a draft `events` object, stay. Known fields were
662+
already validated by the surface model; this only removes the cross-era names.
663+
"""
664+
if method not in _CAPABILITY_RESULT_METHODS:
665+
return
666+
capabilities = data.get("capabilities")
667+
if not isinstance(capabilities, dict):
668+
return
669+
era_names = _wire_names(_SERVER_CAPABILITY_MODEL[_capability_era(version)])
670+
owned = cast("dict[str, Any]", capabilities)
671+
for name in _ALL_SERVER_CAPABILITY_NAMES - era_names:
672+
owned.pop(name, None)
673+
674+
634675
def serialize_server_result(
635676
method: str,
636677
version: str,
@@ -640,8 +681,9 @@ def serialize_server_result(
640681
) -> dict[str, Any]:
641682
"""Validate `data` against `surface` and return its surface-shaped dump.
642683
643-
The surface model carries `extra="ignore"`, so fields not in `version`'s
644-
schema are dropped from the returned dict.
684+
Closed surface models carry `extra="ignore"`, so fields not in `version`'s
685+
schema are dropped. `ServerCapabilities` is an open set: unknown keys are
686+
kept, and names that belong only to another schema era are removed.
645687
646688
Raises:
647689
ValueError: `version` is not a known protocol version.
@@ -650,9 +692,11 @@ def serialize_server_result(
650692
"""
651693
_check_known_version(version)
652694
adapter = _adapter(surface[(method, version)])
653-
return adapter.dump_python(
695+
dumped = adapter.dump_python(
654696
adapter.validate_python(data, by_name=False), by_alias=True, mode="json", exclude_none=True
655697
)
698+
_drop_cross_era_server_capability_keys(method, version, dumped)
699+
return dumped
656700

657701

658702
def validate_server_result(

0 commit comments

Comments
 (0)