Skip to content

Commit 7234f0e

Browse files
committed
feat: DispatchContext.message_metadata passes SessionMessage.metadata through verbatim
Scaffolding for the swap: ServerRunner._make_context will read this to populate ServerRequestContext.request / close_sse_stream / etc. the same way the current Server._handle_request does. Marked TODO(maxisbey): remove for Context rework — the redesign replaces this with the per-transport context shape.
1 parent 47989e7 commit 7234f0e

5 files changed

Lines changed: 110 additions & 2 deletions

File tree

src/mcp/shared/direct_dispatcher.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT
2626
from mcp.shared.exceptions import MCPError, NoBackChannelError
27+
from mcp.shared.message import MessageMetadata
2728
from mcp.shared.transport_context import TransportContext
2829
from mcp.types import INTERNAL_ERROR, REQUEST_TIMEOUT
2930

@@ -47,6 +48,8 @@ class _DirectDispatchContext:
4748
transport: TransportContext
4849
_back_request: _Request
4950
_back_notify: _Notify
51+
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
52+
"""Always ``None``: in-memory dispatch attaches no transport metadata."""
5053
_on_progress: ProgressFnT | None = None
5154
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
5255

src/mcp/shared/dispatcher.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import anyio
2323
import anyio.abc
2424

25+
from mcp.shared.message import MessageMetadata
2526
from mcp.shared.transport_context import TransportContext
2627

2728
__all__ = [
@@ -107,6 +108,19 @@ def transport(self) -> TransportT_co:
107108
"""Transport-specific metadata for this inbound message."""
108109
...
109110

111+
@property
112+
def message_metadata(self) -> MessageMetadata:
113+
"""The metadata the transport attached to this inbound message, if any.
114+
115+
This is `SessionMessage.metadata` passed through verbatim: HTTP
116+
transports attach `ServerMessageMetadata` (the HTTP request, SSE
117+
stream-close callbacks); stdio and in-memory dispatch attach nothing.
118+
Tied to the `SessionMessage` wire format — goes away when transports
119+
stop delivering messages that way.
120+
"""
121+
# TODO(maxisbey): remove for context rework
122+
...
123+
110124
@property
111125
def cancel_requested(self) -> anyio.Event:
112126
"""Set when the peer sends ``notifications/cancelled`` for this request."""

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
116116
transport: TransportT
117117
_dispatcher: JSONRPCDispatcher[TransportT]
118118
_request_id: RequestId | None
119+
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
120+
"""The transport-attached `SessionMessage.metadata` for this inbound message.
121+
122+
Carries `ServerMessageMetadata` (HTTP request, SSE stream-close callbacks)
123+
that the server lifts onto its request context. ``None`` for transports
124+
that attach nothing.
125+
"""
119126
_progress_token: ProgressToken | None = None
120127
_closed: bool = False
121128
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
@@ -398,6 +405,7 @@ def _dispatch_request(
398405
transport=transport_ctx,
399406
_dispatcher=self,
400407
_request_id=req.id,
408+
message_metadata=metadata,
401409
_progress_token=progress_token,
402410
)
403411
scope = anyio.CancelScope()
@@ -439,7 +447,9 @@ def _dispatch_notification(
439447
pass
440448
# fall through: progress is also teed to on_notify
441449
transport_ctx = self._transport_builder(None, metadata)
442-
dctx = _JSONRPCDispatchContext(transport=transport_ctx, _dispatcher=self, _request_id=None)
450+
dctx = _JSONRPCDispatchContext(
451+
transport=transport_ctx, _dispatcher=self, _request_id=None, message_metadata=metadata
452+
)
443453
self._spawn(on_notify, dctx, msg.method, msg.params, sender_ctx=sender_ctx)
444454

445455
def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None:

tests/shared/test_dispatcher.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,16 @@ async def server_on_request(
196196
assert result == {"ok": True}
197197

198198

199+
@pytest.mark.anyio
200+
async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair_factory: PairFactory):
201+
"""Plain requests carry no transport metadata, so handlers see ``None``."""
202+
async with running_pair(pair_factory) as (client, _server, _crec, srec):
203+
with anyio.fail_after(5):
204+
await client.send_raw_request("tools/call", None)
205+
assert len(srec.contexts) == 1
206+
assert srec.contexts[0].message_metadata is None
207+
208+
199209
@pytest.mark.anyio
200210
async def test_direct_send_raw_request_wraps_non_mcperror_exception_as_internal_error_with_cause():
201211
"""DirectDispatcher-specific: the original exception is chained via __cause__."""

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
_outbound_metadata,
2323
_Pending,
2424
)
25-
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
25+
from mcp.shared.message import ClientMessageMetadata, MessageMetadata, ServerMessageMetadata, SessionMessage
2626
from mcp.shared.transport_context import TransportContext
2727
from mcp.types import (
2828
CONNECTION_CLOSED,
@@ -274,6 +274,77 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
274274
s.close()
275275

276276

277+
@pytest.mark.anyio
278+
async def test_ctx_message_metadata_carries_inbound_request_metadata():
279+
"""Transport-attached metadata (HTTP request, SSE close hooks) is readable off the dispatch context."""
280+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
281+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
282+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
283+
metadata = ServerMessageMetadata(request_context="request-scoped-data")
284+
seen: list[MessageMetadata] = []
285+
286+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
287+
seen.append(ctx.message_metadata)
288+
return {}
289+
290+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
291+
raise NotImplementedError
292+
293+
try:
294+
async with anyio.create_task_group() as tg:
295+
await tg.start(server.run, on_request, on_notify)
296+
await c2s_send.send(
297+
SessionMessage(
298+
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params=None),
299+
metadata=metadata,
300+
)
301+
)
302+
with anyio.fail_after(5):
303+
await s2c_recv.receive() # response sent ⇒ the handler has run
304+
tg.cancel_scope.cancel()
305+
finally:
306+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
307+
s.close()
308+
assert len(seen) == 1
309+
assert seen[0] is metadata # the exact object, passed through verbatim
310+
311+
312+
@pytest.mark.anyio
313+
async def test_ctx_message_metadata_carries_inbound_notification_metadata():
314+
"""Notifications get the same metadata pass-through as requests."""
315+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
316+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
317+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
318+
metadata = ServerMessageMetadata(request_context="request-scoped-data")
319+
seen: list[MessageMetadata] = []
320+
notified = anyio.Event()
321+
322+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
323+
raise NotImplementedError
324+
325+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
326+
seen.append(ctx.message_metadata)
327+
notified.set()
328+
329+
try:
330+
async with anyio.create_task_group() as tg:
331+
await tg.start(server.run, on_request, on_notify)
332+
await c2s_send.send(
333+
SessionMessage(
334+
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized", params=None),
335+
metadata=metadata,
336+
)
337+
)
338+
with anyio.fail_after(5):
339+
await notified.wait()
340+
tg.cancel_scope.cancel()
341+
finally:
342+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
343+
s.close()
344+
assert len(seen) == 1
345+
assert seen[0] is metadata
346+
347+
277348
@pytest.mark.anyio
278349
async def test_ctx_progress_with_only_progress_value_omits_total_and_message():
279350
received: list[tuple[float, float | None, str | None]] = []

0 commit comments

Comments
 (0)