Skip to content

Commit b952bdf

Browse files
committed
Serialize spec results through the per-version surface schema
Generated surface types switch to extra="ignore" (with a per-version allow-list for spec-declared open types: Result, _meta, GetTaskPayloadResult, Tool input/output schemas, URLElicitationRequiredError data). The runner now serializes spec-method results by dumping the monolith model and re-dumping through the negotiated version's surface row, so 2026-only fields (resultType, ttlMs, cacheScope) never reach the wire on a pre-2026 session. - Monolith ttl_ms/cache_scope default to None; the SDK does not stamp a caching policy. EmptyResult.result_type stays None (deployed peer servers strict-validate ping responses). - Spec methods absent at the negotiated version reject with METHOD_NOT_FOUND even if a handler is registered; custom methods fall through unchanged. - Version fallback for pre-handshake/stateless is the literal 2025-11-25, not LATEST_PROTOCOL_VERSION. - Add serialize_server_result to types.methods; codegen drift-guard asserts the open-class substitution count. - Docstrings now say method-gating per version, shape per schema era.
1 parent 73f1572 commit b952bdf

14 files changed

Lines changed: 573 additions & 368 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,6 @@ Common renames:
221221

222222
Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`).
223223

224-
### Results now serialize `resultType` and cache-directive defaults
225-
226-
Serialized results now include `resultType` by default (and `ttlMs`/`cacheScope` on cacheable results). Peers ignore unknown result fields, so this interoperates across protocol versions, but tests or recorded fixtures that compare exact serialized payloads need the new keys added.
227-
228224
### Server handler results are validated against the protocol schema
229225

230226
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.
@@ -1237,7 +1233,7 @@ If you relied on extra fields round-tripping through MCP types, move that data i
12371233

12381234
### 2025-11-25 and 2026-07-28 protocol fields modeled
12391235

1240-
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. Most are optional with `None` defaults; the result-directive fields carry serialized defaults - see [Results now serialize `resultType` and cache-directive defaults](#results-now-serialize-resulttype-and-cache-directive-defaults).
1236+
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `None`; `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
12411237

12421238
### `streamable_http_app()` available on lowlevel Server
12431239

scripts/gen_surface_types.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@
4949
],
5050
}
5151

