Protocol types for 2026-07-28: superset monolith, committed per-version packages, and wire-method maps (#2849)
This commit is contained in:
parent
2693fb95ae
commit
65be5a7147
4 changed files with 19076 additions and 371 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue