Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

This commit is contained in:
Max 2026-06-26 09:51:59 +02:00 • committed by GitHub
commit 587340279e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1163 additions and 150 deletions

View file

@ -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:

View file

@ -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

View file

@ -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."""

View file

@ -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)

View file

@ -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__

View file

@ -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(