52+
# Classes the spec defines as open key-value bags: `_meta` content, the
53+
# JSON-Schema-document fields on `Tool`, and the schemas with explicit
54+
# `additionalProperties: {}`. These keep `extra="allow"` so the sieve preserves
55+
# arbitrary keys; every other class ignores extras. Per-version because codegen
56+
# reuses class names across versions for unrelated schemas (e.g. `Data`).
57+
OPEN_CLASSES: dict[str, frozenset[str]] = {
58+
"2025-11-25": frozenset({"Meta", "InputSchema", "OutputSchema", "Result", "GetTaskPayloadResult", "Data"}),
59+
"2026-07-28": frozenset({"MetaObject", "RequestMetaObject", "InputSchema", "OutputSchema", "Result"}),
60+
}
61+
5262
# Hand-written union aliases the wire-method maps reference by value; the schema
5363
# has no named definition for "everything tools/call may return", so name it here.
5464
EPILOGUES: dict[str, str] = {
@@ -109,7 +119,7 @@ def run_codegen(schema_path: Path, output_path: Path) -> None:
109119
"--use-annotated", "--use-field-description", "--use-schema-description",
110120
"--enum-field-as-literal", "all",
111121
"--use-union-operator", "--use-double-quotes",
112-
"--extra-fields", "allow",
122+
"--extra-fields", "ignore",
113123
# JSON Schema `format` is annotation-only; codegen's defaults
114124
# (Base64Str, AnyUrl) over-assert and reject valid wire data.
115125
"--type-mappings", "byte=string", "uri=string", "uri-template=string",
@@ -122,6 +132,29 @@ def run_codegen(schema_path: Path, output_path: Path) -> None:
122132
raise SystemExit(f"datamodel-codegen failed:\n{result.stderr}")
123133

124134

135+
def allow_open_class_extras(source: str, open_classes: frozenset[str]) -> str:
136+
"""Restore ``extra="allow"`` on ``open_classes`` only.
137+
138+
Every other class uses ``extra="ignore"`` so the surface acts as a sieve;
139+
``open_classes`` are the places the spec defines as open key-value bags.
140+
"""
141+
142+
def patch(match: re.Match[str]) -> str:
143+
if match.group(1) not in open_classes:
144+
return match.group(0)
145+
return match.group(0).replace('extra="ignore"', 'extra="allow"')
146+
147+
source = re.sub(
148+
r'^class (\w+)\(WireModel\):\n(?: {4}.*\n|\n)*? {4}model_config = ConfigDict\(\n {8}extra="ignore",\n {4}\)\n',
149+
patch,
150+
source,
151+
flags=re.MULTILINE,
152+
)
153+
# Drift guard: substitution count must match the allow-list.
154+
assert source.count('extra="allow"') == len(open_classes), (source.count('extra="allow"'), open_classes)
155+
return source
156+
157+
125158
def build(entry: dict[str, str]) -> str:
126159
"""Generate, post-process, and format one version's surface module text."""
127160
version = entry["protocol_version"]
@@ -137,6 +170,7 @@ def build(entry: dict[str, str]) -> str:
137170

138171
source = re.sub(r"\A# generated by datamodel-codegen:\n#[^\n]*\n", "", source)
139172
source = re.sub(r"^class Model\(RootModel\[Any\]\):\n {4}root: Any\n+", "", source, count=1, flags=re.MULTILINE)
173+
source = allow_open_class_extras(source, OPEN_CLASSES[version])
140174
if epilogue := EPILOGUES.get(version, ""):
141175
# Insert before the trailing model_rebuild() block: pyright's evaluation
142176
# order for the recursive RootModel block is sensitive to placement.

src/mcp/server/runner.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -219,19 +219,22 @@ async def _on_request(
219219
params: Mapping[str, Any] | None,
220220
) -> dict[str, Any]:
221221
ctx = self._make_context(dctx, _extract_meta(params))
222-
# Fallback covers the initialize handshake itself and stateless mode
223-
# until the per-request version header is plumbed through.
224-
version = self.connection.protocol_version or LATEST_PROTOCOL_VERSION
222+
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
223+
# handshake (which only exists at <=2025) and stateless until the header
224+
# is plumbed; its meaning is fixed regardless of LATEST bumps.
225+
version = self.connection.protocol_version or "2025-11-25"
226+
is_spec_method = method in _methods.MONOLITH_REQUESTS
225227

226228
async def _inner() -> HandlerResult:
227229
# Pinned compat: spec methods are surface-validated before lookup,
228230
# so malformed params are INVALID_PARAMS even with no handler
229-
# registered. Custom methods miss the surface map and fall through
231+
# registered. Custom methods miss the monolith map and fall through
230232
# to `entry.params_type` exactly as before.
231-
try:
232-
_methods.validate_client_request(method, version, params)
233-
except KeyError:
234-
pass # not a spec method at this version; lookup below handles it
233+
if is_spec_method:
234+
try:
235+
_methods.validate_client_request(method, version, params)
236+
except KeyError:
237+
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
235238
# TODO(maxisbey): the 2026-07-28 spec drops the handshake; this branch and
236239
# the gate become a per-version legacy path then. Initialize runs inline
237240
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
@@ -259,15 +262,21 @@ async def _inner() -> HandlerResult:
259262

260263
call = self._compose_server_middleware(ctx, method, params, _inner)
261264
result = _dump_result(await call())
262-
try:
263-
_methods.validate_server_result(method, version, result)
264-
except KeyError:
265-
pass # custom method; no result schema
266-
except ValidationError:
267-
# Server bug, not client fault. Detail stays in the server log:
268-
# pydantic messages echo the result body.
269-
logger.exception("handler for %r returned an invalid result", method)
270-
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
265+
# TODO: reject resultType values outside {"complete", "input_required"} unless the
266+
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
267+
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
268+
if is_spec_method:
269+
try:
270+
result = _methods.serialize_server_result(method, version, result)
271+
except KeyError:
272+
# Middleware short-circuited a wrong-version spec method without
273+
# calling `call_next`; it owns the result shape.
274+
pass
275+
except ValidationError:
276+
# Server bug, not client fault. Detail stays in the server log:
277+
# pydantic messages echo the result body.
278+
logger.exception("handler for %r returned an invalid result", method)
279+
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
271280
if method == "initialize":
272281
# Commit only on chain success, so a middleware veto leaves no state.
273282
# Race-free: the read loop is parked until this call returns.
@@ -282,16 +291,18 @@ async def _on_notify(
282291
) -> None:
283292
ctx = self._make_context(dctx, _extract_meta(params))
284293
# Same fallback as `_on_request`: covers pre-handshake and stateless.
285-
version = self.connection.protocol_version or LATEST_PROTOCOL_VERSION
294+
version = self.connection.protocol_version or "2025-11-25"
286295

287296
async def _inner() -> None:
288-
try:
289-
_methods.validate_client_notification(method, version, params)
290-
except KeyError:
291-
pass # not a spec notification at this version
292-
except ValidationError:
293-
logger.warning("dropped %r: malformed params", method)
294-
return
297+
if method in _methods.MONOLITH_NOTIFICATIONS:
298+
try:
299+
_methods.validate_client_notification(method, version, params)
300+
except KeyError:
301+
logger.debug("dropped %r: not defined at %s", method, version)
302+
return
303+
except ValidationError:
304+
logger.warning("dropped %r: malformed params", method)
305+
return
295306
if method == "notifications/initialized":
296307
# Surface validation above already rejected a malformed body, so
297308
# commit; fall through so a registered handler observes an

src/mcp/types/_types.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,16 +185,15 @@ class PaginatedResult(Result):
185185
class CacheableResult(Result):
186186
"""Base class for results that carry client-side caching directives (2026-07-28).
187187
188-
Both fields are required on the 2026-07-28 wire and always serialized by
189-
this SDK; older peers ignore the extra keys. The defaults are SDK choices
190-
(the schema declares none).
188+
Both fields are required on the 2026-07-28 wire; the SDK declares no
189+
default, so a handler answering at 2026-07-28 must set them explicitly.
191190
"""
192191

193-
ttl_ms: Annotated[int, Field(ge=0)] = 0
192+
ttl_ms: Annotated[int, Field(ge=0)] | None = None
194193
"""How long (ms) the client MAY cache this response, analogous to HTTP
195194
`Cache-Control: max-age`. 0 means immediately stale."""
196195

197-
cache_scope: Literal["public", "private"] = "private"
196+
cache_scope: Literal["public", "private"] | None = None
198197
"""Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows
199198
shared caches to serve the response to any user; "private" forbids that."""
200199

@@ -203,9 +202,10 @@ class EmptyResult(Result):
203202
"""A result that indicates success but carries no data.
204203
205204
`result_type` defaults to None so this dumps as `{}`: deployed TypeScript
206-
and Rust SDK clients validate empty results strictly and reject extra keys.
207-
The 2026-07-28 schema requires `resultType`, so code answering an empty
208-
result on a 2026-07-28+ session must pass `result_type="complete"`.
205+
and Rust SDK peers (clients and servers) validate empty results strictly
206+
and reject extra keys. The 2026-07-28 schema requires `resultType`, so code
207+
answering an empty result on a 2026-07-28+ session must pass
208+
`result_type="complete"`.
209209
"""
210210

211211
result_type: ResultType | None = None

src/mcp/types/_wire_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55

66
class WireModel(BaseModel):
7-
"""Base for generated wire models: enables ``populate_by_name``; subclasses set ``extra="allow"`` themselves."""
7+
"""Base for generated wire models: enables ``populate_by_name``; subclasses set ``extra`` themselves."""
88

99
model_config = ConfigDict(populate_by_name=True)

src/mcp/types/methods.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
"""Per-version method maps and parse functions for inbound MCP traffic.
1+
"""Per-version method maps and parse/serialize functions for MCP traffic.
22
3-
Surface maps key `(method, version)` to schema-exact types (key absence is the
4-
version gate); monolith maps key `method` to the version-free `mcp.types` models
5-
user code receives. Session-layer wiring is a follow-up."""
3+
This module is supported public API; the `mcp.types.v*` packages it draws on
4+
are internal validators and not for direct import.
5+
6+
Surface maps key `(method, version)` to per-version wire types (key absence is
7+
the version gate; shape validation is per schema era, i.e. 2025-11-25 for every
8+
pre-2026 version and 2026-07-28 for 2026). Monolith maps key `method` to the
9+
version-free `mcp.types` models user code receives."""
610

711
from __future__ import annotations
812

@@ -34,6 +38,7 @@
3438
"parse_server_notification",
3539
"parse_server_request",
3640
"parse_server_result",
41+
"serialize_server_result",
3742
"validate_client_notification",
3843
"validate_client_request",
3944
"validate_server_result",
@@ -459,9 +464,9 @@ def parse_client_request(
459464
"""Validate a client request against `surface`, then parse and return its `monolith` model.
460465
461466
Args:
462-
surface: `(method, version)` to schema-exact type map; the version-gate
463-
lookup and shape check run against this. Pass an extended map to
464-
admit custom methods.
467+
surface: `(method, version)` to wire-type map; the version-gate lookup
468+
and (per-schema-era) shape check run against this. Pass an extended
469+
map to admit custom methods.
465470
monolith: `method` to version-free model map; the returned instance is
466471
parsed from this row. Must cover every method `surface` admits.
467472
@@ -486,9 +491,9 @@ def parse_server_request(
486491
"""Validate a server request against `surface`, then parse and return its `monolith` model.
487492
488493
Args:
489-
surface: `(method, version)` to schema-exact type map; the version-gate
490-
lookup and shape check run against this. Pass an extended map to
491-
admit custom methods.
494+
surface: `(method, version)` to wire-type map; the version-gate lookup
495+
and (per-schema-era) shape check run against this. Pass an extended
496+
map to admit custom methods.
492497
monolith: `method` to version-free model map; the returned instance is
493498
parsed from this row. Must cover every method `surface` admits.
494499
@@ -533,9 +538,9 @@ def parse_client_notification(
533538
"""Validate a client notification against `surface`, then parse and return its `monolith` model.
534539
535540
Args:
536-
surface: `(method, version)` to schema-exact type map; the version-gate
537-
lookup and shape check run against this. Pass an extended map to
538-
admit custom methods.
541+
surface: `(method, version)` to wire-type map; the version-gate lookup
542+
and (per-schema-era) shape check run against this. Pass an extended
543+
map to admit custom methods.
539544
monolith: `method` to version-free model map; the returned instance is
540545
parsed from this row. Must cover every method `surface` admits.
541546
@@ -560,9 +565,9 @@ def parse_server_notification(
560565
"""Validate a server notification against `surface`, then parse and return its `monolith` model.
561566
562567
Args:
563-
surface: `(method, version)` to schema-exact type map; the version-gate
564-
lookup and shape check run against this. Pass an extended map to
565-
admit custom methods.
568+
surface: `(method, version)` to wire-type map; the version-gate lookup
569+
and (per-schema-era) shape check run against this. Pass an extended
570+
map to admit custom methods.
566571
monolith: `method` to version-free model map; the returned instance is
567572
parsed from this row. Must cover every method `surface` admits.
568573
@@ -578,6 +583,30 @@ def parse_server_notification(
578583
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
579584

580585

586+
def serialize_server_result(
587+
method: str,
588+
version: str,
589+
data: Mapping[str, Any],
590+
*,
591+
surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS,
592+
) -> dict[str, Any]:
593+
"""Validate `data` against `surface` and return its surface-shaped dump.
594+
595+
The surface model carries `extra="ignore"`, so fields not in `version`'s
596+
schema are dropped from the returned dict.
597+
598+
Raises:
599+
ValueError: `version` is not a known protocol version.
600+
KeyError: `(method, version)` is not in `surface`.
601+
pydantic.ValidationError: result fails surface validation.
602+
"""
603+
_check_known_version(version)
604+
adapter = _adapter(surface[(method, version)])
605+
return adapter.dump_python(
606+
adapter.validate_python(data, by_name=False), by_alias=True, mode="json", exclude_none=True
607+
)
608+
609+
581610
def validate_server_result(
582611
method: str,
583612
version: str,
@@ -592,8 +621,7 @@ def validate_server_result(
592621
KeyError: `(method, version)` is not in `surface`.
593622
pydantic.ValidationError: result fails surface validation.
594623
"""
595-
_check_known_version(version)
596-
_adapter(surface[(method, version)]).validate_python(data, by_name=False)
624+
serialize_server_result(method, version, data, surface=surface)
597625

598626

599627
def parse_server_result(
@@ -607,9 +635,9 @@ def parse_server_result(
607635
"""Validate a server result against `surface`, then parse and return its `monolith` model.
608636
609637
Args:
610-
surface: `(method, version)` to schema-exact type map; the version-gate
611-
lookup and shape check run against this. Pass an extended map to
612-
admit custom methods.
638+
surface: `(method, version)` to wire-type map; the version-gate lookup
639+
and (per-schema-era) shape check run against this. Pass an extended
640+
map to admit custom methods.
613641
monolith: `method` to version-free model map; the returned instance is
614642
parsed from this row. Must cover every method `surface` admits.
615643
@@ -635,9 +663,9 @@ def parse_client_result(
635663
"""Validate a client result against `surface`, then parse and return its `monolith` model.
636664
637665
Args:
638-
surface: `(method, version)` to schema-exact type map; the version-gate
639-
lookup and shape check run against this. Pass an extended map to
640-
admit custom methods.
666+
surface: `(method, version)` to wire-type map; the version-gate lookup
667+
and (per-schema-era) shape check run against this. Pass an extended
668+
map to admit custom methods.
641669
monolith: `method` to version-free model map; the returned instance is
642670
parsed from this row. Must cover every method `surface` admits.
643671

0 commit comments

Comments
 (0)