Skip to content

Commit 1368213

Browse files
committed
Fix discriminator direction, elicit gate, and inbound leniency
- Runner discriminator uses direction-specific SPEC_CLIENT_METHODS so server-direction methods (roots/list, sampling/createMessage, elicitation/create) registered via add_request_handler reach their handler instead of METHOD_NOT_FOUND - Elicit gate now validates the rendered schema property-by-property against PrimitiveSchemaDefinition (restores list[str] + json_schema_extra multi-select; bare list[str] without enum still rejected; Literal[...] now supported) - gen_surface_types.py always invokes datamodel-codegen via uv (the shutil.which() shortcut defeated the version pin) - RequestedSchema.properties relaxed to dict[str, Any] in the generated surfaces (older python-sdk releases emit anyOf for Optional fields); ElicitResult.content value union gains a null arm (monolith superset leniency) - Client _on_notify logs version-absent notifications at debug, malformed at warning (mirrors runner) - migration.md: fix the input_schema={} example; document client-side surface validation; correct the elicit-gate section
1 parent 67116fb commit 1368213

14 files changed

Lines changed: 371 additions & 204 deletions

File tree

‎docs/migration.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ Because `populate_by_name=True` is set, the old camelCase names still work as co
225225

226226
Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected.
227227

228+
### Client validates inbound traffic against the protocol schema
229+
230+
`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).
231+
228232
### `args` parameter removed from `ClientSessionGroup.call_tool()`
229233

230234
The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead.
@@ -535,9 +539,9 @@ await ctx.log(level="info", data="hello")
535539

536540
Positional calls (`await ctx.info("hello")`) are unaffected.
537541

538-
### `Context.elicit()` schema gate tightened
542+
### `Context.elicit()` schema gate validates the rendered schema
539543

540-
`Context.elicit()` (and `elicit_with_validation()`) now accept only schemas whose fields are a single primitive type (`str`, `int`, `float`, `bool`) or `Optional[primitive]`. `list[...]` fields and unions of multiple primitives (e.g. `int | str`) raise `TypeError` at the call site; previously `list[str]` and arbitrary primitive unions were allowed but produced a `requestedSchema` outside the spec's restricted subset. `Optional[T]` fields now render as `{"type": ...}` with the field omitted from `required` instead of the non-spec `anyOf` shape.
544+
`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected.
541545

542546
### Replace `RootModel` by union types with `TypeAdapter` validation
543547

@@ -947,7 +951,7 @@ from mcp.types import (
947951
)
948952

949953
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
950-
return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={})])
954+
return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})])
951955

952956

953957
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:

‎scripts/gen_surface_types.py‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import hashlib
1515
import json
1616
import re
17-
import shutil
1817
import subprocess
1918
import sys
2019
import tempfile
@@ -35,22 +34,40 @@
3534
("$defs/NumberSchema/properties/default/type", "integer", ["integer", "number"]),
3635
("$defs/NumberSchema/properties/maximum/type", "integer", ["integer", "number"]),
3736
("$defs/NumberSchema/properties/minimum/type", "integer", ["integer", "number"]),
37+
# ``null`` arm is monolith superset leniency: hosts may answer optional form fields with null.
3838
(
3939
"$defs/ElicitResult/properties/content/additionalProperties/anyOf/1/type",
4040
["string", "integer", "boolean"],
41-
["string", "integer", "number", "boolean"],
41+
["string", "integer", "number", "boolean", "null"],
42+
),
43+
# Older python-sdk releases emit ``anyOf`` for Optional fields; the callback's
44+
# own schema validation is the real gate, so accept any property shape inbound.
45+
# PrimitiveSchemaDefinition becomes an orphan $def after this patch but
46+
# datamodel-codegen still emits it; elicitation.py imports it as the gate type.
47+
(
48+
"$defs/ElicitRequestFormParams/properties/requestedSchema/properties/properties/additionalProperties",
49+
{"$ref": "#/$defs/PrimitiveSchemaDefinition"},
50+
{},
4251
),
4352
],
4453
"2026-07-28": [
4554
("$defs/NumberSchema/properties/default/type", "number", ["integer", "number"]),
4655
("$defs/NumberSchema/properties/maximum/type", "number", ["integer", "number"]),
4756
("$defs/NumberSchema/properties/minimum/type", "number", ["integer", "number"]),
57+
# ``null`` arm is monolith superset leniency: hosts may answer optional form fields with null.
4858
(
4959
"$defs/ElicitResult/properties/content/additionalProperties/anyOf/1/type",
5060
["string", "integer", "boolean"],
51-
["string", "integer", "number", "boolean"],
61+
["string", "integer", "number", "boolean", "null"],
5262
),
5363
("$defs/JSONValue/anyOf/2/type", ["string", "integer", "boolean"], ["string", "integer", "number", "boolean"]),
64+
# Older python-sdk releases emit ``anyOf`` for Optional fields; the callback's
65+
# own schema validation is the real gate, so accept any property shape inbound.
66+
(
67+
"$defs/ElicitRequestFormParams/properties/requestedSchema/properties/properties/additionalProperties",
68+
{"$ref": "#/$defs/PrimitiveSchemaDefinition"},
69+
{},
70+
),
5471
],
5572
}
5673

