Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations as _annotations

import base64
import contextlib
import logging
from collections.abc import AsyncGenerator, Awaitable, Callable
Expand Down Expand Up @@ -43,13 +44,60 @@

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
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
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?<b64>?=`` 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."""

Expand Down Expand Up @@ -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(
Expand Down
78 changes: 77 additions & 1 deletion tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading