Skip to content

Commit 5fb16c8

Browse files
committed
Send SEP-2243 Mcp-Method/Mcp-Name headers from the StreamableHTTP client
Per SEP-2243, MCP clients should send routing headers on POST requests so spec-compliant servers and intermediaries can route without parsing the JSON-RPC body. The StreamableHTTP client did not send them. Add Mcp-Method (the JSON-RPC method, for requests and notifications) and Mcp-Name (params.name for tools and prompts, else params.uri for resources) to outgoing POST headers. Mcp-Name values are encoded per the SEP-2243 value rules: safe printable-ASCII values are sent unchanged, while non-ASCII, control characters, or significant leading/trailing whitespace are wrapped as =?base64?<b64>?=. Besides matching the spec, this prevents a UnicodeEncodeError when a tool name or URI contains non-ASCII characters, and neutralizes header injection via CR/LF. Responses and errors, which have no method, send neither header. Fixes #2715
1 parent 616476f commit 5fb16c8

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

src/mcp/client/streamable_http.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations as _annotations
44

5+
import base64
56
import contextlib
67
import logging
78
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -43,13 +44,60 @@
4344

4445
MCP_SESSION_ID = "mcp-session-id"
4546
MCP_PROTOCOL_VERSION = "mcp-protocol-version"
47+
MCP_METHOD = "mcp-method"
48+
MCP_NAME = "mcp-name"
4649
LAST_EVENT_ID = "last-event-id"
4750

4851
# Reconnection defaults
4952
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
5053
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
5154

5255

56+
def _encode_mcp_header_value(value: str) -> str:
57+
"""Encode a value for an MCP routing header per SEP-2243.
58+
59+
Returns ``value`` unchanged when it is already safe to send as an HTTP
60+
header value: printable ASCII (0x20-0x7E) with no leading or trailing
61+
whitespace, and not already matching the ``=?base64?...?=`` sentinel.
62+
Otherwise returns the SEP-2243 base64 form ``=?base64?<b64>?=`` over the
63+
UTF-8 bytes, which safely carries non-ASCII text, control characters
64+
(avoiding header injection), and significant leading/trailing whitespace.
65+
"""
66+
is_safe = (
67+
all("\x20" <= ch <= "\x7e" for ch in value)
68+
and (not value or (value[0] not in " \t" and value[-1] not in " \t"))
69+
and not (value.startswith("=?base64?") and value.endswith("?="))
70+
)
71+
if is_safe:
72+
return value
73+
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
74+
return f"=?base64?{encoded}?="
75+
76+
77+
def _set_mcp_request_headers(headers: dict[str, str], message: JSONRPCMessage) -> None:
78+
"""Add SEP-2243 routing headers for an outgoing POST message.
79+
80+
``Mcp-Method`` carries the JSON-RPC method for requests and notifications.
81+
``Mcp-Name`` carries the target ``params.name`` (tools, prompts) or, when
82+
that is absent, ``params.uri`` (resources), encoded per SEP-2243 so that
83+
non-ASCII, control characters, or significant whitespace are transmitted
84+
safely. JSON-RPC responses and errors, which have no method, receive
85+
neither header.
86+
87+
See https://modelcontextprotocol.io/specification (SEP-2243).
88+
"""
89+
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
90+
return
91+
headers[MCP_METHOD] = message.method
92+
params = message.params
93+
if params is None:
94+
return
95+
name = params.get("name")
96+
mcp_name = name if isinstance(name, str) else params.get("uri")
97+
if isinstance(mcp_name, str):
98+
headers[MCP_NAME] = _encode_mcp_header_value(mcp_name)
99+
100+
53101
class StreamableHTTPError(Exception):
54102
"""Base exception for StreamableHTTP transport errors."""
55103

@@ -255,6 +303,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
255303
"""Handle a POST request with response processing."""
256304
headers = self._prepare_headers()
257305
message = ctx.session_message.message
306+
_set_mcp_request_headers(headers, message)
258307
is_initialization = self._is_initialization_request(message)
259308

