Skip to content

Commit 326a600

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 a5afd94 commit 326a600

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
@@ -4356,20 +4356,10 @@ def __post_init__(self) -> None:
43564356
source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field",
43574357
behavior="A negative ttlMs on an inbound result is ignored and treated as 0.",
43584358
added_in="2026-07-28",
4359-
divergence=Divergence(
4360-
note=(
4361-
"The client rejects a negative ttlMs with a pydantic ValidationError out of the "
4362-
"request call instead of ignoring it and treating it as 0: Field(ge=0) on the "
4363-
"2026-07-28 wire surface (and on the monolith CacheableResult) raises before any "
4364-
"coerce-to-zero leniency could run, and there is no response cache for 'treat as "
4365-
"0' to act on. The gap is asymmetric: ge=0 on server-authored EMISSION is correct "
4366-
"by-construction strictness (a conformant server can never author a negative "
4367-
"ttlMs through the typed API); the gap is ONLY the client's inbound parse, which "
4368-
"validates before any clamp-to-0 could apply. The remedy is receive-side leniency "
4369-
"-- clamp a negative inbound ttlMs to 0 before validation -- NOT loosening the "
4370-
"shared type, which would silently bless negative emission server-side."
4371-
),
4372-
issue="L112",
4359+
note=(
4360+
"The leniency is receive-side only: the client clamps a negative inbound ttlMs to 0 "
4361+
"before validation, while emission keeps ge=0 on the shared type -- a conformant "
4362+
"server can never author a negative ttlMs through the typed API."
43734363
),
43744364
),
43754365
"caching:ttl:positive-fresh-window": Requirement(
@@ -6094,22 +6084,11 @@ def __post_init__(self) -> None:
60946084
),
60956085
added_in="2026-07-28",
60966086
transports=("streamable-http",),
6097-
divergence=Divergence(
6098-
note=(
6099-
"The server performs no Mcp-Param-* header validation: the inbound ladder "
6100-
"compares only MCP-Protocol-Version, Mcp-Method and Mcp-Name, so a request "
6101-
"whose decoded Mcp-Param header disagrees with the body argument is accepted "
6102-
"and the handler runs on the body value; the same gap covers the spec's "
6103-
"'client omits header but value is in body' reject row. The SDK has no notion "
6104-
"of a 'recognized' param header (the inbound ladder never sees a tool schema); "
6105-
"the pinned accept uses a header that name-matches a body argument -- the "
6106-
"strongest candidate for any future validation -- and the unknown-header arm "
6107-
"(a header with no corresponding body argument) is deliberately not pinned: "
6108-
"its reject-vs-ignore consequence must be decided when validation lands."
6109-
),
6110-
issue="L110",
6087+
note=(
6088+
"TS implements this (createMcpHandler) with no requirement id of its own. When the "
6089+
"server registers no tools/list handler the validation deliberately fails open and "
6090+
"the call is served."
61116091
),
6112-
note="TS implements this (createMcpHandler) with no requirement id of its own.",
61136092
),
61146093
"hosting:http:modern:invalid-header-chars-rejected": Requirement(
61156094
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
@@ -1437,19 +1437,24 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14371437

14381438

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

1449+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
1450+
tool = Tool(
1451+
name="run",
1452+
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
1453+
)
1454+
return ListToolsResult(tools=[tool])
1455+
14491456
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
1450-
assert params.name == "run"
1451-
assert params.arguments == {"region": "us-west1"}
1452-
return CallToolResult(content=[TextContent(text="ok")])
1457+
raise NotImplementedError # The mismatch is rejected before dispatch reaches the handler.
14531458

14541459
body = {
14551460
"jsonrpc": "2.0",
@@ -1459,13 +1464,15 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14591464
}
14601465
headers = _modern_headers(method="tools/call", name="run") | {"mcp-param-region": "eu-central1"}
14611466
with anyio.fail_after(5):
1462-
async with mounted_app(Server("param-mismatch", on_call_tool=call_tool)) as (http, _):
1467+
async with mounted_app(Server("param-mismatch", on_list_tools=list_tools, on_call_tool=call_tool)) as (
1468+
http,
1469+
_,
1470+
):
14631471
response = await http.post("/mcp", json=body, headers=headers)
14641472

1465-
# 200, not the spec-mandated 400: the request is served despite the mismatching header.
1466-
assert response.status_code == 200
1467-
parsed = JSONRPCResponse.model_validate(response.json())
1468-
assert parsed.id == 1
1469-
assert parsed.result == snapshot(
1470-
{"content": [{"text": "ok", "type": "text"}], "isError": False, "resultType": "complete"}
1473+
assert response.status_code == 400
1474+
assert JSONRPCError.model_validate(response.json()).error == snapshot(
1475+
ErrorData(
1476+
code=HEADER_MISMATCH, message="Mcp-Param-Region header does not match the request body's 'region' argument"
1477+
)
14711478
)

0 commit comments

Comments
 (0)