Skip to content

Commit 73f1572

Browse files
committed
Wire per-version validation into ServerRunner
- Add validate_client_request/notification, validate_server_result to types.methods (surface-only siblings of the parse_* functions) - ServerRunner validates inbound requests/notifications against the negotiated version's surface schema; custom methods fall through to the registered params_type as before - Handler results are validated against the surface schema after dump; a spec-invalid result is logged and returns INTERNAL_ERROR to the client - Map JSON-Schema format byte/uri/uri-template to plain str in codegen (Base64Str and AnyUrl over-assert on annotation-only formats); regenerate both surface packages - Fix two test fixtures that built Tool(input_schema={}) without the spec-required type field - Document handler-result validation in migration.md
1 parent 0c6211e commit 73f1572

10 files changed

Lines changed: 242 additions & 73 deletions

File tree

docs/migration.md

Lines changed: 4 additions & 0 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
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.
227227

228+
### Server handler results are validated against the protocol schema
229+
230+
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.
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.

scripts/gen_surface_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ def run_codegen(schema_path: Path, output_path: Path) -> None:
110110
"--enum-field-as-literal", "all",
111111
"--use-union-operator", "--use-double-quotes",
112112
"--extra-fields", "allow",
113+
# JSON Schema `format` is annotation-only; codegen's defaults
114+
# (Base64Str, AnyUrl) over-assert and reject valid wire data.
115+
"--type-mappings", "byte=string", "uri=string", "uri-template=string",
113116
"--disable-timestamp",
114117
],
115118
capture_output=True, text=True,

src/mcp/server/runner.py

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from collections.abc import Mapping
2020
from dataclasses import dataclass, field
2121
from functools import partial, reduce
22-
from typing import TYPE_CHECKING, Any, Generic, cast, get_args
22+
from typing import TYPE_CHECKING, Any, Generic, cast
2323

2424
import anyio.abc
2525
from opentelemetry.trace import SpanKind, StatusCode
@@ -38,19 +38,18 @@
3838
from mcp.shared.transport_context import TransportContext
3939
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
4040
from mcp.types import (
41+
INTERNAL_ERROR,
4142
INVALID_PARAMS,
4243
LATEST_PROTOCOL_VERSION,
4344
METHOD_NOT_FOUND,
44-
ClientRequest,
4545
ErrorData,
4646
Implementation,
4747
InitializeRequestParams,
4848
InitializeResult,
49-
NotificationParams,
5049
RequestParams,
5150
RequestParamsMeta,
52-
client_request_adapter,
5351
)
52+
from mcp.types import methods as _methods
5453

5554
if TYPE_CHECKING:
5655
from mcp.server.lowlevel.server import Server
@@ -80,14 +79,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
8079
return None
8180

8281

83-
_SPEC_CLIENT_METHODS: frozenset[str] = frozenset(
84-
cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(ClientRequest)
85-
)
86-
"""Method names in the spec `ClientRequest` union, derived from the
87-
discriminator literal on each arm. Used to gate upfront validation so custom
88-
methods registered via `add_request_handler` are not rejected."""
89-
90-
9182
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
9283
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
9384
@@ -228,16 +219,19 @@ async def _on_request(
228219
params: Mapping[str, Any] | None,
229220
) -> dict[str, Any]:
230221
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
231225

232226
async def _inner() -> HandlerResult:
233-
# TODO(maxisbey): pinned compat: spec methods are validated against
234-
# the ClientRequest union before lookup, so malformed params are
235-
# INVALID_PARAMS even with no handler registered.
236-
if method in _SPEC_CLIENT_METHODS:
237-
payload: dict[str, Any] = {"method": method}
238-
if params is not None:
239-
payload["params"] = dict(params)
240-
client_request_adapter.validate_python(payload, by_name=False)
227+
# Pinned compat: spec methods are surface-validated before lookup,
228+
# so malformed params are INVALID_PARAMS even with no handler
229+
# registered. Custom methods miss the surface map and fall through
230+
# 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
241235
# TODO(maxisbey): the 2026-07-28 spec drops the handshake; this branch and
242236
# the gate become a per-version legacy path then. Initialize runs inline
243237
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
@@ -265,6 +259,15 @@ async def _inner() -> HandlerResult:
265259

266260
call = self._compose_server_middleware(ctx, method, params, _inner)
267261
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
268271
if method == "initialize":
269272
# Commit only on chain success, so a middleware veto leaves no state.
270273
# Race-free: the read loop is parked until this call returns.
@@ -278,18 +281,21 @@ async def _on_notify(
278281
params: Mapping[str, Any] | None,
279282
) -> None:
280283
ctx = self._make_context(dctx, _extract_meta(params))
284+
# Same fallback as `_on_request`: covers pre-handshake and stateless.
285+
version = self.connection.protocol_version or LATEST_PROTOCOL_VERSION
281286

