diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index aa3e50e07e..0784d3b331 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -2,6 +2,7 @@ from __future__ import annotations as _annotations +import base64 import contextlib import logging from collections.abc import AsyncGenerator, Awaitable, Callable @@ -43,6 +44,8 @@ MCP_SESSION_ID = "mcp-session-id" MCP_PROTOCOL_VERSION = "mcp-protocol-version" +MCP_METHOD = "mcp-method" +MCP_NAME = "mcp-name" LAST_EVENT_ID = "last-event-id" # Reconnection defaults @@ -50,6 +53,51 @@ MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up +def _encode_mcp_header_value(value: str) -> str: + """Encode a value for an MCP routing header per SEP-2243. + + Returns ``value`` unchanged when it is already safe to send as an HTTP + header value: printable ASCII (0x20-0x7E) with no leading or trailing + whitespace, and not already matching the ``=?base64?...?=`` sentinel. + Otherwise returns the SEP-2243 base64 form ``=?base64??=`` over the + UTF-8 bytes, which safely carries non-ASCII text, control characters + (avoiding header injection), and significant leading/trailing whitespace. + """ + is_safe = ( + all("\x20" <= ch <= "\x7e" for ch in value) + and (not value or (value[0] not in " \t" and value[-1] not in " \t")) + and not (value.startswith("=?base64?") and value.endswith("?=")) + ) + if is_safe: + return value + encoded = base64.b64encode(value.encode("utf-8")).decode("ascii") + return f"=?base64?{encoded}?=" + + +def _set_mcp_request_headers(headers: dict[str, str], message: JSONRPCMessage) -> None: + """Add SEP-2243 routing headers for an outgoing POST message. + + ``Mcp-Method`` carries the JSON-RPC method for requests and notifications. + ``Mcp-Name`` carries the target ``params.name`` (tools, prompts) or, when + that is absent, ``params.uri`` (resources), encoded per SEP-2243 so that + non-ASCII, control characters, or significant whitespace are transmitted + safely. JSON-RPC responses and errors, which have no method, receive + neither header. + + See https://modelcontextprotocol.io/specification (SEP-2243). + """ + if not isinstance(message, JSONRPCRequest | JSONRPCNotification): + return + headers[MCP_METHOD] = message.method + params = message.params + if params is None: + return + name = params.get("name") + mcp_name = name if isinstance(name, str) else params.get("uri") + if isinstance(mcp_name, str): + headers[MCP_NAME] = _encode_mcp_header_value(mcp_name) + + class StreamableHTTPError(Exception): """Base exception for StreamableHTTP transport errors.""" @@ -255,6 +303,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" headers = self._prepare_headers() message = ctx.session_message.message + _set_mcp_request_headers(headers, message) is_initialization = self._is_initialization_request(message) async with ctx.client.stream( diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 3d5770fb61..f1876da9d7 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -29,7 +29,12 @@ from mcp import MCPError, types from mcp.client.session import ClientSession -from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client +from mcp.client.streamable_http import ( + StreamableHTTPTransport, + _encode_mcp_header_value, + _set_mcp_request_headers, + streamable_http_client, +) from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http import ( MCP_PROTOCOL_VERSION_HEADER, @@ -2318,3 +2323,74 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers( assert "content-type" in headers_data assert headers_data["content-type"] == "application/json" + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + # Request with params.name (tools/prompts) -> Mcp-Name is the name. + ( + types.JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "read_file"}), + {"mcp-method": "tools/call", "mcp-name": "read_file"}, + ), + # Request with params.uri but no name (resources) -> Mcp-Name is the uri. + ( + types.JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/read", params={"uri": "file:///README.md"}), + {"mcp-method": "resources/read", "mcp-name": "file:///README.md"}, + ), + # Request without params -> only Mcp-Method. + ( + types.JSONRPCRequest(jsonrpc="2.0", id=3, method="initialize", params=None), + {"mcp-method": "initialize"}, + ), + # Request whose name is not a string and has no uri -> only Mcp-Method. + ( + types.JSONRPCRequest(jsonrpc="2.0", id=4, method="tools/call", params={"name": 123}), + {"mcp-method": "tools/call"}, + ), + # Notification -> Mcp-Method, never Mcp-Name. + ( + types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), + {"mcp-method": "notifications/initialized"}, + ), + # Response and error have no method -> no MCP routing headers. + ( + types.JSONRPCResponse(jsonrpc="2.0", id=5, result={}), + {}, + ), + ( + types.JSONRPCError(jsonrpc="2.0", id=6, error=types.ErrorData(code=types.INTERNAL_ERROR, message="boom")), + {}, + ), + # A name with non-ASCII characters is encoded per SEP-2243, not sent raw + # (sending it raw would raise UnicodeEncodeError when building the request). + ( + types.JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={"name": "café"}), + {"mcp-method": "tools/call", "mcp-name": "=?base64?Y2Fmw6k=?="}, + ), + ], +) +def test_set_mcp_request_headers(message: types.JSONRPCMessage, expected: dict[str, str]) -> None: + """SEP-2243: POST messages carry Mcp-Method, and Mcp-Name when a target is present.""" + headers: dict[str, str] = {} + _set_mcp_request_headers(headers, message) + assert headers == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Safe values are sent unchanged. + ("get_weather", "get_weather"), + ("file:///projects/myapp/config.json", "file:///projects/myapp/config.json"), + ("", ""), + # Unsafe values are base64-wrapped (examples taken from SEP-2243). + ("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?="), # non-ASCII + (" padded ", "=?base64?IHBhZGRlZCA=?="), # leading/trailing space + ("line1\nline2", "=?base64?bGluZTEKbGluZTI=?="), # control character / injection + ("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="), # sentinel collision + ], +) +def test_encode_mcp_header_value(value: str, expected: str) -> None: + """SEP-2243 value encoding: safe ASCII passes through, everything else is base64-wrapped.""" + assert _encode_mcp_header_value(value) == expected