Skip to content

Commit 53a538a

Browse files
committed
Check every alias form when reserving core surface wire keys
A claim model could route a reserved key through validation_alias, serialization_alias, or an AliasChoices entry and still hit the core pre-validation dead end the reservation exists to prevent. _wire_keys collects every top-level key a field can read from or write to, and the reservation checks the full set.
1 parent 99fec3c commit 53a538a

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

src/mcp/client/extension.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from mcp_types import CORE_RESULT_TYPES, CallToolResult, InputRequiredResult, Result
1515
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
16-
from pydantic import BaseModel
16+
from pydantic import AliasChoices, AliasPath, BaseModel
17+
from pydantic.fields import FieldInfo
1718

1819
from mcp.shared.extension import validate_extension_identifier
1920

@@ -35,6 +36,22 @@
3536
_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"})
3637
"""Typed optional fields of the core result surface that pre-validates every inbound result."""
3738

39+
40+
def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]:
41+
"""Every top-level wire key this field can read from or write to."""
42+
keys = {field.alias or name}
43+
if field.serialization_alias:
44+
keys.add(field.serialization_alias)
45+
validation_alias = field.validation_alias
46+
choices = validation_alias.choices if isinstance(validation_alias, AliasChoices) else [validation_alias]
47+
for choice in choices:
48+
if isinstance(choice, AliasPath):
49+
choice = choice.path[0]
50+
if isinstance(choice, str):
51+
keys.add(choice)
52+
return frozenset(keys)
53+
54+
3855
ClaimedT = TypeVar("ClaimedT", bound=Result)
3956
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
4057

@@ -74,11 +91,11 @@ def __post_init__(self) -> None:
7491
if issubclass(self.model, CallToolResult | InputRequiredResult):
7592
raise ValueError("claim models must not subclass core result types")
7693
for name, model_field in self.model.model_fields.items():
77-
if (model_field.alias or name) in _RESERVED_WIRE_ALIASES:
94+
for clash in sorted(_wire_keys(name, model_field) & _RESERVED_WIRE_ALIASES):
7895
raise ValueError(
79-
f"{self.model.__name__}.{name} aliases {model_field.alias or name!r}, a typed field "
80-
"of the core result surface; a colliding value would fail core validation before "
81-
"the claim adapter runs"
96+
f"{self.model.__name__}.{name} aliases {clash!r}, a typed field of the core "
97+
"result surface; a colliding value would fail core validation before the "
98+
"claim adapter runs"
8299
)
83100
field = self.model.model_fields.get("result_type")
84101
if field is None or get_args(field.annotation) != (self.result_type,):

tests/client/test_extension.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
from inline_snapshot import snapshot
88
from mcp_types import CallToolResult, InputRequiredResult, Result
99
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
10-
from pydantic import BaseModel
10+
from pydantic import AliasChoices, AliasPath, BaseModel, Field
11+
from pydantic.fields import FieldInfo
1112

1213
from mcp.client.extension import (
1314
ClaimContext,
1415
ClientExtension,
1516
NotificationBinding,
1617
ResultClaim,
18+
_wire_keys,
1719
advertise,
1820
)
1921

@@ -148,6 +150,60 @@ def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
148150
)
149151

150152

153+
class _ValidationAliasResult(Result):
154+
result_type: Literal["va"] = "va"
155+
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
156+
157+
158+
class _SerializationAliasResult(Result):
159+
result_type: Literal["sa"] = "sa"
160+
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
161+
162+
163+
class _AliasChoicesResult(Result):
164+
result_type: Literal["ac"] = "ac"
165+
vendor_state: dict[str, Any] | None = Field(
166+
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
167+
)
168+
169+
170+
class _AliasPathResult(Result):
171+
result_type: Literal["ap"] = "ap"
172+
vendor_state: dict[str, Any] | None = Field(
173+
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
174+
)
175+
176+
177+
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
178+
"""SDK-defined: a field with no aliases reads and writes only its own name."""
179+
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
180+
181+
182+
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
183+
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
184+
messages: dict[str, str] = {}
185+
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
186+
with pytest.raises(ValueError) as exc_info:
187+
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
188+
messages[model.__name__] = str(exc_info.value)
189+
190+
assert messages == snapshot(
191+
{
192+
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
193+
"'requestState', a typed field of the core result surface; a colliding value would fail "
194+
"core validation before the claim adapter runs",
195+
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
196+
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
197+
"core validation before the claim adapter runs",
198+
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
199+
"result surface; a colliding value would fail core validation before the claim adapter runs",
200+
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
201+
"'requestState', a typed field of the core result surface; a colliding value would fail "
202+
"core validation before the claim adapter runs",
203+
}
204+
)
205+
206+
151207
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
152208
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
153209
with pytest.raises(ValueError) as exc_info:

0 commit comments

Comments
 (0)