Skip to content

Commit e0990bf

Browse files
committed
Thread per-message headers from session to transport via CallOptions/metadata sidecar
Additive infrastructure for the client-side outbound stamp: - CallOptions gains a headers key; ClientMessageMetadata gains a headers field - _plan_outbound projects opts['headers'] onto the metadata (same path resumption tokens take); JSONRPCDispatcher.notify accepts opts and threads headers through - Outbound.notify Protocol grows opts=None; all implementers updated (Connection, _NoChannelOutbound, _SingleExchangeDispatchContext, peer, context, DirectDispatcher, test stubs) - StreamableHTTPTransport's POST path merges metadata.headers into the request (alongside existing _prepare_headers/_per_message_headers, which are removed in the next commit) - MCP_METHOD_HEADER, MCP_NAME_HEADER, encode_header_value moved to shared/inbound (single source for the header names) - Tests pin both new paths
1 parent cb9e260 commit e0990bf

18 files changed

Lines changed: 124 additions & 43 deletions

‎src/mcp/client/streamable_http.py‎

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33
from __future__ import annotations as _annotations
44

5-
import base64
65
import contextlib
76
import logging
8-
import re
97
from collections.abc import AsyncGenerator, Awaitable, Callable
108
from contextlib import asynccontextmanager
119
from dataclasses import dataclass
@@ -20,7 +18,7 @@
2018
from mcp.shared._compat import resync_tracer
2119
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2220
from mcp.shared._httpx_utils import create_mcp_http_client
23-
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
21+
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
2422
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2523
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
2624
from mcp.types import (
@@ -47,24 +45,12 @@
4745
StreamReader = ContextReceiveStream[SessionMessage]
4846

4947
MCP_SESSION_ID = "mcp-session-id"
50-
MCP_METHOD = "mcp-method"
51-
MCP_NAME = "mcp-name"
5248
LAST_EVENT_ID = "last-event-id"
5349

5450
# Reconnection defaults
5551
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
5652
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
5753

58-
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
59-
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
60-
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
61-
62-
63-
def _encode_header_value(value: str) -> str:
64-
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
65-
return value
66-
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
67-
6854

6955
class StreamableHTTPError(Exception):
7056
"""Base exception for StreamableHTTP transport errors."""
@@ -112,7 +98,7 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
11298
return {}
11399
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
114100
return {}
115-
headers: dict[str, str] = {MCP_METHOD: message.method}
101+
headers: dict[str, str] = {MCP_METHOD_HEADER: message.method}
116102
# TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read
117103
# (params.uri); a method->param-key map replaces this gate when those land.
118104
if (
@@ -121,7 +107,7 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
121107
and message.params
122108
and isinstance(name := message.params.get("name"), str)
123109
):
124-
headers[MCP_NAME] = _encode_header_value(name)
110+
headers[MCP_NAME_HEADER] = encode_header_value(name)
125111
return headers
126112

127113
def _prepare_headers(self) -> dict[str, str]:
@@ -302,6 +288,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
302288
headers = self._prepare_headers()
303289
message = ctx.session_message.message
304290
headers.update(self._per_message_headers(message))
291+
if ctx.metadata is not None and ctx.metadata.headers is not None:
292+
headers.update(ctx.metadata.headers)
305293
is_initialization = self._is_initialization_request(message)
306294

307295
async with ctx.client.stream(

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async def send_raw_request(
7777
) -> dict[str, Any]:
7878
raise NoBackChannelError(method)
7979

80-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
80+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
8181
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
8282
return None
8383

‎src/mcp/server/connection.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async def send_raw_request(
9393
) -> dict[str, Any]:
9494
raise NoBackChannelError(method)
9595

96-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
96+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
9797
logger.debug("dropped %s: no standalone channel", method)
9898

9999

@@ -277,14 +277,14 @@ async def send_request(
277277
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
278278
return cls.model_validate(raw, by_name=False)
279279

280-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
280+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
281281
"""Send a best-effort notification on the standalone stream.
282282
283283
Never raises. If there's no standalone channel or the stream is broken,
284284
the notification is dropped and debug-logged.
285285
"""
286286
try:
287-
await self.outbound.notify(method, params)
287+
await self.outbound.notify(method, params, opts)
288288
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
289289
logger.debug("dropped %s: standalone stream closed", method)
290290

‎src/mcp/shared/context.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ async def send_raw_request(
7373
"""
7474
return await self._dctx.send_raw_request(method, params, opts)
7575

76-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
76+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
7777
"""Send a notification to the peer on the back-channel."""
78-
await self._dctx.notify(method, params)
78+
await self._dctx.notify(method, params, opts)
7979

8080
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
8181
"""Report progress for this request, if the peer supplied a progress token.

‎src/mcp/shared/direct_dispatcher.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class _DirectDispatchContext:
6363
def can_send_request(self) -> bool:
6464
return self.transport.can_send_request
6565

66-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
66+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
6767
await self._back_notify(method, params)
6868

6969
async def send_raw_request(
@@ -133,12 +133,13 @@ async def send_raw_request(
133133
raise RuntimeError("DirectDispatcher.send_raw_request called before run()")
134134
return await self._peer._dispatch_request(method, params, opts)
135135

136-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
136+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
137137
"""Send a notification by invoking the peer's `on_notify` directly.
138138
139139
Fire-and-forget: usable before `run()` (delivery waits for the peer to
140140
start), and after close it is silently dropped, matching
141-
`JSONRPCDispatcher.notify`.
141+
`JSONRPCDispatcher.notify`. `opts` is accepted for `Dispatcher`
142+
conformance; there is no HTTP layer here so `headers` is ignored.
142143
"""
143144
if self._peer is None:
144145
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")

‎src/mcp/shared/dispatcher.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ class CallOptions(TypedDict, total=False):
8585
resumption is removed in the next protocol revision.
8686
"""
8787

88+
headers: dict[str, str]
89+
"""Transport-layer hint: HTTP transports merge these onto the outgoing request; non-HTTP transports ignore."""
90+
8891

8992
@runtime_checkable
9093
class Outbound(Protocol):
@@ -111,7 +114,7 @@ async def send_raw_request(
111114
"""
112115
...
113116

114-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
117+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
115118
"""Send a fire-and-forget notification."""
116119
...
117120

‎src/mcp/shared/inbound.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
status.
99
"""
1010

11+
import base64
12+
import re
1113
from collections.abc import Mapping, Sequence
1214
from dataclasses import dataclass
1315
from types import MappingProxyType
@@ -34,13 +36,33 @@
3436
"ERROR_CODE_HTTP_STATUS",
3537
"InboundLadderRejection",
3638
"InboundModernRoute",
39+
"MCP_METHOD_HEADER",
40+
"MCP_NAME_HEADER",
3741
"MCP_PROTOCOL_VERSION_HEADER",
3842
"classify_inbound_request",
43+
"encode_header_value",
3944
]
4045

4146
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
4247
"""Canonical lowercase name of the HTTP header carrying the MCP protocol version."""
4348

49+
MCP_METHOD_HEADER: Final = "mcp-method"
50+
"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method."""
51+
52+
MCP_NAME_HEADER: Final = "mcp-name"
53+
"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI)."""
54+
55+
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
56+
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
57+
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
58+
59+
60+
def encode_header_value(value: str) -> str:
61+
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
62+
return value
63+
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
64+
65+
4466
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
4567
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
4668
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,11 @@ def request_id(self) -> RequestId | None:
132132
def can_send_request(self) -> bool:
133133
return self.transport.can_send_request and not self._closed
134134

135-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
135+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
136136
if self._closed:
137137
logger.debug("dropped %s: dispatch context closed", method)
138138
return
139-
await self._dispatcher.notify(method, params, _related_request_id=self._request_id)
139+
await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id)
140140

141141
async def send_raw_request(
142142
self,
@@ -209,6 +209,7 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non
209209
cancel_on_abandon = opts.get("cancel_on_abandon", True)
210210
token = opts.get("resumption_token")
211211
on_token = opts.get("on_resumption_token")
212+
headers = opts.get("headers")
212213
if related_request_id is not None:
213214
if token is not None or on_token is not None:
214215
logger.debug(
@@ -217,9 +218,11 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non
217218
return _OutboundPlan(ServerMessageMetadata(related_request_id=related_request_id), cancel_on_abandon)
218219
if token is not None or on_token is not None:
219220
return _OutboundPlan(
220-
ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token),
221+
ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token, headers=headers),
221222
cancel_on_abandon=False,
222223
)
224+
if headers:
225+
return _OutboundPlan(ClientMessageMetadata(headers=headers), cancel_on_abandon)
223226
return _OutboundPlan(None, cancel_on_abandon)
224227

225228

@@ -395,6 +398,7 @@ async def notify(
395398
self,
396399
method: str,
397400
params: Mapping[str, Any] | None,
401+
opts: CallOptions | None = None,
398402
*,
399403
_related_request_id: RequestId | None = None,
400404
) -> None:
@@ -414,7 +418,7 @@ async def notify(
414418
else:
415419
msg = JSONRPCNotification(jsonrpc="2.0", method=method)
416420
try:
417-
await self._write(msg, _plan_outbound(_related_request_id, None).metadata)
421+
await self._write(msg, _plan_outbound(_related_request_id, opts).metadata)
418422
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
419423
# Transport tore down before run() noticed EOF.
420424
logger.debug("dropped %s: write stream closed", method)

‎src/mcp/shared/message.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class ClientMessageMetadata:
2424

2525
resumption_token: ResumptionToken | None = None
2626
on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None
27+
# Per-message HTTP headers (e.g. MCP-Protocol-Version, Mcp-Method) the transport should set.
28+
headers: dict[str, str] | None = None
2729

2830

2931
@dataclass

‎src/mcp/shared/peer.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ async def send_raw_request(
8181
) -> dict[str, Any]:
8282
return await self._outbound.send_raw_request(method, params, opts)
8383

84-
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
85-
await self._outbound.notify(method, params)
84+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
85+
await self._outbound.notify(method, params, opts)
8686

8787
@overload
8888
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)

0 commit comments

Comments
 (0)