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
4 changed files with 1163 additions and 150 deletions
|
|
@ -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__
|
||||
|
|
|
|||
Loading…
Reference in a new issue