Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
This commit is contained in:
parent
603342f0fe
commit
587340279e
6 changed files with 1163 additions and 150 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue