Protocol types for 2026-07-28: superset monolith, committed per-version packages, and wire-method maps (#2849)

This commit is contained in:
Max 2026-06-16 17:40:14 +01:00 • committed by GitHub
commit 65be5a7147
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 19076 additions and 371 deletions

View file

@ -38,6 +38,7 @@ from mcp.types import (
PingRequest,
Request,
)
from mcp.types import methods as _methods
__all__ = ["Connection"]
@ -175,6 +176,13 @@ class Connection:
KeyError: `result_type` omitted for a non-spec request type.
"""
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
# Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25"
if req.method in _methods.MONOLITH_REQUESTS:
try:
_methods.validate_client_result(req.method, version, raw)
except KeyError:
pass
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
return cls.model_validate(raw, by_name=False)

View file

@ -2,15 +2,18 @@
from __future__ import annotations
import types
from collections.abc import Sequence
from typing import Generic, Literal, TypeVar, Union, get_args, get_origin
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
from mcp.server.session import ServerSession
from mcp.types import RequestId
# Internal surface package; imported as the gate's source of truth for spec-valid property schemas.
from mcp.types.v2025_11_25 import PrimitiveSchemaDefinition
ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel)
@ -45,61 +48,38 @@ class AcceptedUrlElicitation(BaseModel):
UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation
# Primitive types allowed in elicitation schemas
_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool)
class _ElicitationJsonSchema(GenerateJsonSchema):
"""JSON-Schema generator that flattens `T | None` to `T` and drops `None` defaults.
The spec's `PrimitiveSchemaDefinition` admits no `anyOf` or null type; an
optional field is expressed by leaving it out of `required`, which pydantic
already does for any field with a default.
"""
def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue:
return self.generate_inner(schema["schema"])
def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue:
result = super().default_schema(schema)
if result.get("default") is None:
result.pop("default", None)
return result
def _validate_elicitation_schema(schema: type[BaseModel]) -> None:
"""Validate that a Pydantic model only contains primitive field types."""
for field_name, field_info in schema.model_fields.items():
annotation = field_info.annotation
def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
"""Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept.
if annotation is None or annotation is types.NoneType: # pragma: no cover
continue
elif _is_primitive_field(annotation):
continue
elif _is_string_sequence(annotation):
continue
else:
raise TypeError(
f"Elicitation schema field '{field_name}' must be a primitive type "
f"{_ELICITATION_PRIMITIVE_TYPES}, a sequence of strings (list[str], etc.), "
f"or Optional of these types. Nested models and complex types are not allowed."
)
def _is_string_sequence(annotation: type) -> bool:
"""Check if annotation is a sequence of strings (list[str], Sequence[str], etc)."""
origin = get_origin(annotation)
# Check if it's a sequence-like type with str elements
if origin:
Catches whatever the renderer let through that isn't spec-valid: bare
`list[str]` (no enum), multi-primitive unions, nested models.
"""
for field_name, prop in json_schema.get("properties", {}).items():
try:
if issubclass(origin, Sequence):
args = get_args(annotation)
# Should have single str type arg
return len(args) == 1 and args[0] is str
except TypeError: # pragma: no cover
# origin is not a class, so it can't be a subclass of Sequence
pass
return False
def _is_primitive_field(annotation: type) -> bool:
"""Check if a field is a primitive type allowed in elicitation schemas."""
# Handle basic primitive types
if annotation in _ELICITATION_PRIMITIVE_TYPES:
return True
# Handle Union types
origin = get_origin(annotation)
if origin is Union or origin is types.UnionType:
args = get_args(annotation)
# All args must be primitive types, None, or string sequences
return all(
arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES or _is_string_sequence(arg) for arg in args
)
return False
PrimitiveSchemaDefinition.model_validate(prop)
except ValidationError:
raise TypeError(
f"Elicitation schema field {field_name!r} rendered as {prop!r}, "
f"which is not a valid PrimitiveSchemaDefinition"
) from None
async def elicit_with_validation(
@ -118,10 +98,8 @@ async def elicit_with_validation(
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
"""
# Validate that schema only contains primitive types and fail loudly if not
_validate_elicitation_schema(schema)
json_schema = schema.model_json_schema()
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
_validate_rendered_properties(json_schema)
result = await session.elicit_form(
message=message,

View file

@ -19,7 +19,7 @@ import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import partial, reduce
from typing import TYPE_CHECKING, Any, Generic, cast, get_args
from typing import TYPE_CHECKING, Any, Generic, cast
import anyio.abc
from opentelemetry.trace import SpanKind, StatusCode
@ -38,19 +38,18 @@ from mcp.shared.message import ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
ClientRequest,
ErrorData,
Implementation,
InitializeRequestParams,
InitializeResult,
NotificationParams,
RequestParams,
RequestParamsMeta,
client_request_adapter,
)
from mcp.types import methods as _methods
if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server
@ -80,14 +79,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
return None
_SPEC_CLIENT_METHODS: frozenset[str] = frozenset(
cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(ClientRequest)
)
"""Method names in the spec `ClientRequest` union, derived from the
discriminator literal on each arm. Used to gate upfront validation so custom
methods registered via `add_request_handler` are not rejected."""
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
@ -228,16 +219,22 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None,
) -> dict[str, Any]:
ctx = self._make_context(dctx, _extract_meta(params))
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
# handshake (which only exists at <=2025) and stateless until the header
# is plumbed; its meaning is fixed regardless of LATEST bumps.
version = self.connection.protocol_version or "2025-11-25"
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
async def _inner() -> HandlerResult:
# TODO(maxisbey): pinned compat: spec methods are validated against
# the ClientRequest union before lookup, so malformed params are
# INVALID_PARAMS even with no handler registered.
if method in _SPEC_CLIENT_METHODS:
payload: dict[str, Any] = {"method": method}
if params is not None:
payload["params"] = dict(params)
client_request_adapter.validate_python(payload, by_name=False)
# Pinned compat: spec methods are surface-validated before lookup,
# so malformed params are INVALID_PARAMS even with no handler
# registered. Custom methods miss the monolith map and fall through
# to `entry.params_type` exactly as before.
if is_spec_method:
try:
_methods.validate_client_request(method, version, params)
except KeyError:
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
# TODO(maxisbey): the 2026-07-28 spec drops the handshake; this branch and
# the gate become a per-version legacy path then. Initialize runs inline
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
@ -265,6 +262,21 @@ class ServerRunner(Generic[LifespanT]):
call = self._compose_server_middleware(ctx, method, params, _inner)
result = _dump_result(await call())
# TODO: reject resultType values outside {"complete", "input_required"} unless the
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
if is_spec_method:
try:
result = _methods.serialize_server_result(method, version, result)
except KeyError:
# Middleware short-circuited a wrong-version spec method without
# calling `call_next`; it owns the result shape.
pass
except ValidationError:
# Server bug, not client fault. Detail stays in the server log:
# pydantic messages echo the result body.
logger.exception("handler for %r returned an invalid result", method)
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state.
# Race-free: the read loop is parked until this call returns.
@ -278,18 +290,23 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None,
) -> None:
ctx = self._make_context(dctx, _extract_meta(params))
# Same fallback as `_on_request`: covers pre-handshake and stateless.
version = self.connection.protocol_version or "2025-11-25"
async def _inner() -> None:
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
try:
_methods.validate_client_notification(method, version, params)
except KeyError:
logger.debug("dropped %r: not defined at %s", method, version)
return
except ValidationError:
logger.warning("dropped %r: malformed params", method)
return
if method == "notifications/initialized":
# Validate before committing so a malformed notification leaves
# state untouched; then fall through so a registered handler
# observes an initialized connection.
if params is not None:
try:
NotificationParams.model_validate(params, by_name=False)
except ValidationError:
logger.warning("dropped %r: malformed params", method)
return
# Surface validation above already rejected a malformed body, so
# commit; fall through so a registered handler observes an
# initialized connection.
self.connection.initialized.set()
elif not self.connection.initialize_accepted:
logger.debug("dropped %s: received before initialization", method)

View file

@ -20,6 +20,7 @@ from mcp.shared.dispatcher import CallOptions, ProgressFnT
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods
__all__ = ["ServerSession"]
@ -96,6 +97,12 @@ class ServerSession:
result = await self._dispatcher.send_raw_request(
data["method"], data.get("params"), opts or None, _related_request_id=related
)
# Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25"
try:
_methods.validate_client_result(request.method, version, result)
except KeyError:
pass
return result_type.model_validate(result, by_name=False)
async def send_notification(