Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
33 changes: 20 additions & 13 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@ async def main():
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared._otel import build_server_span_attributes, extract_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder

logger = logging.getLogger(__name__)
MCP_SESSION_ID_HEADER = "mcp-session-id"

LifespanResultT = TypeVar("LifespanResultT", default=Any)

Expand Down Expand Up @@ -454,28 +455,34 @@ async def _handle_request(
# Extract W3C trace context from _meta (SEP-414).
meta = cast(dict[str, Any] | None, getattr(req.params, "meta", None)) if req.params else None
parent_context = extract_trace_context(meta) if meta is not None else None
server_message_metadata = (
message.message_metadata if isinstance(message.message_metadata, ServerMessageMetadata) else None
)
request_data = server_message_metadata.request_context if server_message_metadata is not None else None
close_sse_stream_cb = None
close_standalone_sse_stream_cb = None
if server_message_metadata is not None:
close_sse_stream_cb = server_message_metadata.close_sse_stream
close_standalone_sse_stream_cb = server_message_metadata.close_standalone_sse_stream
request_headers = getattr(request_data, "headers", None)
session_id = request_headers.get(MCP_SESSION_ID_HEADER) if request_headers is not None else None

with otel_span(
span_name,
kind=SpanKind.SERVER,
attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id},
attributes=build_server_span_attributes(
service_name=self.name,
method=req.method,
request_id=message.request_id,
params=req.params,
session_id=session_id,
),
context=parent_context,
) as span:
if handler := self._request_handlers.get(req.method):
logger.debug("Dispatching request of type %s", type(req).__name__)

try:
# Extract request context and close_sse_stream from message metadata
request_data = None
close_sse_stream_cb = None
close_standalone_sse_stream_cb = None
if message.message_metadata is not None and isinstance(
message.message_metadata, ServerMessageMetadata
):
request_data = message.message_metadata.request_context
close_sse_stream_cb = message.message_metadata.close_sse_stream
close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream

client_capabilities = session.client_params.capabilities if session.client_params else None
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
# Get task metadata from request params if present
Expand Down
48 changes: 48 additions & 0 deletions src/mcp/shared/_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from opentelemetry.trace import SpanKind, get_tracer

_tracer = get_tracer("mcp-python-sdk")
MCP_RPC_SYSTEM = "mcp"


@contextmanager
Expand All @@ -34,3 +35,50 @@
def extract_trace_context(meta: dict[str, Any]) -> Context:
"""Extract W3C trace context from a `_meta` dict."""
return extract(meta)


def build_client_span_attributes(
*,
method: str,
request_id: str | int,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build OTel attributes for an MCP client request span."""
attributes: dict[str, Any] = {
"rpc.system": MCP_RPC_SYSTEM,
"rpc.method": method,
"mcp.method.name": method,
"jsonrpc.request.id": request_id,
}

if params is not None and (resource_uri := params.get("uri")) is not None:
attributes["mcp.resource.uri"] = resource_uri

return attributes

Check failure on line 57 in src/mcp/shared/_otel.py

View check run for this annotation

Claude / Claude Code Review

build_client_span_attributes missing mcp.session.id - client/server span asymmetry

build_client_span_attributes has no session_id parameter, so client spans can never carry the mcp.session.id attribute while server spans for the same session can — creating an observability asymmetry. The PR description's claim that 'mcp.session.id is surfaced on the client side through the streamable HTTP write stream wrapper' is factually incorrect: commit 67ca7fe in this same branch explicitly removed that wrapper without adding a replacement. To fix this, add a session_id parameter to build


def build_server_span_attributes(
*,
service_name: str,
method: str,
request_id: str | int,
params: Any = None,
session_id: str | None = None,
) -> dict[str, Any]:
"""Build OTel attributes for an MCP server request span."""
attributes: dict[str, Any] = {
"rpc.system": MCP_RPC_SYSTEM,
"rpc.service": service_name,
"rpc.method": method,
"mcp.method.name": method,
"jsonrpc.request.id": request_id,
}

resource_uri = getattr(params, "uri", None)
if resource_uri is not None:
attributes["mcp.resource.uri"] = str(resource_uri)

if session_id is not None:
attributes["mcp.session.id"] = session_id

return attributes
8 changes: 6 additions & 2 deletions src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pydantic import BaseModel, TypeAdapter
from typing_extensions import Self

from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._otel import build_client_span_attributes, inject_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPError
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
Expand Down Expand Up @@ -276,7 +276,11 @@ async def send_request(
with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id},
attributes=build_client_span_attributes(
method=request.method,
request_id=request_id,
params=request_data.get("params"),
),
):
# Inject W3C trace context into _meta (SEP-414).
meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {})
Expand Down
36 changes: 36 additions & 0 deletions tests/shared/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,44 @@ def greet(name: str) -> str:
client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet")
server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet")

assert client_span["attributes"]["rpc.system"] == "mcp"
assert client_span["attributes"]["rpc.method"] == "tools/call"
assert client_span["attributes"]["mcp.method.name"] == "tools/call"
assert server_span["attributes"]["rpc.system"] == "mcp"
assert server_span["attributes"]["rpc.service"] == "test"
assert server_span["attributes"]["rpc.method"] == "tools/call"
assert server_span["attributes"]["mcp.method.name"] == "tools/call"

# Server span should be in the same trace as the client span (context propagation).
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]


@pytest.mark.filterwarnings("ignore::RuntimeWarning")
async def test_resource_read_spans_include_resource_uri(capfire: CaptureLogfire):
"""Verify that resource reads include MCP resource and RPC attributes."""
server = MCPServer("test")

@server.resource("test://resource")
def test_resource() -> str:
return "hello"

async with Client(server) as client:
result = await client.read_resource("test://resource")

assert result.contents[0].uri == "test://resource"

spans = capfire.exporter.exported_spans_as_dict()

client_span = next(s for s in spans if s["name"] == "MCP send resources/read")
server_span = next(s for s in spans if s["name"] == "MCP handle resources/read")

assert client_span["attributes"]["rpc.system"] == "mcp"
assert client_span["attributes"]["rpc.method"] == "resources/read"
assert client_span["attributes"]["mcp.method.name"] == "resources/read"
assert client_span["attributes"]["mcp.resource.uri"] == "test://resource"

assert server_span["attributes"]["rpc.system"] == "mcp"
assert server_span["attributes"]["rpc.service"] == "test"
assert server_span["attributes"]["rpc.method"] == "resources/read"
assert server_span["attributes"]["mcp.method.name"] == "resources/read"
assert server_span["attributes"]["mcp.resource.uri"] == "test://resource"
Loading