Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
This commit is contained in:
parent
603342f0fe
commit
587340279e
6 changed files with 1163 additions and 150 deletions
|
|
@ -38,7 +38,9 @@ from mcp.shared.inbound import (
|
|||
MCP_METHOD_HEADER,
|
||||
MCP_NAME_HEADER,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
NAME_BEARING_METHODS,
|
||||
encode_header_value,
|
||||
find_invalid_x_mcp_header,
|
||||
)
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||
|
|
@ -78,8 +80,8 @@ def _make_modern_stamp(
|
|||
headers = opts.setdefault("headers", {})
|
||||
headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
|
||||
headers[MCP_METHOD_HEADER] = data["method"]
|
||||
# TODO: also emit Mcp-Name for prompts/get (params.name) and resources/read (params.uri)
|
||||
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
|
||||
name_key = NAME_BEARING_METHODS.get(data["method"])
|
||||
if name_key is not None and isinstance(name := params.get(name_key), str):
|
||||
headers[MCP_NAME_HEADER] = encode_header_value(name)
|
||||
|
||||
return stamp
|
||||
|
|
@ -429,7 +431,7 @@ class ClientSession:
|
|||
opts: CallOptions = {
|
||||
"timeout": DISCOVER_TIMEOUT_SECONDS,
|
||||
"cancel_on_abandon": False,
|
||||
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
|
||||
"headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]},
|
||||
}
|
||||
return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
|
||||
|
||||
|
|
@ -759,6 +761,16 @@ class ClientSession:
|
|||
types.ListToolsResult,
|
||||
)
|
||||
|
||||
if self._negotiated_version in MODERN_PROTOCOL_VERSIONS:
|
||||
# 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid.
|
||||
kept: list[types.Tool] = []
|
||||
for tool in result.tools:
|
||||
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
|
||||
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
|
||||
continue
|
||||
kept.append(tool)
|
||||
result.tools = kept
|
||||
|
||||
# Cache tool output schemas for future validation
|
||||
# Note: don't clear the cache, as we may be using a cursor
|
||||
for tool in result.tools:
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ from mcp.server.runner import serve_one
|
|||
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request
|
||||
from mcp.shared.inbound import (
|
||||
ERROR_CODE_HTTP_STATUS,
|
||||
InboundLadderRejection,
|
||||
classify_inbound_request,
|
||||
)
|
||||
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any, Generic
|
||||
|
||||
from mcp_types import LoggingLevel
|
||||
from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
|
||||
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
|
||||
_mcp_server: MCPServer | None
|
||||
_input_params: InputResponseRequestParams | None
|
||||
|
||||
# TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely.
|
||||
def __init__(
|
||||
|
|
@ -65,12 +66,14 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
*,
|
||||
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
|
||||
mcp_server: MCPServer | None = None,
|
||||
input_params: InputResponseRequestParams | None = None,
|
||||
# TODO(Marcelo): We should drop this kwargs parameter.
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._request_context = request_context
|
||||
self._mcp_server = mcp_server
|
||||
self._input_params = input_params
|
||||
|
||||
@property
|
||||
def mcp_server(self) -> MCPServer:
|
||||
|
|
@ -219,6 +222,33 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
"""Get the unique ID for this request."""
|
||||
return str(self.request_context.request_id)
|
||||
|
||||
@property
|
||||
def input_responses(self) -> InputResponses | None:
|
||||
"""Client responses to a prior `InputRequiredResult.input_requests`.
|
||||
|
||||
`None` on the initial round, or when the client retried without
|
||||
responses.
|
||||
"""
|
||||
return self._input_params.input_responses if self._input_params else None
|
||||
|
||||
@property
|
||||
def request_state(self) -> str | None:
|
||||
"""Opaque state echoed from a prior `InputRequiredResult.request_state`.
|
||||
|
||||
`None` on the initial round.
|
||||
"""
|
||||
return self._input_params.request_state if self._input_params else None
|
||||
|
||||
@property
|
||||
def client_capabilities(self) -> ClientCapabilities | None:
|
||||
"""The client's declared capabilities for this connection.
|
||||
|
||||
`None` when the client supplied no client info (e.g. an anonymous
|
||||
stateless request without the reserved `_meta` keys).
|
||||
"""
|
||||
client_params = self.request_context.session.client_params
|
||||
return client_params.capabilities if client_params else None
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Access to the underlying session for advanced usage."""
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from mcp_types import (
|
|||
GetPromptRequestParams,
|
||||
GetPromptResult,
|
||||
Icon,
|
||||
InputRequiredResult,
|
||||
ListPromptsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
|
|
@ -306,8 +307,8 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
async def _handle_call_tool(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
|
||||
) -> CallToolResult:
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
) -> CallToolResult | InputRequiredResult:
|
||||
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
||||
try:
|
||||
return await self.call_tool(params.name, params.arguments or {}, context)
|
||||
except MCPError:
|
||||
|
|
@ -323,7 +324,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
async def _handle_read_resource(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
||||
) -> ReadResourceResult:
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
||||
try:
|
||||
results = await self.read_resource(params.uri, context)
|
||||
except ResourceNotFoundError as err:
|
||||
|
|
@ -365,7 +366,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
async def _handle_get_prompt(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
||||
) -> GetPromptResult:
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
||||
return await self.get_prompt(params.name, params.arguments, context)
|
||||
|
||||
async def list_tools(self) -> list[MCPTool]:
|
||||
|
|
@ -387,7 +388,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
async def call_tool(
|
||||
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
|
||||
) -> CallToolResult:
|
||||
) -> CallToolResult | InputRequiredResult:
|
||||
"""Call a tool by name with arguments."""
|
||||
if context is None:
|
||||
context = Context(mcp_server=self)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import json
|
|||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from itertools import chain
|
||||
from types import GenericAlias
|
||||
from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
|
||||
from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints
|
||||
|
||||
import anyio
|
||||
import anyio.to_thread
|
||||
import pydantic_core
|
||||
from mcp_types import CallToolResult, ContentBlock, TextContent
|
||||
from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent
|
||||
from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
|
||||
|
|
@ -29,6 +29,10 @@ from mcp.server.mcpserver.utilities.types import Audio, Image
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _is_input_required_type(obj: Any) -> bool:
|
||||
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
|
||||
|
||||
|
||||
class StrictJsonSchema(GenerateJsonSchema):
|
||||
"""A JSON schema generator that raises exceptions instead of emitting warnings.
|
||||
|
||||
|
|
@ -88,9 +92,13 @@ class FuncMetadata(BaseModel):
|
|||
else:
|
||||
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
|
||||
|
||||
def convert_result(self, result: Any) -> CallToolResult:
|
||||
def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
|
||||
"""Convert a function call result into a `CallToolResult`.
|
||||
|
||||
An `InputRequiredResult` is passed through unchanged so the multi-round
|
||||
flow surfaces on the wire as `resultType: "input_required"` rather than
|
||||
being JSON-dumped into a text block.
|
||||
|
||||
Note: we build unstructured content here **even though the lowlevel server
|
||||
tool call handler provides generic backwards compatibility serialization of
|
||||
structured content**. This is for MCPServer backwards compatibility: we need to
|
||||
|
|
@ -98,6 +106,8 @@ class FuncMetadata(BaseModel):
|
|||
from function return values, whereas the lowlevel server simply serializes
|
||||
the structured output.
|
||||
"""
|
||||
if isinstance(result, InputRequiredResult):
|
||||
return result
|
||||
if isinstance(result, CallToolResult):
|
||||
if self.output_schema is not None:
|
||||
assert self.output_model is not None, "Output model must be set if output schema is defined"
|
||||
|
|
@ -266,10 +276,33 @@ def func_metadata(
|
|||
# unknown (i.e. a bare `Final`).
|
||||
assert return_type_expr is not UNKNOWN
|
||||
|
||||
if _is_input_required_type(return_type_expr):
|
||||
# A tool annotated to return only InputRequiredResult never produces structured content.
|
||||
return FuncMetadata(arg_model=arguments_model)
|
||||
|
||||
# The annotation fed to schema derivation. Starts as the raw return annotation (preserving any
|
||||
# Annotated[...] wrapper) and is narrowed below if InputRequiredResult arms are stripped.
|
||||
effective_annotation: Any = sig.return_annotation
|
||||
|
||||
if is_union_origin(get_origin(return_type_expr)):
|
||||
args = get_args(return_type_expr)
|
||||
# Check if CallToolResult appears in the union (excluding None for Optional check)
|
||||
if any(isinstance(arg, type) and issubclass(arg, CallToolResult) for arg in args if arg is not type(None)):
|
||||
# InputRequiredResult is a control-flow signal, not data: strip it so the residual arms
|
||||
# drive schema derivation. convert_result short-circuits on an InputRequiredResult instance
|
||||
# before output validation, so the schema only ever sees the data arms at runtime.
|
||||
residual = tuple(a for a in args if not _is_input_required_type(a))
|
||||
if not residual:
|
||||
return FuncMetadata(arg_model=arguments_model)
|
||||
if len(residual) != len(args):
|
||||
# PEP 604 has no syntax for "union of a runtime tuple"; Union[...] is the only spelling.
|
||||
effective_annotation = residual[0] if len(residual) == 1 else Union[residual] # noqa: UP007
|
||||
# Re-normalize so the residual is processed exactly as if it had been the declared
|
||||
# return annotation: unwraps a top-level Annotated[...] arm and re-derives metadata,
|
||||
# so the CallToolResult/BaseModel/TypedDict dispatch below sees the bare type.
|
||||
inspected_return_ann = inspect_annotation(effective_annotation, annotation_source=AnnotationSource.FUNCTION)
|
||||
return_type_expr = inspected_return_ann.type
|
||||
if len(residual) > 1 and any(
|
||||
isinstance(a, type) and issubclass(a, CallToolResult) for a in residual if a is not type(None)
|
||||
):
|
||||
raise InvalidSignature(
|
||||
f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. "
|
||||
"To return empty results, use: CallToolResult(content=[])"
|
||||
|
|
@ -295,7 +328,7 @@ def func_metadata(
|
|||
else:
|
||||
return FuncMetadata(arg_model=arguments_model)
|
||||
else:
|
||||
original_annotation = sig.return_annotation
|
||||
original_annotation = effective_annotation
|
||||
|
||||
output_model, output_schema, wrap_output = _try_create_model_and_schema(
|
||||
original_annotation, return_type_expr, func.__name__
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
"""Inbound request classification for the modern per-request-envelope path.
|
||||
|
||||
Pure module: no I/O, no transport, no ``mcp.server`` imports. Runs the
|
||||
Pure module: no I/O, no transport, no `mcp.server` imports. Runs the
|
||||
validation ladder against a decoded JSON-RPC body and returns either an
|
||||
:class:`InboundModernRoute` (every rung passed) or an
|
||||
:class:`InboundLadderRejection` (the first rung that failed). Callers map a
|
||||
rejection's ``code`` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP
|
||||
rejection's `code` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP
|
||||
status.
|
||||
|
||||
Also hosts the shared header-value codec and the `x-mcp-header` schema
|
||||
validator so client emit and server validate read the same source of truth.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from mcp_types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
|
|
@ -39,8 +43,12 @@ __all__ = [
|
|||
"MCP_METHOD_HEADER",
|
||||
"MCP_NAME_HEADER",
|
||||
"MCP_PROTOCOL_VERSION_HEADER",
|
||||
"NAME_BEARING_METHODS",
|
||||
"X_MCP_HEADER_KEY",
|
||||
"classify_inbound_request",
|
||||
"decode_header_value",
|
||||
"encode_header_value",
|
||||
"find_invalid_x_mcp_header",
|
||||
]
|
||||
|
||||
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
|
||||
|
|
@ -52,17 +60,152 @@ MCP_METHOD_HEADER: Final = "mcp-method"
|
|||
MCP_NAME_HEADER: Final = "mcp-name"
|
||||
"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI)."""
|
||||
|
||||
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
|
||||
X_MCP_HEADER_KEY: Final = "x-mcp-header"
|
||||
"""JSON-Schema property annotation that designates an `Mcp-Param-*` HTTP header."""
|
||||
|
||||
NAME_BEARING_METHODS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"tools/call": "name",
|
||||
"prompts/get": "name",
|
||||
"resources/read": "uri",
|
||||
}
|
||||
)
|
||||
"""Method → params key whose value is mirrored as the `Mcp-Name` HTTP header.
|
||||
|
||||
Shared by client emit (which header to send) and server validate (which body
|
||||
field to compare against), so both ends agree on the field by construction.
|
||||
"""
|
||||
|
||||
_B64_SENTINEL = re.compile(r"^=\?base64\?(?P<payload>.*)\?=$")
|
||||
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
|
||||
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
|
||||
# RFC 9110 §5.6.2 token: the only characters permitted in an HTTP field name.
|
||||
_RFC9110_TOKEN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
||||
# JSON-Schema types the spec permits to carry `x-mcp-header` (transports.mdx
|
||||
# §Custom Headers). `number` is explicitly forbidden — float→str is not
|
||||
# portable across implementations.
|
||||
_X_MCP_HEADER_PRIMITIVE_TYPES: Final = frozenset({"string", "integer", "boolean"})
|
||||
|
||||
# JSON Schema 2020-12 applicator keywords whose values are themselves schema
|
||||
# positions, grouped by value shape. `properties` is handled separately as the
|
||||
# only keyword that preserves the statically-reachable chain; every keyword
|
||||
# here drops the chain to None. Instance-data keywords (`default`, `examples`,
|
||||
# `const`, `enum`) and `$ref`/`$dynamicRef` are deliberately absent so the
|
||||
# walk never mistakes data for an annotation and never dereferences.
|
||||
_SUBSCHEMA_SINGLE: Final = frozenset(
|
||||
{
|
||||
"items",
|
||||
"contains",
|
||||
"unevaluatedItems",
|
||||
"additionalProperties",
|
||||
"propertyNames",
|
||||
"unevaluatedProperties",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"contentSchema",
|
||||
}
|
||||
)
|
||||
_SUBSCHEMA_LIST: Final = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"})
|
||||
_SUBSCHEMA_MAP: Final = frozenset({"patternProperties", "dependentSchemas", "$defs", "definitions"})
|
||||
|
||||
|
||||
def _walk_schema_positions(root: Any) -> Iterator[tuple[tuple[str, ...] | None, dict[str, Any]]]:
|
||||
"""Yield `(properties_path, schema)` for every schema position in `root`.
|
||||
|
||||
`properties_path` is the chain of `properties` keys from the root to the
|
||||
position, or `None` once any other applicator keyword has been crossed.
|
||||
The root itself yields `()`. Only the JSON Schema 2020-12 applicators
|
||||
listed above are entered; instance-data keywords are not, and `$ref` is
|
||||
not dereferenced, so the walk terminates on any finite JSON value. An
|
||||
explicit stack keeps the function total even on pathologically deep input.
|
||||
"""
|
||||
stack: list[tuple[tuple[str, ...] | None, Any]] = [((), root)]
|
||||
while stack:
|
||||
path, node = stack.pop()
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
schema = cast(dict[str, Any], node)
|
||||
yield path, schema
|
||||
for kw, val in schema.items():
|
||||
if kw == "properties" and isinstance(val, dict):
|
||||
for name, sub in cast(dict[str, Any], val).items():
|
||||
stack.append(((*path, name) if path is not None else None, sub))
|
||||
elif kw in _SUBSCHEMA_SINGLE:
|
||||
stack.append((None, val))
|
||||
elif kw in _SUBSCHEMA_LIST and isinstance(val, list):
|
||||
stack.extend((None, sub) for sub in cast(list[Any], val))
|
||||
elif kw in _SUBSCHEMA_MAP and isinstance(val, dict):
|
||||
stack.extend((None, sub) for sub in cast(dict[str, Any], val).values())
|
||||
|
||||
|
||||
def encode_header_value(value: str) -> str:
|
||||
"""Wrap `value` in the `=?base64?...?=` sentinel when it would not survive an HTTP field round-trip.
|
||||
|
||||
Plain printable ASCII without leading/trailing whitespace passes verbatim;
|
||||
anything else (control chars, non-ASCII, edge whitespace, or a value that
|
||||
already looks like the sentinel) is base64-wrapped so the receiver can
|
||||
recover the exact bytes.
|
||||
"""
|
||||
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
|
||||
return value
|
||||
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
|
||||
|
||||
|
||||
def decode_header_value(value: str | None) -> str | None:
|
||||
"""Inverse of :func:`encode_header_value`.
|
||||
|
||||
Returns the value verbatim unless it carries the `=?base64?...?=` sentinel,
|
||||
in which case the payload is decoded as UTF-8. A malformed sentinel (bad
|
||||
base64 or bad UTF-8) yields `None` so a corrupt header never matches a body
|
||||
value by accident. `None` in → `None` out so callers can pass
|
||||
`headers.get(...)` directly.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
m = _B64_SENTINEL.fullmatch(value)
|
||||
if m is None:
|
||||
return value
|
||||
try:
|
||||
return base64.b64decode(m.group("payload"), validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def find_invalid_x_mcp_header(input_schema: Any) -> str | None:
|
||||
"""Return a reason string if any `x-mcp-header` annotation in `input_schema` is invalid; else `None`.
|
||||
|
||||
Walks every JSON Schema 2020-12 schema position. An annotation is valid
|
||||
only when it sits on a property statically reachable from the root via a
|
||||
chain of pure `properties` keys, names a non-empty RFC 9110 token, is on
|
||||
an integer/string/boolean property, and is case-insensitively unique
|
||||
across the whole schema. A `None` / non-mapping schema has no schema
|
||||
positions and returns `None`.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
for path, schema in _walk_schema_positions(input_schema):
|
||||
if X_MCP_HEADER_KEY not in schema:
|
||||
continue
|
||||
if not path: # None (off the pure-properties chain) or () (the root itself)
|
||||
return f"{X_MCP_HEADER_KEY} found at a schema position not reachable via a pure `properties` chain"
|
||||
where = ".".join(path)
|
||||
header = schema[X_MCP_HEADER_KEY]
|
||||
if not isinstance(header, str) or not _RFC9110_TOKEN.fullmatch(header):
|
||||
return f"property {where!r}: {X_MCP_HEADER_KEY} {header!r} is not an RFC 9110 token"
|
||||
prop_type = schema.get("type")
|
||||
if not isinstance(prop_type, str) or prop_type not in _X_MCP_HEADER_PRIMITIVE_TYPES:
|
||||
return (
|
||||
f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on "
|
||||
f"integer/string/boolean properties (got {prop_type!r})"
|
||||
)
|
||||
lower = header.lower()
|
||||
if lower in seen:
|
||||
return f"{X_MCP_HEADER_KEY} {header!r} on property {where!r} duplicates property {seen[lower]!r}"
|
||||
seen[lower] = where
|
||||
return None
|
||||
|
||||
|
||||
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
|
||||
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
|
||||
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
|
||||
|
|
@ -76,7 +219,7 @@ ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
|
|||
METHOD_NOT_FOUND: 404,
|
||||
}
|
||||
)
|
||||
"""HTTP status to send for a JSON-RPC ``error.code``.
|
||||
"""HTTP status to send for a JSON-RPC `error.code`.
|
||||
|
||||
Consulted for classifier-origin *and* handler-origin errors, so one table
|
||||
decides the wire status regardless of where the error was produced. Unmapped
|
||||
|
|
@ -88,7 +231,7 @@ codes fall back to the caller's default (typically 200).
|
|||
class InboundModernRoute:
|
||||
"""A modern-protocol request whose envelope passed every ladder rung.
|
||||
|
||||
``client_info`` and ``client_capabilities`` are the raw envelope values;
|
||||
`client_info` and `client_capabilities` are the raw envelope values;
|
||||
the classifier checks presence only, not shape. Method existence is not a
|
||||
ladder rung — kernel dispatch is the single source of truth for that.
|
||||
"""
|
||||
|
|
@ -117,25 +260,26 @@ def classify_inbound_request(
|
|||
|
||||
Rungs, in order — first failure wins:
|
||||
|
||||
1. ``params._meta`` is a mapping carrying every reserved envelope key
|
||||
1. `params._meta` is a mapping carrying every reserved envelope key
|
||||
(protocol version, client info, client capabilities) → else
|
||||
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
|
||||
2. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals
|
||||
the envelope's protocol version → else
|
||||
:data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the
|
||||
supported-version rung so a client that disagrees with itself is told
|
||||
so, rather than told the body's version is unsupported.
|
||||
3. The envelope's protocol version is in ``supported_modern_versions`` →
|
||||
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
|
||||
protocol version, `Mcp-Method` equals `body.method`, and — for the
|
||||
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
|
||||
body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs
|
||||
before the supported-version rung so a client that disagrees with itself
|
||||
is told so, rather than told the body's version is unsupported.
|
||||
3. The envelope's protocol version is in `supported_modern_versions` →
|
||||
else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
|
||||
``data = {"supported": [...], "requested": <value>}``.
|
||||
`data = {"supported": [...], "requested": <value>}`.
|
||||
|
||||
Method existence is *not* a rung: kernel dispatch owns that decision so
|
||||
custom-registered methods route and the answer lives in one place.
|
||||
|
||||
Args:
|
||||
body: The decoded JSON-RPC request mapping. Envelope shape
|
||||
(``jsonrpc`` / ``id``) is not checked here.
|
||||
headers: Transport headers keyed by lowercase name, or ``None`` to
|
||||
(`jsonrpc` / `id`) is not checked here.
|
||||
headers: Transport headers keyed by lowercase name, or `None` to
|
||||
skip the header rung (non-HTTP callers).
|
||||
supported_modern_versions: Modern protocol revisions this server
|
||||
accepts on the per-request-envelope path.
|
||||
|
|
@ -152,12 +296,27 @@ def classify_inbound_request(
|
|||
"client-capabilities envelope keys",
|
||||
)
|
||||
|
||||
# TODO(L59): also validate Mcp-Method / Mcp-Name per SEP-2243 §Server Validation
|
||||
if headers is not None and headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
|
||||
)
|
||||
if headers is not None:
|
||||
if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
|
||||
)
|
||||
method: Any = body.get("method")
|
||||
if headers.get(MCP_METHOD_HEADER) != method:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_METHOD_HEADER} header does not match the request body's method",
|
||||
)
|
||||
name_key = NAME_BEARING_METHODS.get(method)
|
||||
if name_key is not None:
|
||||
# Rung 1 already proved body["params"] is a mapping.
|
||||
body_value = body["params"].get(name_key)
|
||||
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter",
|
||||
)
|
||||
|
||||
if protocol_version not in supported_modern_versions:
|
||||
return InboundLadderRejection(
|
||||
|
|
|
|||
Loading…
Reference in a new issue