Skip to content

Commit 0e0b746

Browse files
committed
Propagate W3C trace context via _meta per SEP-414
Inject traceparent/tracestate into request _meta on send, extract it on the server side as the parent context for the handler span. This enables cross-process trace propagation over stdio and HTTP transports.
1 parent e2d4b03 commit 0e0b746

File tree

4 files changed

+31
-6
lines changed

4 files changed

+31
-6
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async def main():
6565
from mcp.server.streamable_http import EventStore
6666
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
6767
from mcp.server.transport_security import TransportSecuritySettings
68-
from mcp.shared._otel import otel_span
68+
from mcp.shared._otel import extract_trace_context, otel_span
6969
from mcp.shared._stream_protocols import ReadStream, WriteStream
7070
from mcp.shared.exceptions import MCPError
7171
from mcp.shared.message import ServerMessageMetadata, SessionMessage
@@ -450,10 +450,15 @@ async def _handle_request(
450450
target = getattr(req.params, "name", None) if req.params else None
451451
span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}"
452452

453+
# Extract W3C trace context from _meta (SEP-414).
454+
meta = getattr(req.params, "meta", None) if req.params else None
455+
parent_context = extract_trace_context(meta) if isinstance(meta, dict) else None
456+
453457
with otel_span(
454458
span_name,
455459
kind="SERVER",
456460
attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id},
461+
context=parent_context,
457462
):
458463
if handler := self._request_handlers.get(req.method):
459464
logger.debug("Dispatching request of type %s", type(req).__name__)
@@ -474,7 +479,7 @@ async def _handle_request(
474479
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
475480
# Get task metadata from request params if present
476481
task_metadata = None
477-
if hasattr(req, "params") and req.params is not None:
482+
if hasattr(req, "params") and req.params is not None: # pragma: no branch
478483
task_metadata = getattr(req.params, "task", None)
479484
ctx = ServerRequestContext(
480485
request_id=message.request_id,

src/mcp/shared/_otel.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from contextlib import contextmanager
77
from typing import Any
88

9+
from opentelemetry.context import Context
10+
from opentelemetry.propagate import extract, inject
911
from opentelemetry.trace import SpanKind, get_tracer
1012

1113
_tracer = get_tracer("mcp-python-sdk")
@@ -17,8 +19,19 @@ def otel_span(
1719
*,
1820
kind: str = "INTERNAL",
1921
attributes: dict[str, Any] | None = None,
22+
context: Context | None = None,
2023
) -> Iterator[Any]:
2124
"""Create an OTel span."""
2225
span_kind = getattr(SpanKind, kind, SpanKind.INTERNAL)
23-
with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes) as span:
26+
with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes, context=context) as span:
2427
yield span
28+
29+
30+
def inject_trace_context(meta: Any) -> None:
31+
"""Inject W3C trace context (traceparent/tracestate) into a ``_meta`` dict."""
32+
inject(meta)
33+
34+
35+
def extract_trace_context(meta: Any) -> Context:
36+
"""Extract W3C trace context from a ``_meta`` dict."""
37+
return extract(meta)

src/mcp/shared/session.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic import BaseModel, TypeAdapter
1313
from typing_extensions import Self
1414

15-
from mcp.shared._otel import otel_span
15+
from mcp.shared._otel import inject_trace_context, otel_span
1616
from mcp.shared._stream_protocols import ReadStream, WriteStream
1717
from mcp.shared.exceptions import MCPError
1818
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
@@ -269,8 +269,6 @@ async def send_request(
269269
self._progress_callbacks[request_id] = progress_callback
270270

271271
try:
272-
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
273-
274272
target = request_data.get("params", {}).get("name")
275273
span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}"
276274

@@ -279,6 +277,14 @@ async def send_request(
279277
kind="CLIENT",
280278
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id},
281279
):
280+
# Inject W3C trace context into _meta (SEP-414).
281+
if "params" not in request_data:
282+
request_data["params"] = {}
283+
if "_meta" not in request_data["params"]:
284+
request_data["params"]["_meta"] = {}
285+
inject_trace_context(request_data["params"]["_meta"])
286+
287+
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
282288
await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata))
283289

284290
# request read timeout takes precedence over session read timeout

tests/shared/test_otel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
pytestmark = pytest.mark.anyio
1010

1111

12+
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
1213
async def test_client_and_server_spans(capfire: CaptureLogfire):
1314
"""Verify that calling a tool produces client and server spans with correct attributes."""
1415
server = MCPServer("test")

0 commit comments

Comments
 (0)