Skip to content

Commit b72f110

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 9bdc03d commit b72f110

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
@@ -47,13 +48,60 @@
4748
StreamReader = ContextReceiveStream[SessionMessage]
4849

4950
MCP_SESSION_ID = "mcp-session-id"
51+
MCP_METHOD = "mcp-method"
52+
MCP_NAME = "mcp-name"
5053
LAST_EVENT_ID = "last-event-id"
5154

5255
# Reconnection defaults
5356
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
5457
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
5558

5659

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

@@ -319,6 +367,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
319367
headers = self._prepare_headers()
320368
if ctx.metadata is not None and ctx.metadata.headers is not None:
321369
headers.update(ctx.metadata.headers)
370+
_set_mcp_request_headers(headers, message)
322371

323372
async with ctx.client.stream(
324373
"POST",

tests/shared/test_streamable_http.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@
4545
from mcp import MCPError
4646
from mcp.client import ClientRequestContext
4747
from mcp.client.session import ClientSession
48-
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
48+
from mcp.client.streamable_http import (
49+
StreamableHTTPTransport,
50+
_encode_mcp_header_value,
51+
_set_mcp_request_headers,
52+
streamable_http_client,
53+
)
4954
from mcp.server import Server, ServerRequestContext
5055
from mcp.server.streamable_http import (
5156
GET_STREAM_KEY,
@@ -2283,3 +2288,74 @@ async def asgi_receive() -> Message:
22832288
assert body_chunks[-1] == {"type": "http.response.body", "body": b"", "more_body": False}
22842289
assert "Error in standalone SSE writer" not in caplog.text
22852290
assert "Error in standalone SSE response" not in caplog.text
2291+
2292+
2293+
@pytest.mark.parametrize(
2294+
("message", "expected"),
2295+
[
2296+
# Request with params.name (tools/prompts) -> Mcp-Name is the name.
2297+
(
2298+
types.JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "read_file"}),
2299+
{"mcp-method": "tools/call", "mcp-name": "read_file"},
2300+
),
2301+
# Request with params.uri but no name (resources) -> Mcp-Name is the uri.
2302+
(
2303+
types.JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/read", params={"uri": "file:///README.md"}),
2304+
{"mcp-method": "resources/read", "mcp-name": "file:///README.md"},
2305+
),
2306+
# Request without params -> only Mcp-Method.
2307+
(
2308+
types.JSONRPCRequest(jsonrpc="2.0", id=3, method="initialize", params=None),
2309+
{"mcp-method": "initialize"},
2310+
),
2311+
# Request whose name is not a string and has no uri -> only Mcp-Method.
2312+
(
2313+
types.JSONRPCRequest(jsonrpc="2.0", id=4, method="tools/call", params={"name": 123}),
2314+
{"mcp-method": "tools/call"},
2315+
),
2316+
# Notification -> Mcp-Method, never Mcp-Name.
2317+
(
2318+
types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
2319+
{"mcp-method": "notifications/initialized"},
2320+
),
2321+
# Response and error have no method -> no MCP routing headers.
2322+
(
2323+
types.JSONRPCResponse(jsonrpc="2.0", id=5, result={}),
2324+
{},
2325+
),
2326+
(
2327+
types.JSONRPCError(jsonrpc="2.0", id=6, error=types.ErrorData(code=types.INTERNAL_ERROR, message="boom")),
2328+
{},
2329+
),
2330+
# A name with non-ASCII characters is encoded per SEP-2243, not sent raw
2331+
# (sending it raw would raise UnicodeEncodeError when building the request).
2332+
(
2333+
types.JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={"name": "café"}),
2334+
{"mcp-method": "tools/call", "mcp-name": "=?base64?Y2Fmw6k=?="},
2335+
),
2336+
],
2337+
)
2338+
def test_set_mcp_request_headers(message: types.JSONRPCMessage, expected: dict[str, str]) -> None:
2339+
"""SEP-2243: POST messages carry Mcp-Method, and Mcp-Name when a target is present."""
2340+
headers: dict[str, str] = {}
2341+
_set_mcp_request_headers(headers, message)
2342+
assert headers == expected
2343+
2344+
2345+
@pytest.mark.parametrize(
2346+
("value", "expected"),
2347+
[
2348+
# Safe values are sent unchanged.
2349+
("get_weather", "get_weather"),
2350+
("file:///projects/myapp/config.json", "file:///projects/myapp/config.json"),
2351+
("", ""),
2352+
# Unsafe values are base64-wrapped (examples taken from SEP-2243).
2353+
("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?="), # non-ASCII
2354+
(" padded ", "=?base64?IHBhZGRlZCA=?="), # leading/trailing space
2355+
("line1\nline2", "=?base64?bGluZTEKbGluZTI=?="), # control character / injection
2356+
("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="), # sentinel collision
2357+
],
2358+
)
2359+
def test_encode_mcp_header_value(value: str, expected: str) -> None:
2360+
"""SEP-2243 value encoding: safe ASCII passes through, everything else is base64-wrapped."""
2361+
assert _encode_mcp_header_value(value) == expected

0 commit comments

Comments
 (0)