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
4 changed files with 1163 additions and 150 deletions

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__