Skip to content

Commit 316c035

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 a9ca217 commit 316c035

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
@@ -63,7 +63,7 @@
6363
"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method."""
6464

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

6868
X_MCP_HEADER_KEY: Final = "x-mcp-header"
6969
"""JSON-Schema property annotation that designates an `Mcp-Param-*` HTTP header."""
@@ -73,9 +73,16 @@
7373
"tools/call": "name",
7474
"prompts/get": "name",
7575
"resources/read": "uri",
76+
"tasks/get": "taskId",
77+
"tasks/update": "taskId",
78+
"tasks/cancel": "taskId",
7679
}
7780
)
78-
"""Method → params key whose value is mirrored as the `Mcp-Name` HTTP header.
81+
"""Method → wire params key whose value is mirrored as the `Mcp-Name` HTTP header.
82+
83+
The first three rows are SEP-2243; the `tasks/*` rows are SEP-2663 §Streamable
84+
HTTP: Routing Headers, which mandates `Mcp-Name: <taskId>` so intermediaries can
85+
route task requests to the server instance holding the task's state.
7986
8087
Shared by client emit (which header to send) and server validate (which body
8188
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
@@ -320,6 +320,27 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and
320320
assert response.json()["error"]["code"] == HEADER_MISMATCH
321321

322322

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

325346

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
@@ -29,6 +34,7 @@
2934

3035
from mcp.client import advertise
3136
from mcp.client.client import Client
37+
from mcp.client.streamable_http import streamable_http_client
3238
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
3339
from mcp.server.extension import Extension
3440
from mcp.server.mcpserver import MCPServer
@@ -46,6 +52,7 @@
4652
)
4753
from mcp.shared.exceptions import MCPError
4854
from mcp.shared.tasks import CancelTaskRequest, GetTaskRequest, GetTaskResult, UpdateTaskRequest
55+
from tests.interaction._connect import BASE_URL, mounted_app
4956

5057
pytestmark = pytest.mark.anyio
5158

@@ -644,6 +651,49 @@ async def test_tasks_result_method_is_method_not_found() -> None:
644651
assert exc_info.value.code == METHOD_NOT_FOUND
645652

646653

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

243243

244+
@pytest.mark.parametrize(
245+
("method", "name_key"),
246+
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
247+
)
248+
def test_header_rung_passes_matching_name_header_for_name_bearing_methods(method: str, name_key: str) -> None:
249+
"""Spec-mandated: an `Mcp-Name` header equal to the named body param passes rung 3 for every
250+
name-bearing method — SEP-2243 for `tools/call`/`prompts/get`/`resources/read`, SEP-2663
251+
§Streamable HTTP: Routing Headers for `tasks/*` (`Mcp-Name` MUST carry `params.taskId`)."""
252+
body = envelope(method, extra_params={name_key: "expected"})
253+
result = classify_inbound_request(body, headers=matching_headers(body))
254+
assert isinstance(result, InboundModernRoute)
255+
256+
244257
@pytest.mark.parametrize(
245258
("method", "name_key"),
246259
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
@@ -379,8 +392,18 @@ def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> No
379392

380393

381394
def test_name_bearing_methods_table_matches_spec() -> None:
382-
"""Spec-mandated: pins the method → name-param table the client emit and server validate share."""
383-
assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
395+
"""Spec-mandated: pins the method → name-param table the client emit and server validate share.
396+
397+
The first three rows are SEP-2243; the `tasks/*` rows are SEP-2663 §Streamable HTTP: Routing
398+
Headers ("the client MUST set the `Mcp-Name` header ... to the value of `params.taskId`")."""
399+
assert NAME_BEARING_METHODS == {
400+
"tools/call": "name",
401+
"prompts/get": "name",
402+
"resources/read": "uri",
403+
"tasks/get": "taskId",
404+
"tasks/update": "taskId",
405+
"tasks/cancel": "taskId",
406+
}
384407

385408

386409
# --- find_invalid_x_mcp_header -------------------------------------------------

0 commit comments

Comments
 (0)