Skip to content

Commit 2ab9402

Browse files
committed
Stamp and validate Mcp-Name on tasks/* requests (SEP-2663 routing headers)
SEP-2663 mandates Mcp-Name: <taskId> on tasks/get, tasks/update, and tasks/cancel Streamable HTTP POSTs. Adding the three methods to NAME_BEARING_METHODS covers both ends at once: the client's modern header stamp emits the header automatically and the server's validation ladder rejects a mismatched or absent value. On a server without the tasks extension a header-bearing tasks/* request still reaches -32601 method dispatch, while a headerless one fails the header rung first, consistent with the SEP-2243 header rules; a test pins the ordering.
1 parent ec4ab17 commit 2ab9402

4 files changed

Lines changed: 106 additions & 5 deletions

File tree

src/mcp/shared/inbound.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method."""
6262

6363
MCP_NAME_HEADER: Final = "mcp-name"
64-
"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI)."""
64+
"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt name, resource URI, task id)."""
6565

6666
X_MCP_HEADER_KEY: Final = "x-mcp-header"
6767
"""JSON-Schema property annotation that designates an `Mcp-Param-*` HTTP header."""
@@ -71,9 +71,16 @@
7171
"tools/call": "name",
7272
"prompts/get": "name",
7373
"resources/read": "uri",
74+
"tasks/get": "taskId",
75+
"tasks/update": "taskId",
76+
"tasks/cancel": "taskId",
7477
}
7578
)
76-
"""Method → params key whose value is mirrored as the `Mcp-Name` HTTP header.
79+
"""Method → wire params key whose value is mirrored as the `Mcp-Name` HTTP header.
80+
81+
The first three rows are SEP-2243; the `tasks/*` rows are SEP-2663 §Streamable
82+
HTTP: Routing Headers, which mandates `Mcp-Name: <taskId>` so intermediaries can
83+
route task requests to the server instance holding the task's state.
7784
7885
Shared by client emit (which header to send) and server validate (which body
7986
field to compare against), so both ends agree on the field by construction.

tests/server/test_streamable_http_modern.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,27 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and
316316
assert response.json()["error"]["code"] == HEADER_MISMATCH
317317

318318

319+
async def test_tasks_request_without_tasks_extension_fails_header_rung_before_method_dispatch() -> None:
320+
"""Pins the SEP-2243 rung ordering for the SEP-2663 routing headers: `tasks/get` is
321+
name-bearing (`Mcp-Name` MUST carry `params.taskId` per SEP-2663 §Streamable HTTP: Routing
322+
Headers), and the header rung runs before method dispatch. On a server WITHOUT the tasks
323+
extension, a request whose `Mcp-Name` agrees with the body reaches dispatch and is answered
324+
METHOD_NOT_FOUND at HTTP 404; one with `Mcp-Name` absent is rejected HEADER_MISMATCH at
325+
HTTP 400 without dispatch ever seeing the method."""
326+
body = _list_tools_body()
327+
body["method"] = "tasks/get"
328+
body["params"]["taskId"] = "task-123"
329+
async with _asgi_client(Server("test")) as http:
330+
with_name = await http.post(
331+
"/mcp", json=body, headers={MCP_METHOD_HEADER: "tasks/get", MCP_NAME_HEADER: "task-123"}
332+
)
333+
without_name = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tasks/get"})
334+
assert with_name.status_code == 404
335+
assert with_name.json()["error"]["code"] == METHOD_NOT_FOUND
336+
assert without_name.status_code == 400
337+
assert without_name.json()["error"]["code"] == HEADER_MISMATCH
338+
339+
319340
# --- SSE response mode ---------------------------------------------------------
320341

321342

tests/server/test_tasks.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
envelope have non-spec shapes, so the raw wire dict is read with a permissive
77
`dict` result type. Determinism comes from an injected fixed `clock`; task ids
88
are random `task_<token>` bearer capabilities, so they are captured and reused
9-
for identity rather than snapshotted.
9+
for identity rather than snapshotted. The one exception to the in-memory rule is
10+
the SEP-2663 routing-header test, which drives the in-process HTTP bridge because
11+
the `Mcp-Name` header only exists at the HTTP seam.
1012
"""
1113

14+
import json
1215
from collections.abc import Awaitable, Callable
1316
from datetime import datetime, timedelta, timezone
1417
from types import SimpleNamespace
1518
from typing import Any, Literal, cast
1619

20+
import anyio
21+
import httpx
1722
import mcp_types as types
1823
import pytest
1924
from inline_snapshot import snapshot
@@ -28,6 +33,7 @@
2833
from pydantic import BaseModel, TypeAdapter
2934

3035
from mcp.client.client import Client
36+
from mcp.client.streamable_http import streamable_http_client
3137
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
3238
from mcp.server.extension import Extension
3339
from mcp.server.mcpserver import MCPServer
@@ -45,6 +51,7 @@
4551
)
4652
from mcp.shared.exceptions import MCPError
4753
from mcp.shared.tasks import CancelTaskRequest, GetTaskRequest, GetTaskResult, UpdateTaskRequest
54+
from tests.interaction._connect import BASE_URL, mounted_app
4855

4956
pytestmark = pytest.mark.anyio
5057

@@ -639,6 +646,49 @@ async def test_tasks_result_method_is_method_not_found() -> None:
639646
assert exc_info.value.code == METHOD_NOT_FOUND
640647

641648

649+
async def test_tasks_requests_over_streamable_http_carry_mcp_name_routing_header() -> None:
650+
"""SEP-2663 §Streamable HTTP: Routing Headers: when `tasks/get`, `tasks/update`, or
651+
`tasks/cancel` is sent over Streamable HTTP, "the client MUST set the `Mcp-Name` header
652+
(defined by SEP-2243) to the value of `params.taskId`" so intermediaries can route the
653+
request to the instance holding the task's state.
654+
655+
The session's modern stamp reads `NAME_BEARING_METHODS`, so the typed `tasks/*` wrappers
656+
sent through `client.session.send_request` are stamped with no tasks-specific client code.
657+
Asserted at the wire via the `mounted_app` request hook because the client never exposes
658+
outgoing headers; each call also round-trips successfully, so the stamped value passes the
659+
server's header-mismatch rung end to end."""
660+
posts: list[httpx.Request] = []
661+
662+
async def on_request(request: httpx.Request) -> None:
663+
posts.append(request)
664+
665+
captured: list[str] = []
666+
with anyio.fail_after(5):
667+
async with (
668+
mounted_app(_tasks_server(), on_request=on_request) as (http, _),
669+
Client(
670+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
671+
extensions={EXTENSION_ID: {}},
672+
) as client,
673+
):
674+
created = await _augmented_call(client)
675+
assert isinstance(created["taskId"], str)
676+
captured.append(created["taskId"])
677+
await _get_task(client, created["taskId"])
678+
await _update_task(client, created["taskId"])
679+
await _cancel_task(client, created["taskId"])
680+
681+
task_id = captured[0]
682+
observed = [(json.loads(request.content)["method"], request.headers.get("mcp-name")) for request in posts]
683+
assert observed == [
684+
("server/discover", None),
685+
("tools/call", "echo"),
686+
("tasks/get", task_id),
687+
("tasks/update", task_id),
688+
("tasks/cancel", task_id),
689+
]
690+
691+
642692
@pytest.mark.parametrize("method", ["get", "update", "cancel"])
643693
async def test_tasks_methods_from_non_declaring_client_are_missing_required_capability(method: str) -> None:
644694
"""SEP-2663: every `tasks/*` call from a modern client that did not declare the

tests/shared/test_inbound.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ def test_header_rung_rejects_missing_method_header() -> None:
202202
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
203203

204204

205+
@pytest.mark.parametrize(
206+
("method", "name_key"),
207+
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
208+
)
209+
def test_header_rung_passes_matching_name_header_for_name_bearing_methods(method: str, name_key: str) -> None:
210+
"""Spec-mandated: an `Mcp-Name` header equal to the named body param passes rung 3 for every
211+
name-bearing method — SEP-2243 for `tools/call`/`prompts/get`/`resources/read`, SEP-2663
212+
§Streamable HTTP: Routing Headers for `tasks/*` (`Mcp-Name` MUST carry `params.taskId`)."""
213+
body = envelope(method, extra_params={name_key: "expected"})
214+
result = classify_inbound_request(body, headers=matching_headers(body))
215+
assert isinstance(result, InboundModernRoute)
216+
217+
205218
@pytest.mark.parametrize(
206219
("method", "name_key"),
207220
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
@@ -340,8 +353,18 @@ def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> No
340353

341354

342355
def test_name_bearing_methods_table_matches_spec() -> None:
343-
"""Spec-mandated: pins the method → name-param table the client emit and server validate share."""
344-
assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
356+
"""Spec-mandated: pins the method → name-param table the client emit and server validate share.
357+
358+
The first three rows are SEP-2243; the `tasks/*` rows are SEP-2663 §Streamable HTTP: Routing
359+
Headers ("the client MUST set the `Mcp-Name` header ... to the value of `params.taskId`")."""
360+
assert NAME_BEARING_METHODS == {
361+
"tools/call": "name",
362+
"prompts/get": "name",
363+
"resources/read": "uri",
364+
"tasks/get": "taskId",
365+
"tasks/update": "taskId",
366+
"tasks/cancel": "taskId",
367+
}
345368

346369

347370
# --- find_invalid_x_mcp_header -------------------------------------------------

0 commit comments

Comments
 (0)