282287
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
283295
if method == "notifications/initialized":
284-
# Validate before committing so a malformed notification leaves
285-
# state untouched; then fall through so a registered handler
286-
# observes an initialized connection.
287-
if params is not None:
288-
try:
289-
NotificationParams.model_validate(params, by_name=False)
290-
except ValidationError:
291-
logger.warning("dropped %r: malformed params", method)
292-
return
296+
# Surface validation above already rejected a malformed body, so
297+
# commit; fall through so a registered handler observes an
298+
# initialized connection.
293299
self.connection.initialized.set()
294300
elif not self.connection.initialize_accepted:
295301
logger.debug("dropped %s: received before initialization", method)

src/mcp/types/methods.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"parse_server_notification",
3535
"parse_server_request",
3636
"parse_server_result",
37+
"validate_client_notification",
38+
"validate_client_request",
39+
"validate_server_result",
3740
]
3841

3942

@@ -427,6 +430,24 @@ def _monolith_row(monolith: Mapping[str, _MonolithT], method: str) -> _MonolithT
427430
raise RuntimeError(f"inconsistent extension maps: surface defines {method!r} but monolith does not") from None
428431

429432

433+
def validate_client_request(
434+
method: str,
435+
version: str,
436+
params: Mapping[str, Any] | None,
437+
*,
438+
surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_REQUESTS,
439+
) -> None:
440+
"""Validate a client request against `surface` only.
441+
442+
Raises:
443+
ValueError: `version` is not a known protocol version.
444+
KeyError: `(method, version)` is not in `surface` (the version gate).
445+
pydantic.ValidationError: body fails surface validation.
446+
"""
447+
_check_known_version(version)
448+
surface[(method, version)].model_validate({**_REQUEST_STUB, **_body(method, params)}, by_name=False)
449+
450+
430451
def parse_client_request(
431452
method: str,
432453
version: str,
@@ -450,9 +471,7 @@ def parse_client_request(
450471
pydantic.ValidationError: body fails surface or monolith validation.
451472
RuntimeError: surface matched but `method` has no monolith row.
452473
"""
453-
_check_known_version(version)
454-
surface_type = surface[(method, version)]
455-
surface_type.model_validate({**_REQUEST_STUB, **_body(method, params)}, by_name=False)
474+
validate_client_request(method, version, params, surface=surface)
456475
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
457476

458477

@@ -485,6 +504,24 @@ def parse_server_request(
485504
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
486505

487506

507+
def validate_client_notification(
508+
method: str,
509+
version: str,
510+
params: Mapping[str, Any] | None,
511+
*,
512+
surface: Mapping[tuple[str, str], type[BaseModel]] = CLIENT_NOTIFICATIONS,
513+
) -> None:
514+
"""Validate a client notification against `surface` only.
515+
516+
Raises:
517+
ValueError: `version` is not a known protocol version.
518+
KeyError: `(method, version)` is not in `surface`.
519+
pydantic.ValidationError: body fails surface validation.
520+
"""
521+
_check_known_version(version)
522+
surface[(method, version)].model_validate({**_NOTIFICATION_STUB, **_body(method, params)}, by_name=False)
523+
524+
488525
def parse_client_notification(
489526
method: str,
490527
version: str,
@@ -508,9 +545,7 @@ def parse_client_notification(
508545
pydantic.ValidationError: body fails surface or monolith validation.
509546
RuntimeError: surface matched but `method` has no monolith row.
510547
"""
511-
_check_known_version(version)
512-
surface_type = surface[(method, version)]
513-
surface_type.model_validate({**_NOTIFICATION_STUB, **_body(method, params)}, by_name=False)
548+
validate_client_notification(method, version, params, surface=surface)
514549
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
515550

516551

@@ -543,6 +578,24 @@ def parse_server_notification(
543578
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
544579

545580

581+
def validate_server_result(
582+
method: str,
583+
version: str,
584+
data: Mapping[str, Any],
585+
*,
586+
surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS,
587+
) -> None:
588+
"""Validate a server result against `surface` only.
589+
590+
Raises:
591+
ValueError: `version` is not a known protocol version.
592+
KeyError: `(method, version)` is not in `surface`.
593+
pydantic.ValidationError: result fails surface validation.
594+
"""
595+
_check_known_version(version)
596+
_adapter(surface[(method, version)]).validate_python(data, by_name=False)
597+
598+
546599
def parse_server_result(
547600
method: str,
548601
version: str,
@@ -566,8 +619,7 @@ def parse_server_result(
566619
pydantic.ValidationError: result fails surface or monolith validation.
567620
RuntimeError: surface matched but `method` has no monolith row.
568621
"""
569-
_check_known_version(version)
570-
_adapter(surface[(method, version)]).validate_python(data, by_name=False)
622+
validate_server_result(method, version, data, surface=surface)
571623
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
572624
return result
573625

0 commit comments

Comments
 (0)