Skip to content

Commit 2a922ce

Browse files
committed
Re-pin the negative-ttl and Mcp-Param mismatch tests to the landed behaviour
The client now clamps a negative inbound ttlMs to 0 before validation, and the modern HTTP entry validates Mcp-Param-* headers against the tool's advertised schema. Both tests flip from pinning the old gap to asserting the spec-mandated outcome, and their divergence records are removed.
1 parent 9ba9a21 commit 2a922ce

3 files changed

Lines changed: 40 additions & 58 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4443,20 +4443,10 @@ def __post_init__(self) -> None:
44434443
source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field",
44444444
behavior="A negative ttlMs on an inbound result is ignored and treated as 0.",
44454445
added_in="2026-07-28",
4446-
divergence=Divergence(
4447-
note=(
4448-
"The client rejects a negative ttlMs with a pydantic ValidationError out of the "
4449-
"request call instead of ignoring it and treating it as 0: Field(ge=0) on the "
4450-
"2026-07-28 wire surface (and on the monolith CacheableResult) raises before any "
4451-
"coerce-to-zero leniency could run, and there is no response cache for 'treat as "
4452-
"0' to act on. The gap is asymmetric: ge=0 on server-authored EMISSION is correct "
4453-
"by-construction strictness (a conformant server can never author a negative "
4454-
"ttlMs through the typed API); the gap is ONLY the client's inbound parse, which "
4455-
"validates before any clamp-to-0 could apply. The remedy is receive-side leniency "
4456-
"-- clamp a negative inbound ttlMs to 0 before validation -- NOT loosening the "
4457-
"shared type, which would silently bless negative emission server-side."
4458-
),
4459-
issue="L112",
4446+
note=(
4447+
"The leniency is receive-side only: the client clamps a negative inbound ttlMs to 0 "
4448+
"before validation, while emission keeps ge=0 on the shared type -- a conformant "
4449+
"server can never author a negative ttlMs through the typed API."
44604450
),
44614451
),
44624452
"caching:ttl:positive-fresh-window": Requirement(
@@ -6183,22 +6173,11 @@ def __post_init__(self) -> None:
61836173
),
61846174
added_in="2026-07-28",
61856175
transports=("streamable-http",),
6186-
divergence=Divergence(
6187-
note=(
6188-
"The server performs no Mcp-Param-* header validation: the inbound ladder "
6189-
"compares only MCP-Protocol-Version, Mcp-Method and Mcp-Name, so a request "
6190-
"whose decoded Mcp-Param header disagrees with the body argument is accepted "
6191-
"and the handler runs on the body value; the same gap covers the spec's "
6192-
"'client omits header but value is in body' reject row. The SDK has no notion "
6193-
"of a 'recognized' param header (the inbound ladder never sees a tool schema); "
6194-
"the pinned accept uses a header that name-matches a body argument -- the "
6195-
"strongest candidate for any future validation -- and the unknown-header arm "
6196-
"(a header with no corresponding body argument) is deliberately not pinned: "
6197-
"its reject-vs-ignore consequence must be decided when validation lands."
6198-
),
6199-
issue="L110",
6176+
note=(
6177+
"TS implements this (createMcpHandler) with no requirement id of its own. When the "
6178+
"server registers no tools/list handler the validation deliberately fails open and "
6179+
"the call is served."
62006180
),
6201-
note="TS implements this (createMcpHandler) with no requirement id of its own.",
62026181
),
62036182
"hosting:http:modern:invalid-header-chars-rejected": Requirement(
62046183
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-behavior-for-custom-headers",

tests/interaction/lowlevel/test_caching.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
Tool,
2828
)
2929
from mcp_types.version import LATEST_MODERN_VERSION
30-
from pydantic import ValidationError
3130