260309
async with ctx.client.stream(

tests/shared/test_streamable_http.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929

3030
from mcp import MCPError, types
3131
from mcp.client.session import ClientSession
32-
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
32+
from mcp.client.streamable_http import (
33+
StreamableHTTPTransport,
34+
_encode_mcp_header_value,
35+
_set_mcp_request_headers,
36+
streamable_http_client,
37+
)
3338
from mcp.server import Server, ServerRequestContext
3439
from mcp.server.streamable_http import (
3540
MCP_PROTOCOL_VERSION_HEADER,
@@ -2318,3 +2323,74 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
23182323

23192324
assert "content-type" in headers_data
23202325
assert headers_data["content-type"] == "application/json"
2326+
2327+
2328+
@pytest.mark.parametrize(
2329+
("message", "expected"),
2330+
[
2331+
# Request with params.name (tools/prompts) -> Mcp-Name is the name.
2332+
(
2333+
types.JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "read_file"}),
2334+
{"mcp-method": "tools/call", "mcp-name": "read_file"},
2335+
),
2336+
# Request with params.uri but no name (resources) -> Mcp-Name is the uri.
2337+
(
2338+
types.JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/read", params={"uri": "file:///README.md"}),
2339+
{"mcp-method": "resources/read", "mcp-name": "file:///README.md"},
2340+
),
2341+
# Request without params -> only Mcp-Method.
2342+
(
2343+
types.JSONRPCRequest(jsonrpc="2.0", id=3, method="initialize", params=None),
2344+
{"mcp-method": "initialize"},
2345+
),
2346+
# Request whose name is not a string and has no uri -> only Mcp-Method.
2347+
(
2348+
types.JSONRPCRequest(jsonrpc="2.0", id=4, method="tools/call", params={"name": 123}),
2349+
{"mcp-method": "tools/call"},
2350+
),
2351+
# Notification -> Mcp-Method, never Mcp-Name.
2352+
(
2353+
types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
2354+
{"mcp-method": "notifications/initialized"},
2355+
),
2356+
# Response and error have no method -> no MCP routing headers.
2357+
(
2358+
types.JSONRPCResponse(jsonrpc="2.0", id=5, result={}),
2359+
{},
2360+
),
2361+
(
2362+
types.JSONRPCError(jsonrpc="2.0", id=6, error=types.ErrorData(code=types.INTERNAL_ERROR, message="boom")),
2363+
{},
2364+
),
2365+
# A name with non-ASCII characters is encoded per SEP-2243, not sent raw
2366+
# (sending it raw would raise UnicodeEncodeError when building the request).
2367+
(
2368+
types.JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={"name": "café"}),
2369+
{"mcp-method": "tools/call", "mcp-name": "=?base64?Y2Fmw6k=?="},
2370+
),
2371+
],
2372+
)
2373+
def test_set_mcp_request_headers(message: types.JSONRPCMessage, expected: dict[str, str]) -> None:
2374+
"""SEP-2243: POST messages carry Mcp-Method, and Mcp-Name when a target is present."""
2375+
headers: dict[str, str] = {}
2376+
_set_mcp_request_headers(headers, message)
2377+
assert headers == expected
2378+
2379+
2380+
@pytest.mark.parametrize(
2381+
("value", "expected"),
2382+
[
2383+
# Safe values are sent unchanged.
2384+
("get_weather", "get_weather"),
2385+
("file:///projects/myapp/config.json", "file:///projects/myapp/config.json"),
2386+
("", ""),
2387+
# Unsafe values are base64-wrapped (examples taken from SEP-2243).
2388+
("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?="), # non-ASCII
2389+
(" padded ", "=?base64?IHBhZGRlZCA=?="), # leading/trailing space
2390+
("line1\nline2", "=?base64?bGluZTEKbGluZTI=?="), # control character / injection
2391+
("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="), # sentinel collision
2392+
],
2393+
)
2394+
def test_encode_mcp_header_value(value: str, expected: str) -> None:
2395+
"""SEP-2243 value encoding: safe ASCII passes through, everything else is base64-wrapped."""
2396+
assert _encode_mcp_header_value(value) == expected

0 commit comments

Comments
 (0)