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

View file

@ -20,7 +20,9 @@ Scenarios:
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
elicitation-sep1034-client-defaults - Elicitation with default accept callback
sep-2322-client-request-state - Drive the manual MRTR retry surface
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
auth/* - Authorization code flow (default for auth scenarios)
@ -296,6 +298,43 @@ async def run_http_standard_headers(server_url: str) -> None:
logger.debug(f"add_numbers result: {result}")
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
"""Minimal arguments satisfying a tool inputSchema's required list."""
by_type: dict[str, Any] = {
"string": "x",
"integer": 0,
"number": 0,
"boolean": False,
"object": {},
"array": [],
"null": None,
}
properties = input_schema.get("properties", {})
return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])}
@register("http-invalid-tool-headers")
async def run_http_invalid_tool_headers(server_url: str) -> None:
"""List tools, then call every tool the SDK surfaces (SEP-2243).
The harness mock advertises one valid tool plus several with malformed
x-mcp-header annotations (empty, non-primitive type, duplicate, invalid
chars). The scenario passes if valid_tool is called and the malformed
ones are not -- so a conforming client filters them out of the list_tools
result and the loop below never sees them. The scenario sets
allowClientError, so a per-call failure is logged and skipped rather
than aborting the whole run.
"""
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}")
for tool in listed.tools:
try:
await client.call_tool(tool.name, _stub_required_args(tool.input_schema))
except Exception:
logger.exception(f"call_tool({tool.name!r}) failed")
@register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
@ -305,6 +344,53 @@ async def run_elicitation_defaults(server_url: str) -> None:
logger.debug(f"test_client_elicitation_defaults result: {result}")
@register("sep-2322-client-request-state")
async def run_mrtr_client(server_url: str) -> None:
"""Drive the manual MRTR retry surface against the SEP-2322 client mock.
The mock speaks the modern lifecycle (server/discover, no initialize) and
inspects the wire params of each tools/call round, so this exercises the
explicit allow_input_required=True path rather than an auto-loop: round 1
receives an InputRequiredResult, the fixture fulfils the elicitation
locally, then round 2 retries with input_responses + the echoed
request_state. Passing request_state straight off the typed result -- a
str when the server sent one, None when it didn't -- lets the
serializer's exclude_none drop the key in the no-state case without a
branch here. The unrelated call between rounds proves MRTR params don't
leak across tools, and the no-result-type call must parse as a complete
CallToolResult with no retry.
"""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
confirm = {"confirm": types.ElicitResult(action="accept", content={"confirmed": True})}
r1 = await client.call_tool("test_mrtr_echo_state", {}, allow_input_required=True)
assert isinstance(r1, types.InputRequiredResult)
await client.call_tool("test_mrtr_unrelated", {})
await client.call_tool(
"test_mrtr_echo_state",
{},
input_responses=confirm,
request_state=r1.request_state,
allow_input_required=True,
)
r2 = await client.call_tool("test_mrtr_no_state", {}, allow_input_required=True)
assert isinstance(r2, types.InputRequiredResult)
await client.call_tool(
"test_mrtr_no_state",
{},
input_responses=confirm,
request_state=r2.request_state,
allow_input_required=True,
)
result = await client.call_tool("test_mrtr_no_result_type", {})
assert isinstance(result, types.CallToolResult)
@register("auth/client-credentials-jwt")
async def run_client_credentials_jwt(server_url: str) -> None:
"""Client credentials flow with private_key_jwt authentication."""
@ -441,8 +527,7 @@ def main() -> None:
asyncio.run(run_auth_code_client(server_url))
else:
# Unhandled scenarios:
# - sep-2322-client-request-state (SEP-2322 / S6: MRTR client loop)
# - http-custom-headers, http-invalid-tool-headers (SEP-2243 / S8: Mcp-Param-* headers)
# - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission)
print(f"Unknown scenario: {scenario}", file=sys.stderr)
sys.exit(1)
else:

View file

@ -21,48 +21,19 @@
# milestone.
client:
# --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) ---
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
# tool-schema-cache vs per-call tool_definition design (S8).
- http-custom-headers
- http-invalid-tool-headers
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
# (it is an extension scenario not carried into the 2026 wire), so it is
# neither run nor evaluated on this leg.
server:
# --- Carried-forward 2025-era scenarios still failing on the 2026 wire ---
# The stateless 2026 path now reaches handlers for plain request/response
# scenarios; tools-call-with-progress still fails because the stateless
# server has no channel for server→client progress notifications.
- tools-call-with-progress
# SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's
# schema has none of the 2020-12 keywords the scenario checks. The scenario
# is in `--suite all` but not `--suite active`, so this is the only leg that
# runs it; it fails identically at 2025-11-25 (not a 2026-path regression).
- json-schema-2020-12
# --- Draft scenarios (same failures and reasons as the `--suite draft` leg) ---
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
- input-required-result-request-state
- input-required-result-multiple-input-requests
- input-required-result-multi-round
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
- input-required-result-non-tool-request
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- input-required-result-missing-input-response
- input-required-result-validate-input

View file

@ -12,12 +12,9 @@
client:
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
# tool-schema-cache vs per-call tool_definition design (S8).
- http-custom-headers
- http-invalid-tool-headers
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
@ -26,23 +23,6 @@ client:
server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
- input-required-result-request-state
- input-required-result-multiple-input-requests
- input-required-result-multi-round
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
- input-required-result-non-tool-request
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- input-required-result-missing-input-response
- input-required-result-validate-input

View file

@ -15,15 +15,10 @@ permissions:
env:
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.5.
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7.
# Bump deliberately and reconcile both
# .github/actions/conformance/expected-failures*.yml files in the same change.
#
# TODO: replace with @modelcontextprotocol/conformance@0.2.0-alpha.5 once
# https://github.com/modelcontextprotocol/conformance/pull/357 publishes, and
# drop CONFORMANCE_PKG_SHA256 plus the fetch-and-verify step below.
CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@65fcd39"
CONFORMANCE_PKG_SHA256: "9a381d7083f8be2fe7ae44efeca54530f18c61425805ddaf9cd88915efcc1574"
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.7"
jobs:
server-conformance:
@ -39,19 +34,6 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Fetch and verify conformance harness
# Only when CONFORMANCE_PKG is a URL: download, check the recorded
# sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
# When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
# own integrity check applies).
run: |
case "$CONFORMANCE_PKG" in
https://*)
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
;;
esac
- run: uv sync --frozen --all-extras --package mcp-everything-server
- name: Run server conformance (active suite)
run: >-
@ -83,26 +65,22 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Fetch and verify conformance harness
run: |
case "$CONFORMANCE_PKG" in
https://*)
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
;;
esac
- run: uv sync --frozen --all-extras --package mcp
- name: Run client conformance (all suite)
# The harness runs all scenarios via unbounded Promise.all; with 40
# scenarios on a 2-core runner the slowest one (sse-retry, which has a
# real-time SSE reconnect wait) needs more than the 30s default budget.
run: >-
npx --yes "$CONFORMANCE_PKG" client
--command 'uv run --frozen python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--expected-failures ./.github/actions/conformance/expected-failures.yml
- name: Run client conformance (2026-07-28 wire, all suite)
run: >-
npx --yes "$CONFORMANCE_PKG" client
--command 'uv run --frozen python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--spec-version 2026-07-28
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml

View file

@ -10,7 +10,8 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t
### `MCPServer.call_tool()` returns `CallToolResult`
`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously
`MCPServer.call_tool()` now returns a `CallToolResult` (or an
`InputRequiredResult` when a multi-round tool requests further input). It previously
advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal
conversion shapes (a bare content sequence or a `(content, structured_content)`
tuple), forcing callers to re-assemble a `CallToolResult` themselves.

View file

@ -6,8 +6,12 @@ Server implementing all MCP features for conformance testing based on Conformanc
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import logging
from typing import Any
import click
from mcp.server import ServerRequestContext
@ -20,10 +24,20 @@ from mcp_types import (
Completion,
CompletionArgument,
CompletionContext,
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
EmbeddedResource,
EmptyResult,
ImageContent,
InputRequest,
InputRequiredResult,
JSONRPCMessage,
ListRootsRequest,
ListRootsResult,
PromptReference,
ResourceTemplateReference,
SamplingMessage,
@ -33,7 +47,7 @@ from mcp_types import (
TextResourceContents,
UnsubscribeRequestParams,
)
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from mcp_types.jsonrpc import INVALID_PARAMS, MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@ -333,6 +347,231 @@ async def test_missing_capability(ctx: Context) -> str:
return "Client declared sampling capability; proceeding."
# SEP-2322 InputRequiredResult fixtures (multi-round-trip / ephemeral workflow)
NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation(message: str = "What is your name?") -> ElicitRequest:
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=NAME_SCHEMA))
@mcp.tool()
async def test_input_required_result_elicitation(ctx: Context) -> str | InputRequiredResult:
"""Tests InputRequiredResult with a single elicitation request"""
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
name = answer.content.get("name", "stranger") if isinstance(answer, ElicitResult) and answer.content else "?"
return f"Hello, {name}!"
return InputRequiredResult(input_requests={"user_name": _name_elicitation()})
@mcp.tool()
async def test_input_required_result_sampling(ctx: Context) -> str | InputRequiredResult:
"""Tests InputRequiredResult with a single sampling request"""
responses = ctx.input_responses
if responses and "capital_question" in responses:
answer = responses["capital_question"]
text = answer.content.text if isinstance(answer, CreateMessageResult) and answer.content.type == "text" else "?"
return f"Model said: {text}"
return InputRequiredResult(
input_requests={
"capital_question": CreateMessageRequest(
params=CreateMessageRequestParams(
messages=[
SamplingMessage(
role="user", content=TextContent(type="text", text="What is the capital of France?")
)
],
max_tokens=100,
)
)
}
)
@mcp.tool()
async def test_input_required_result_list_roots(ctx: Context) -> str | InputRequiredResult:
"""Tests InputRequiredResult with a single roots/list request"""
responses = ctx.input_responses
if responses and "client_roots" in responses:
answer = responses["client_roots"]
count = len(answer.roots) if isinstance(answer, ListRootsResult) else 0
return f"Client exposed {count} root(s)."
return InputRequiredResult(input_requests={"client_roots": ListRootsRequest()})
@mcp.tool()
async def test_input_required_result_request_state(ctx: Context) -> str | InputRequiredResult:
"""Tests requestState round-tripping in the InputRequiredResult flow"""
responses = ctx.input_responses
if responses and "confirm" in responses and ctx.request_state == "request-state-nonce":
return "state-ok: confirmation received"
confirm = ElicitRequest(
params=ElicitRequestFormParams(
message="Please confirm",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
return InputRequiredResult(input_requests={"confirm": confirm}, request_state="request-state-nonce")
@mcp.tool()
async def test_input_required_result_multiple_inputs(ctx: Context) -> str | InputRequiredResult:
"""Tests InputRequiredResult carrying elicitation, sampling and roots requests together"""
responses = ctx.input_responses
if responses and {"user_name", "greeting", "client_roots"} <= responses.keys():
return "All inputs received."
return InputRequiredResult(
input_requests={
"user_name": _name_elicitation(),
"greeting": CreateMessageRequest(
params=CreateMessageRequestParams(
messages=[
SamplingMessage(role="user", content=TextContent(type="text", text="Generate a greeting"))
],
max_tokens=50,
)
),
"client_roots": ListRootsRequest(),
},
request_state="multiple-inputs",
)
@mcp.tool()
async def test_input_required_result_multi_round(ctx: Context) -> str | InputRequiredResult:
"""Tests a three-round InputRequiredResult flow with evolving requestState"""
state = json.loads(ctx.request_state) if ctx.request_state else {"round": 0}
responses = ctx.input_responses or {}
if state["round"] == 0:
return InputRequiredResult(
input_requests={"step1": _name_elicitation("Step 1: What is your name?")},
request_state=json.dumps({"round": 1}),
)
if state["round"] == 1 and "step1" in responses:
step1 = responses["step1"]
name = step1.content.get("name") if isinstance(step1, ElicitResult) and step1.content else None
color_schema = {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]}
return InputRequiredResult(
input_requests={
"step2": ElicitRequest(
params=ElicitRequestFormParams(
message="Step 2: What is your favorite color?", requested_schema=color_schema
)
)
},
request_state=json.dumps({"round": 2, "name": name}),
)
if state["round"] == 2 and "step2" in responses:
step2 = responses["step2"]
color = step2.content.get("color") if isinstance(step2, ElicitResult) and step2.content else None
return f"{state.get('name')} likes {color}."
# Missing or out-of-order response: re-request from the start.
return InputRequiredResult(
input_requests={"step1": _name_elicitation("Step 1: What is your name?")},
request_state=json.dumps({"round": 1}),
)
# Fixed key for the conformance fixture; a real server would derive or rotate this.
_STATE_HMAC_KEY = b"everything-server-fixture-key"
def _seal_state(payload: str) -> str:
encoded = base64.urlsafe_b64encode(payload.encode()).decode()
sig = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest()
return f"{encoded}.{sig}"
def _unseal_state(state: str) -> str:
encoded, _, sig = state.partition(".")
expected = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest()
if not sig or not hmac.compare_digest(sig, expected):
raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification")
try:
return base64.urlsafe_b64decode(encoded).decode()
except (binascii.Error, UnicodeDecodeError) as e:
raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") from e
@mcp.tool()
async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult:
"""Tests that the server rejects a requestState that fails HMAC verification"""
if ctx.request_state is None:
confirm = ElicitRequest(
params=ElicitRequestFormParams(
message="Please confirm",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
return InputRequiredResult(input_requests={"confirm": confirm}, request_state=_seal_state("round-1"))
payload = _unseal_state(ctx.request_state)
return f"state-ok: {payload}"
@mcp.tool()
async def test_input_required_result_capabilities(ctx: Context) -> InputRequiredResult:
"""Tests that inputRequests only include methods the client declared support for"""
caps = ctx.client_capabilities
requests: dict[str, InputRequest] = {}
if caps is None or caps.sampling is not None:
requests["sample"] = CreateMessageRequest(
params=CreateMessageRequestParams(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Say hello"))],
max_tokens=50,
)
)
if caps is None or caps.elicitation is not None:
requests["ask"] = _name_elicitation()
return InputRequiredResult(input_requests=requests, request_state="capability-gated")
# SEP-1613 / SEP-2106 JSON Schema 2020-12 fixture: a tool whose inputSchema carries
# the full set of 2020-12 keywords the conformance scenario asserts on.
JSON_SCHEMA_2020_12_INPUT_SCHEMA: dict[str, Any] = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"address": {
"$anchor": "addressDef",
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
}
},
"properties": {
"name": {"type": "string"},
"address": {"$ref": "#/$defs/address"},
"contactMethod": {"type": "string", "enum": ["phone", "email"]},
"phone": {"type": "string"},
"email": {"type": "string"},
},
"allOf": [{"anyOf": [{"required": ["phone"]}, {"required": ["email"]}]}],
"if": {"properties": {"contactMethod": {"const": "phone"}}, "required": ["contactMethod"]},
"then": {"required": ["phone"]},
"else": {"required": ["email"]},
"additionalProperties": False,
}
@mcp.tool(name="json_schema_2020_12_tool")
def json_schema_2020_12_tool() -> str:
"""Tests JSON Schema 2020-12 keyword preservation in tools/list (inputSchema installed below)."""
return "json_schema_2020_12_tool"
# TODO(felix): replace with a public input_schema= override once MCPServer.tool() grows one.
mcp._tool_manager._tools["json_schema_2020_12_tool"].parameters = ( # pyright: ignore[reportPrivateUsage]
JSON_SCHEMA_2020_12_INPUT_SCHEMA
)
@mcp.tool()
async def test_reconnection(ctx: Context) -> str:
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""

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(

View file

@ -472,6 +472,38 @@ async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code:
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@pytest.mark.anyio
async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None:
"""At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header`
annotation is malformed; handshake-era sessions surface them unchanged. Two
tools are advertised — one valid, one with a non-RFC-9110-token header name —
and the modern client sees only the valid one."""
valid = types.Tool(
name="ok",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
)
bad = types.Tool(
name="dropme",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
)
async def on_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[valid, bad])
server = Server("test", on_list_tools=on_list_tools)
with anyio.fail_after(5):
async with Client(server) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok"]
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok", "dropme"]
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is

View file

@ -206,7 +206,7 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
headers=base_headers() | {"mcp-protocol-version": bad},
headers=base_headers() | {"mcp-protocol-version": bad, "mcp-method": "tools/list"},
)
assert response.status_code == 400

View file

@ -10,7 +10,7 @@ from typing import Annotated, Any, Final, NamedTuple, TypedDict
import annotated_types
import pytest
from dirty_equals import IsPartialDict
from mcp_types import CallToolResult
from mcp_types import CallToolResult, InputRequiredResult
from pydantic import BaseModel, Field
from mcp.server.mcpserver.exceptions import InvalidSignature
@ -862,6 +862,29 @@ def test_tool_call_result_annotated_is_structured_and_converted():
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form():
"""Stripping `InputRequiredResult` makes the residual behave exactly as if it were the
declared return annotation, including the `Annotated[CallToolResult, Model]` special case
— the schema derives from `Model` and `convert_result` validates `structured_content`
against it instead of wrapping the whole `CallToolResult`."""
class PersonClass(BaseModel):
name: str
def fn_bare() -> Annotated[CallToolResult, PersonClass]:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
bare = func_metadata(fn_bare)
iir = func_metadata(fn_iir)
assert iir.output_schema == bare.output_schema
assert iir.wrap_output == bare.wrap_output
assert isinstance(bare.convert_result(fn_bare()), CallToolResult)
assert isinstance(iir.convert_result(fn_iir()), CallToolResult)
def test_tool_call_result_annotated_is_structured_and_invalid():
class PersonClass(BaseModel):
name: str
@ -1038,7 +1061,9 @@ def test_structured_output_aliases():
# Check that the actual output uses aliases too
result = ModelWithAliases(**{"first": "hello", "second": "world"})
structured_content = meta.convert_result(result).structured_content
converted = meta.convert_result(result)
assert isinstance(converted, CallToolResult)
structured_content = converted.structured_content
assert structured_content is not None
# The structured content should use aliases to match the schema
@ -1051,7 +1076,9 @@ def test_structured_output_aliases():
# Also test the case where we have a model with defaults to ensure aliases work in all cases
result_with_defaults = ModelWithAliases() # Uses default None values
structured_content_defaults = meta.convert_result(result_with_defaults).structured_content
converted_defaults = meta.convert_result(result_with_defaults)
assert isinstance(converted_defaults, CallToolResult)
structured_content_defaults = converted_defaults.structured_content
assert structured_content_defaults is not None
# Even with defaults, should use aliases in output
@ -1191,3 +1218,71 @@ def test_preserves_pydantic_metadata():
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
def test_convert_result_passes_input_required_result_through_unchanged():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_input_required_result_return_annotation_yields_no_output_schema():
def fn() -> InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
assert meta.output_model is None
def test_union_with_input_required_result_derives_schema_from_residual_arm():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"]["type"] == "string"
converted = meta.convert_result("hello")
assert isinstance(converted, CallToolResult)
assert converted.structured_content == {"result": "hello"}
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_call_tool_result_unioned_with_input_required_result_is_accepted():
def fn() -> CallToolResult | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
def test_basemodel_union_input_required_result_derives_model_schema():
class Payload(BaseModel):
x: int
def fn() -> Payload | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_model is Payload
assert meta.wrap_output is False
assert meta.output_schema == Payload.model_json_schema()
def test_call_tool_result_in_union_with_input_required_result_is_still_rejected():
def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch
with pytest.raises(InvalidSignature, match="CallToolResult cannot be used in Union"):
func_metadata(fn)
def test_union_of_only_input_required_subclasses_yields_no_output_schema():
class StepA(InputRequiredResult):
pass
class StepB(InputRequiredResult):
pass
def fn() -> StepA | StepB: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None

View file

@ -3,6 +3,7 @@ from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -11,15 +12,21 @@ from mcp_types import (
AudioContent,
BlobResourceContents,
CallToolResult,
ClientCapabilities,
Completion,
CompletionArgument,
CompletionContext,
ContentBlock,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
EmbeddedResource,
GetPromptResult,
Icon,
ImageContent,
InputRequiredResult,
ListPromptsResult,
ListRootsRequest,
Prompt,
PromptArgument,
PromptMessage,
@ -1570,3 +1577,98 @@ async def test_read_resource_template_not_found():
assert exc_info.value.error.code == INVALID_PARAMS
assert exc_info.value.error.data == {"uri": "resource://users/999"}
async def test_tool_returning_input_required_result_reaches_client_unchanged():
mcp = MCPServer()
@mcp.tool()
async def ask(ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"roots": ListRootsRequest()}, request_state="round-1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.call_tool("ask", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
assert result.input_requests is not None
assert result.input_requests["roots"].method == "roots/list"
async def test_tool_reads_input_responses_and_request_state_from_context_on_retry():
mcp = MCPServer()
@mcp.tool()
async def greet(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses and "who" in responses:
who = responses["who"]
assert isinstance(who, ElicitResult) and who.content is not None
return f"Hello, {who.content['name']}! (state={ctx.request_state})"
return InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
},
request_state="r1",
)
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.call_tool("greet", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.call_tool(
"greet",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, CallToolResult)
block = r2.content[0]
assert isinstance(block, TextContent)
assert block.text == "Hello, Alice! (state=r1)"
async def test_context_exposes_client_capabilities_from_connection():
mcp = MCPServer()
seen: list[ClientCapabilities | None] = []
@mcp.tool()
async def probe(ctx: Context) -> str:
seen.append(ctx.client_capabilities)
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert len(seen) == 1
assert isinstance(seen[0], ClientCapabilities)
async def test_context_input_responses_and_request_state_are_none_on_initial_round():
mcp = MCPServer()
captured: dict[str, Any] = {}
@mcp.tool()
async def probe(ctx: Context) -> str:
captured["responses"] = ctx.input_responses
captured["state"] = ctx.request_state
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert captured == {"responses": None, "state": None}

View file

@ -15,6 +15,7 @@ import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
@ -39,7 +40,7 @@ from mcp.server._streamable_http_modern import (
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext
pytestmark = pytest.mark.anyio
@ -67,7 +68,10 @@ def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettin
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION},
headers={
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
"content-type": "application/json",
},
)
@ -150,7 +154,7 @@ async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info
body["method"] = "custom/greet"
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={"content-type": "application/json"})
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"})
assert response.status_code == 200
assert response.json()["result"] == {"ok": True}
assert seen == [None]
@ -175,7 +179,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_rais
with caplog.at_level(logging.ERROR, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.json()["result"]["tools"] == []
@ -203,7 +207,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
# coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there);
# the shielded-cancel path inside the request task disrupts the tracer in this frame.
assert response.status_code == 200 # pragma: lax no cover
@ -270,3 +274,30 @@ async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and
# Handler internals never reach the wire.
assert "boom" not in reply.error.message
assert "request handler raised" in caplog.text
# --- header cross-check at the wire --------------------------------------------
async def test_handle_modern_request_rejects_mismatched_method_header_with_400_and_header_mismatch() -> None:
"""Spec-mandated: an `Mcp-Method` header that disagrees with `body.method` is rejected at the
boundary as HTTP 400 with JSON-RPC error code HEADER_MISMATCH; the handler never runs."""
async with _asgi_client(Server("test")) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "prompts/list"})
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and_header_mismatch() -> None:
"""Spec-mandated: for a name-bearing method, an `Mcp-Name` header that disagrees with the body's
named param is rejected as HTTP 400 with JSON-RPC error code HEADER_MISMATCH."""
body = _list_tools_body()
body["method"] = "tools/call"
body["params"]["name"] = "real"
body["params"]["arguments"] = {}
async with _asgi_client(Server("test")) as http:
response = await http.post(
"/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "wrong"}
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH

View file

@ -1,7 +1,7 @@
"""Pure-function tests of :mod:`mcp.shared.inbound`.
Independent verifier of the classifier: every ladder rung is exercised
pass+fail with no ``mcp.server`` / transport imports and no inlined error-code
pass+fail with no `mcp.server` / transport imports and no inlined error-code
or protocol-version literals — all facts are imported from their one source.
"""
@ -27,10 +27,16 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, M
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_METHOD_HEADER,
MCP_NAME_HEADER,
MCP_PROTOCOL_VERSION_HEADER,
NAME_BEARING_METHODS,
InboundLadderRejection,
InboundModernRoute,
classify_inbound_request,
decode_header_value,
encode_header_value,
find_invalid_x_mcp_header,
)
CLIENT_INFO = {"name": "t", "version": "0"}
@ -42,10 +48,11 @@ def envelope(
*,
version: str = LATEST_MODERN_VERSION,
drop: frozenset[str] = frozenset(),
extra_params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a JSON-RPC body carrying a complete modern ``_meta`` envelope.
"""Build a JSON-RPC body carrying a complete modern `_meta` envelope.
``drop`` removes named envelope keys so rung-1 failures are driven from one
`drop` removes named envelope keys so rung-1 failures are driven from one
table instead of repeating reserved-key constants per call site.
"""
meta: dict[str, Any] = {
@ -55,7 +62,22 @@ def envelope(
}
for key in drop:
del meta[key]
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": {"_meta": meta}}
params: dict[str, Any] = {"_meta": meta}
if extra_params:
params.update(extra_params)
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
def matching_headers(body: dict[str, Any]) -> dict[str, str]:
"""The minimal lowercase HTTP header set that agrees with `body` for rung 2."""
headers = {
MCP_PROTOCOL_VERSION_HEADER: body["params"]["_meta"][PROTOCOL_VERSION_META_KEY],
MCP_METHOD_HEADER: body["method"],
}
name_key = NAME_BEARING_METHODS.get(body["method"])
if name_key is not None and name_key in body["params"]:
headers[MCP_NAME_HEADER] = encode_header_value(body["params"][name_key])
return headers
def assert_rejected(result: object, code: int) -> InboundLadderRejection:
@ -78,7 +100,7 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection:
],
)
def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
"""Spec-mandated: a modern request lacking any of the three reserved ``_meta`` keys is rejected INVALID_PARAMS."""
"""Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS."""
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
assert rejection.data is None
@ -94,7 +116,7 @@ def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
],
)
def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
"""Spec-mandated: non-mapping ``params`` / ``_meta`` cannot carry the envelope and reject INVALID_PARAMS."""
"""Spec-mandated: non-mapping `params` / `_meta` cannot carry the envelope and reject INVALID_PARAMS."""
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
@ -102,7 +124,7 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
def test_version_rung_rejects_unsupported_with_data_shape() -> None:
"""Spec-mandated: an envelope version outside the modern set rejects with the ``supported``/``requested`` data."""
"""Spec-mandated: an envelope version outside the modern set rejects with the `supported`/`requested` data."""
rejection = assert_rejected(
classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)),
UNSUPPORTED_PROTOCOL_VERSION,
@ -114,7 +136,7 @@ def test_version_rung_rejects_unsupported_with_data_shape() -> None:
def test_version_rung_data_reflects_supplied_supported_list() -> None:
"""SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes."""
"""SDK-defined: the caller-supplied `supported_modern_versions` is what rejection `data.supported` echoes."""
custom = (LATEST_HANDSHAKE_VERSION,)
rejection = assert_rejected(
classify_inbound_request(envelope(), supported_modern_versions=custom),
@ -127,14 +149,15 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None:
def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None:
"""SDK-defined: ``headers=None`` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds."""
"""SDK-defined: `headers=None` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds."""
result = classify_inbound_request(envelope(), headers=None)
assert isinstance(result, InboundModernRoute)
def test_header_rung_passes_when_header_matches_envelope() -> None:
"""Spec-mandated: an HTTP version header equal to the envelope version passes rung 3."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
body = envelope()
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
@ -150,12 +173,78 @@ def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH)
@pytest.mark.parametrize(
"override",
[
pytest.param({MCP_METHOD_HEADER: "prompts/list"}, id="method-mismatch"),
pytest.param({MCP_METHOD_HEADER: "TOOLS/LIST"}, id="method-case-mismatch"),
],
)
def test_header_rung_rejects_method_header_disagreement(override: dict[str, str]) -> None:
"""Spec-mandated: `Mcp-Method` must equal `body.method` exactly (case-sensitive) → else HEADER_MISMATCH."""
body = envelope()
rejection = assert_rejected(
classify_inbound_request(body, headers=matching_headers(body) | override), HEADER_MISMATCH
)
assert MCP_METHOD_HEADER in rejection.message
def test_header_rung_rejects_missing_method_header() -> None:
"""Spec-mandated: an HTTP request on the modern path without `Mcp-Method` is HEADER_MISMATCH."""
body = envelope()
headers = matching_headers(body)
del headers[MCP_METHOD_HEADER]
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
@pytest.mark.parametrize(
("method", "name_key"),
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
)
def test_header_rung_rejects_missing_or_mismatched_name_header_for_name_bearing_methods(
method: str, name_key: str
) -> None:
"""Spec-mandated: when the body carries the named param, `Mcp-Name` must be present and equal it."""
body = envelope(method, extra_params={name_key: "expected"})
headers = matching_headers(body)
# Mismatch
assert_rejected(classify_inbound_request(body, headers=headers | {MCP_NAME_HEADER: "wrong"}), HEADER_MISMATCH)
# Absent
del headers[MCP_NAME_HEADER]
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
def test_header_rung_decodes_base64_sentinel_before_comparing_name() -> None:
"""Spec-mandated: servers MUST decode the `=?base64?...?=` sentinel before comparing `Mcp-Name`."""
body = envelope("tools/call", extra_params={"name": "résumé"})
headers = matching_headers(body)
assert headers[MCP_NAME_HEADER].startswith("=?base64?")
result = classify_inbound_request(body, headers=headers)
assert isinstance(result, InboundModernRoute)
def test_header_rung_does_not_require_name_header_for_non_name_bearing_method() -> None:
"""SDK-defined: a method outside `NAME_BEARING_METHODS` ignores `Mcp-Name` entirely."""
body = envelope("tools/list")
result = classify_inbound_request(body, headers=matching_headers(body) | {MCP_NAME_HEADER: "anything"})
assert isinstance(result, InboundModernRoute)
def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None:
"""SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name`
check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here."""
body = envelope("tools/call")
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
# --- all rungs pass ------------------------------------------------------------
def test_all_rungs_pass_yields_route() -> None:
"""Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
body = envelope()
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
assert result.protocol_version == LATEST_MODERN_VERSION
assert result.client_info == CLIENT_INFO
@ -165,7 +254,8 @@ def test_all_rungs_pass_yields_route() -> None:
@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"])
def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
"""SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision."""
result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
body = envelope(method)
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
@ -215,3 +305,173 @@ def test_verdict_dataclasses_are_frozen() -> None:
for verdict in (route, rejection):
with pytest.raises(dataclasses.FrozenInstanceError):
setattr(verdict, "message", "mutated")
# --- header-value codec --------------------------------------------------------
@pytest.mark.parametrize(
"raw",
["plain", "with internal space", "", " edge-ws ", "résumé", "a\r\nb", "=?base64?Zm9v?="],
)
def test_decode_header_value_round_trips_encode(raw: str) -> None:
"""SDK-defined: `decode_header_value` is the exact inverse of `encode_header_value` over the full input domain."""
assert decode_header_value(encode_header_value(raw)) == raw
def test_decode_header_value_passes_none_and_plain_through() -> None:
"""SDK-defined: `None` in → `None` out so callers can pass `headers.get(...)` directly; plain stays verbatim."""
assert decode_header_value(None) is None
assert decode_header_value("plain") == "plain"
@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="])
def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None:
"""SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value."""
assert decode_header_value(bad) is None
# --- NAME_BEARING_METHODS ------------------------------------------------------
def test_name_bearing_methods_table_matches_spec() -> None:
"""Spec-mandated: pins the method → name-param table the client emit and server validate share."""
assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
# --- find_invalid_x_mcp_header -------------------------------------------------
def _schema(**props: Any) -> dict[str, Any]:
return {"type": "object", "properties": props}
@pytest.mark.parametrize(
"input_schema",
[
pytest.param(None, id="none"),
pytest.param("not-a-mapping", id="non-mapping"),
pytest.param({"type": "object"}, id="no-properties"),
pytest.param({"type": "object", "properties": "not-a-mapping"}, id="properties-non-mapping"),
pytest.param(_schema(a={"type": "string"}), id="no-annotation"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region"}), id="valid-string"),
pytest.param(_schema(a={"type": "integer", "x-mcp-header": "Count"}), id="valid-integer"),
pytest.param(_schema(a={"type": "boolean", "x-mcp-header": "Flag"}), id="valid-boolean"),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "A"}, b={"type": "string", "x-mcp-header": "B"}),
id="two-distinct",
),
pytest.param(_schema(a="not-a-mapping", b={"type": "string", "x-mcp-header": "B"}), id="non-mapping-prop"),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "R"}}}),
id="nested-on-properties-chain",
),
pytest.param(
_schema(a={"type": "string", "default": {"x-mcp-header": "ignored"}}),
id="annotation-lookalike-in-default-is-data",
),
pytest.param(
_schema(a={"type": "string", "examples": [{"x-mcp-header": "ignored"}]}),
id="annotation-lookalike-in-examples-is-data",
),
pytest.param(
_schema(a={"type": "string", "const": {"x-mcp-header": "ignored"}}),
id="annotation-lookalike-in-const-is-data",
),
pytest.param(
{"properties": {"a": {"type": "string", "x-mcp-header": "R"}}, "$ref": "#/$defs/loop"},
id="ref-is-not-dereferenced",
),
pytest.param(
{"type": "object", "allOf": 0, "anyOf": [], "$defs": 0, "patternProperties": {}},
id="malformed-or-empty-applicators-ignored",
),
],
)
def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_schema: Any) -> None:
"""Spec-mandated: a schema without annotations, or with annotations that are RFC 9110 tokens on
integer/string/boolean properties reachable via a pure `properties` chain and case-insensitively
unique across the whole schema, is valid."""
assert find_invalid_x_mcp_header(input_schema) is None
@pytest.mark.parametrize(
"input_schema",
[
pytest.param(_schema(a={"type": "string", "x-mcp-header": ""}), id="empty"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "My Region"}), id="space"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region:Primary"}), id="colon"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Région"}), id="non-ascii"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region\t1"}), id="control-char"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": 42}), id="non-string"),
pytest.param(_schema(a={"type": "object", "x-mcp-header": "Data"}), id="on-object"),
pytest.param(_schema(a={"type": "array", "x-mcp-header": "Items"}), id="on-array"),
pytest.param(_schema(a={"type": "null", "x-mcp-header": "Nil"}), id="on-null"),
pytest.param(_schema(a={"type": "number", "x-mcp-header": "Ratio"}), id="on-number"),
pytest.param(_schema(a={"type": ["string", "null"], "x-mcp-header": "Maybe"}), id="array-type"),
pytest.param(_schema(a={"type": {"not": "valid"}, "x-mcp-header": "Bad"}), id="dict-type"),
pytest.param(_schema(a={"x-mcp-header": "NoType"}), id="missing-type"),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "Region"}, b={"type": "string", "x-mcp-header": "Region"}),
id="duplicate-same-case",
),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "MyField"}, b={"type": "string", "x-mcp-header": "myfield"}),
id="duplicate-diff-case",
),
pytest.param(
_schema(a={"type": "array", "items": {"type": "string", "x-mcp-header": "X"}}),
id="under-items",
),
pytest.param(
{"allOf": [{"properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]},
id="under-allOf",
),
pytest.param(
{"oneOf": [{"type": "string", "x-mcp-header": "X"}]},
id="under-oneOf",
),
pytest.param(
_schema(a={"if": {"type": "string", "x-mcp-header": "X"}}),
id="under-if",
),
pytest.param(
{"$defs": {"T": {"type": "string", "x-mcp-header": "X"}}, "properties": {}},
id="under-defs",
),
pytest.param(
{"patternProperties": {"^a": {"type": "string", "x-mcp-header": "X"}}},
id="under-patternProperties",
),
pytest.param(
{"type": "string", "x-mcp-header": "X"},
id="on-root-schema",
),
pytest.param(
_schema(
a={"type": "string", "x-mcp-header": "Region"},
o={"type": "object", "properties": {"b": {"type": "string", "x-mcp-header": "region"}}},
),
id="duplicate-across-nesting-levels",
),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "bad name"}}}),
id="nested-bad-token",
),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}),
id="nested-non-primitive",
),
],
)
def test_find_invalid_x_mcp_header_rejects_malformed_annotations(input_schema: dict[str, Any]) -> None:
"""Spec-mandated: empty / non-token / non-primitive / off-chain / duplicate `x-mcp-header`
annotations yield a reason string."""
assert isinstance(find_invalid_x_mcp_header(input_schema), str)
def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> None:
"""SDK-defined: the reason string names the nested property by its dotted `properties` path."""
schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}})
reason = find_invalid_x_mcp_header(schema)
assert reason is not None and "'outer.r'" in reason