3231
from mcp.client import ClientRequestContext
3332
from mcp.client.client import Client
@@ -257,10 +256,10 @@ async def answer_who(context: ClientRequestContext, params: types.ElicitRequestP
257256

258257

259258
@requirement("caching:ttl:negative-treated-as-zero")
260-
async def test_a_negative_ttl_from_a_nonconformant_server_is_rejected_not_coerced_to_zero() -> None:
261-
"""An inbound ttlMs of -1 raises ValidationError instead of being treated as 0 (pinned Divergence).
259+
async def test_a_negative_ttl_from_a_nonconformant_server_is_clamped_to_zero() -> None:
260+
"""An inbound ttlMs of -1 surfaces as ttl_ms 0 instead of failing validation. Spec-mandated (SHOULD).
262261
263-
When coerce-to-zero leniency lands: re-pin to ttl_ms == 0 and delete the Divergence.
262+
Scripted peer: the typed Server cannot author a negative ttlMs (emission keeps ge=0).
264263
"""
265264
async with create_client_server_memory_streams() as (client_streams, server_streams):
266265
client_read, client_write = client_streams
@@ -295,12 +294,9 @@ async def scripted_server() -> None:
295294
server_info=Implementation(name="srv", version="0"),
296295
)
297296
)
298-
with pytest.raises(ValidationError) as excinfo:
299-
with anyio.fail_after(5):
300-
await session.list_tools()
301-
302-
errors = excinfo.value.errors()
303-
assert len(errors) == 1
304-
assert errors[0]["loc"] == ("ttlMs",)
305-
# Stable pydantic-core identifier; the message text is third-party and deliberately unpinned.
306-
assert errors[0]["type"] == "greater_than_equal"
297+
with anyio.fail_after(5):
298+
result = await session.list_tools()
299+
300+
assert result.ttl_ms == 0
301+
assert result.cache_scope == "public"
302+
assert result.tools == []

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,19 +1450,24 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14501450

14511451

14521452
@requirement("hosting:http:modern:mcp-param-mismatch-400")
1453-
async def test_modern_entry_accepts_a_mismatching_mcp_param_header_without_validation() -> None:
1454-
"""A tools/call whose Mcp-Param header disagrees with the body argument is accepted, pinning the gap.
1453+
async def test_modern_mcp_param_header_disagreeing_with_body_argument_is_rejected_400_header_mismatch() -> None:
1454+
"""A ``Mcp-Param-*`` header disagreeing with its body argument is rejected with HTTP 400 and HeaderMismatch.
14551455
1456-
Pins a recorded divergence: the spec mandates 400/-32020 HeaderMismatch on a decoded-header /
1457-
body disagreement, but the ladder compares only MCP-Protocol-Version, Mcp-Method and Mcp-Name,
1458-
so the handler runs on the body value. When validation lands: this test flips to 400 and
1459-
re-pins, while the null-and-absent acceptance test above must stay green.
1456+
Spec-mandated: the server resolves the ``x-mcp-header`` annotation from the tool's advertised
1457+
``inputSchema`` via its own tools/list handler and rejects the decoded-header/body disagreement
1458+
before dispatch. Raw httpx because the HTTP status is a wire-only observable and the typed
1459+
client cannot emit a mismatching header by construction.
14601460
"""
14611461

1462+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
1463+
tool = Tool(
1464+
name="run",
1465+
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
1466+
)
1467+
return ListToolsResult(tools=[tool])
1468+
14621469
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
1463-
assert params.name == "run"
1464-
assert params.arguments == {"region": "us-west1"}
1465-
return CallToolResult(content=[TextContent(text="ok")])
1470+
raise NotImplementedError # The mismatch is rejected before dispatch reaches the handler.
14661471

14671472
body = {
14681473
"jsonrpc": "2.0",
@@ -1472,13 +1477,15 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14721477
}
14731478
headers = _modern_headers(method="tools/call", name="run") | {"mcp-param-region": "eu-central1"}
14741479
with anyio.fail_after(5):
1475-
async with mounted_app(Server("param-mismatch", on_call_tool=call_tool)) as (http, _):
1480+
async with mounted_app(Server("param-mismatch", on_list_tools=list_tools, on_call_tool=call_tool)) as (
1481+
http,
1482+
_,
1483+
):
14761484
response = await http.post("/mcp", json=body, headers=headers)
14771485

1478-
# 200, not the spec-mandated 400: the request is served despite the mismatching header.
1479-
assert response.status_code == 200
1480-
parsed = JSONRPCResponse.model_validate(response.json())
1481-
assert parsed.id == 1
1482-
assert parsed.result == snapshot(
1483-
{"content": [{"text": "ok", "type": "text"}], "isError": False, "resultType": "complete"}
1486+
assert response.status_code == 400
1487+
assert JSONRPCError.model_validate(response.json()).error == snapshot(
1488+
ErrorData(
1489+
code=HEADER_MISMATCH, message="Mcp-Param-Region header does not match the request body's 'region' argument"
1490+
)
14841491
)

0 commit comments

Comments
 (0)