@@ -107,13 +124,11 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) ->
107124

108125

109126
def run_codegen(schema_path: Path, output_path: Path) -> None:
110-
"""Run datamodel-code-generator (from the ``codegen`` dependency group)."""
111-
exe = shutil.which("datamodel-codegen")
112-
cmd = [exe] if exe else ["uv", "run", "--frozen", "--group", "codegen", "datamodel-codegen"]
127+
"""Run datamodel-code-generator at the version pinned in the ``codegen`` dependency group."""
113128
# fmt: off
114129
result = subprocess.run(
115130
[
116-
*cmd,
131+
"uv", "run", "--frozen", "--group", "codegen", "datamodel-codegen",
117132
"--input", str(schema_path),
118133
"--input-file-type", "jsonschema",
119134
"--output", str(output_path),

‎src/mcp/client/session.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,10 @@ async def _on_notify(
554554
version = self.protocol_version or "2025-11-25"
555555
try:
556556
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
557-
except (KeyError, ValidationError):
557+
except KeyError:
558+
logger.debug("dropped %r: not defined at %s", method, version)
559+
return
560+
except ValidationError:
558561
logger.warning("Failed to validate notification: %s", method, exc_info=True)
559562
return
560563
if isinstance(notification, types.CancelledNotification):

‎src/mcp/server/elicitation.py‎

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22

33
from __future__ import annotations
44

5-
import types
6-
from typing import Any, Generic, Literal, TypeVar, Union, get_args, get_origin
5+
from typing import Any, Generic, Literal, TypeVar
76

8-
from pydantic import BaseModel
7+
from pydantic import BaseModel, ValidationError
98
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
109
from pydantic_core import core_schema
1110

1211
from mcp.server.session import ServerSession
1312
from mcp.types import RequestId
1413

14+
# Internal surface package; imported as the gate's source of truth for spec-valid property schemas.
15+
from mcp.types.v2025_11_25 import PrimitiveSchemaDefinition
16+
1517
ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel)
1618

1719

@@ -46,10 +48,6 @@ class AcceptedUrlElicitation(BaseModel):
4648
UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation
4749

4850

49-
# Primitive types allowed in elicitation schemas
50-
_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool)
51-
52-
5351
class _ElicitationJsonSchema(GenerateJsonSchema):
5452
"""JSON-Schema generator that flattens `T | None` to `T` and drops `None` defaults.
5553
@@ -68,26 +66,20 @@ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaVal
6866
return result
6967

7068

71-
def _validate_elicitation_schema(schema: type[BaseModel]) -> None:
72-
"""Validate that a Pydantic model only contains primitive field types."""
73-
for field_name, field_info in schema.model_fields.items():
74-
if not _is_primitive_field(field_info.annotation):
75-
raise TypeError(
76-
f"Elicitation schema field '{field_name}' must be a primitive type "
77-
f"{_ELICITATION_PRIMITIVE_TYPES} or Optional of one. Unions of multiple "
78-
f"primitives, lists, and nested models are not allowed."
79-
)
80-
69+
def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
70+
"""Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept.
8171
82-
def _is_primitive_field(annotation: Any) -> bool:
83-
"""True if `annotation` is a single primitive type, optionally wrapped in `Optional`."""
84-
if annotation in _ELICITATION_PRIMITIVE_TYPES:
85-
return True
86-
origin = get_origin(annotation)
87-
if origin is Union or origin is types.UnionType:
88-
non_none = [a for a in get_args(annotation) if a is not types.NoneType]
89-
return len(non_none) == 1 and non_none[0] in _ELICITATION_PRIMITIVE_TYPES
90-
return False
72+
Catches whatever the renderer let through that isn't spec-valid: bare
73+
`list[str]` (no enum), multi-primitive unions, nested models.
74+
"""
75+
for field_name, prop in json_schema.get("properties", {}).items():
76+
try:
77+
PrimitiveSchemaDefinition.model_validate(prop)
78+
except ValidationError:
79+
raise TypeError(
80+
f"Elicitation schema field {field_name!r} rendered as {prop!r}, "
81+
f"which is not a valid PrimitiveSchemaDefinition"
82+
) from None
9183

9284

9385
async def elicit_with_validation(
@@ -106,10 +98,8 @@ async def elicit_with_validation(
10698
10799
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
108100
"""
109-
# Validate that schema only contains primitive types and fail loudly if not
110-
_validate_elicitation_schema(schema)
111-
112101
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
102+
_validate_rendered_properties(json_schema)
113103

114104
result = await session.elicit_form(
115105
message=message,

‎src/mcp/server/runner.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ async def _on_request(
223223
# handshake (which only exists at <=2025) and stateless until the header
224224
# is plumbed; its meaning is fixed regardless of LATEST bumps.
225225
version = self.connection.protocol_version or "2025-11-25"
226-
is_spec_method = method in _methods.MONOLITH_REQUESTS
226+
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
227227

228228
async def _inner() -> HandlerResult:
229229
# Pinned compat: spec methods are surface-validated before lookup,
@@ -294,7 +294,7 @@ async def _on_notify(
294294
version = self.connection.protocol_version or "2025-11-25"
295295

296296
async def _inner() -> None:
297-
if method in _methods.MONOLITH_NOTIFICATIONS:
297+
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
298298
try:
299299
_methods.validate_client_notification(method, version, params)
300300
except KeyError:

‎src/mcp/types/methods.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"SERVER_NOTIFICATIONS",
3333
"SERVER_REQUESTS",
3434
"SERVER_RESULTS",
35+
"SPEC_CLIENT_METHODS",
36+
"SPEC_CLIENT_NOTIFICATION_METHODS",
3537
"parse_client_notification",
3638
"parse_client_request",
3739
"parse_client_result",
@@ -325,6 +327,15 @@
325327
"""Results clients send, keyed by the originating server request's (method, version)."""
326328

327329

330+
# --- Direction-specific method sets ---
331+
332+
SPEC_CLIENT_METHODS: Final[frozenset[str]] = frozenset(m for m, _ in CLIENT_REQUESTS)
333+
"""Spec request methods a client may send (any version); the server-side spec-method discriminator."""
334+
335+
SPEC_CLIENT_NOTIFICATION_METHODS: Final[frozenset[str]] = frozenset(m for m, _ in CLIENT_NOTIFICATIONS)
336+
"""Spec notification methods a client may send (any version); the server-side spec-method discriminator."""
337+
338+
328339
# --- Monolith maps ---
329340

330341
MONOLITH_REQUESTS: Final[Mapping[str, type[types.Request[Any, Any]]]] = MappingProxyType(

‎src/mcp/types/v2025_11_25/__init__.py‎

Lines changed: 52 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,21 @@ class Cursor(RootModel[str]):
292292
"""
293293

294294

295+
class RequestedSchema(WireModel):
296+
"""
297+
A restricted subset of JSON Schema.
298+
Only top-level properties are allowed, without nesting.
299+
"""
300+
301+
model_config = ConfigDict(
302+
extra="ignore",
303+
)
304+
schema_: Annotated[str | None, Field(alias="$schema")] = None
305+
properties: dict[str, Any]
306+
required: list[str] | None = None
307+
type: Literal["object"]
308+
309+
295310
class ElicitResult(WireModel):
296311
"""
297312
The client's response to an elicitation request.
@@ -311,7 +326,7 @@ class ElicitResult(WireModel):
311326
- "decline": User explicitly decline the action
312327
- "cancel": User dismissed without making an explicit choice
313328
"""
314-
content: dict[str, list[str] | str | int | float | bool] | None = None
329+
content: dict[str, list[str] | str | int | float | bool | None] | None = None
315330
"""
316331
The submitted form data, only present when action is "accept" and mode was "form".
317332
Contains values matching the requested schema.
@@ -1753,6 +1768,42 @@ class CompleteRequestParams(WireModel):
17531768
ref: PromptReference | ResourceTemplateReference
17541769

17551770

1771+
class ElicitRequestFormParams(WireModel):
1772+
"""
1773+
The parameters for a request to elicit non-sensitive information from the user via a form in the client.
1774+
"""
1775+
1776+
model_config = ConfigDict(
1777+
extra="ignore",
1778+
)
1779+
meta: Annotated[Meta | None, Field(alias="_meta")] = None
1780+
"""
1781+
See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.
1782+
"""
1783+
message: str
1784+
"""
1785+
The message to present to the user describing what information is being requested.
1786+
"""
1787+
mode: Literal["form"] = "form"
1788+
"""
1789+
The elicitation mode.
1790+
"""
1791+
requested_schema: Annotated[RequestedSchema, Field(alias="requestedSchema")]
1792+
"""
1793+
A restricted subset of JSON Schema.
1794+
Only top-level properties are allowed, without nesting.
1795+
"""
1796+
task: TaskMetadata | None = None
1797+
"""
1798+
If specified, the caller is requesting task-augmented execution for this request.
1799+
The request will return a CreateTaskResult immediately, and the actual result can be
1800+
retrieved later via tasks/result.
1801+
1802+
Task augmentation is subject to capability negotiation - receivers MUST declare support
1803+
for task augmentation of specific request types in their capabilities.
1804+
"""
1805+
1806+
17561807
class ElicitRequestURLParams(WireModel):
17571808
"""
17581809
The parameters for a request to elicit information from the user via a URL in the client.
@@ -2761,57 +2812,6 @@ class CreateTaskResult(WireModel):
27612812
task: Task
27622813

27632814

2764-
class RequestedSchema(WireModel):
2765-
"""
2766-
A restricted subset of JSON Schema.
2767-
Only top-level properties are allowed, without nesting.
2768-
"""
2769-
2770-
model_config = ConfigDict(
2771-
extra="ignore",
2772-
)
2773-
schema_: Annotated[str | None, Field(alias="$schema")] = None
2774-
properties: dict[str, PrimitiveSchemaDefinition]
2775-
required: list[str] | None = None
2776-
type: Literal["object"]
2777-
2778-
2779-
class ElicitRequestFormParams(WireModel):
2780-
"""
2781-
The parameters for a request to elicit non-sensitive information from the user via a form in the client.
2782-
"""
2783-
2784-
model_config = ConfigDict(
2785-
extra="ignore",
2786-
)
2787-
meta: Annotated[Meta | None, Field(alias="_meta")] = None
2788-
"""
2789-
See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.
2790-
"""
2791-
message: str
2792-
"""
2793-
The message to present to the user describing what information is being requested.
2794-
"""
2795-
mode: Literal["form"] = "form"
2796-
"""
2797-
The elicitation mode.
2798-
"""
2799-
requested_schema: Annotated[RequestedSchema, Field(alias="requestedSchema")]
2800-
"""
2801-
A restricted subset of JSON Schema.
2802-
Only top-level properties are allowed, without nesting.
2803-
"""
2804-
task: TaskMetadata | None = None
2805-
"""
2806-
If specified, the caller is requesting task-augmented execution for this request.
2807-
The request will return a CreateTaskResult immediately, and the actual result can be
2808-
retrieved later via tasks/result.
2809-
2810-
Task augmentation is subject to capability negotiation - receivers MUST declare support
2811-
for task augmentation of specific request types in their capabilities.
2812-
"""
2813-
2814-
28152815
class ElicitRequestParams(RootModel[ElicitRequestURLParams | ElicitRequestFormParams]):
28162816
root: ElicitRequestURLParams | ElicitRequestFormParams
28172817
"""

0 commit comments

Comments
 (0)