Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
35 changes: 23 additions & 12 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async def main():
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,38 @@ 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
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
request_headers = getattr(request_data, "headers", None)
session_id = request_headers.get(MCP_SESSION_ID_HEADER) if request_headers is not None else None
span_attributes: dict[str, Any] = {
"rpc.system": "mcp",
"rpc.service": self.name,
"rpc.method": req.method,
"mcp.method.name": req.method,
"jsonrpc.request.id": message.request_id,
}
resource_uri = getattr(req.params, "uri", None)
if resource_uri is not None:
span_attributes["mcp.resource.uri"] = str(resource_uri)
if session_id is not None:
span_attributes["mcp.session.id"] = session_id

with otel_span(
span_name,
kind=SpanKind.SERVER,
attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id},
attributes=span_attributes,
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
10 changes: 9 additions & 1 deletion src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,19 @@ async def send_request(
try:
target = request_data.get("params", {}).get("name")
span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}"
span_attributes: dict[str, Any] = {
"rpc.system": "mcp",
"rpc.method": request.method,
"mcp.method.name": request.method,
"jsonrpc.request.id": request_id,
}
if (resource_uri := request_data.get("params", {}).get("uri")) is not None:
span_attributes["mcp.resource.uri"] = resource_uri

with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id},
attributes=span_attributes,
):
# 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 @@
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"

Check warning on line 80 in tests/shared/test_otel.py

View check run for this annotation

Claude / Claude Code Review

mcp.session.id span attribute is untested; promised test does not exist

The PR description instructs running pytest with a filter for resource_read_spans_include_session_id, but no such test exists anywhere in the test suite. As a result, the mcp.session.id span attribute added in _handle_request has zero test coverage: all new tests use the in-process Client(server) transport where message.message_metadata carries no HTTP request context, so session_id is always None and the attribute-setting branch is never exercised. A regression in the header extraction logic wo
Loading