diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 9b1037322..c611e57bb 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -30,7 +30,7 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared._otel import extract_trace_context, otel_span +from mcp.shared._otel import build_span_attributes, extract_trace_context, otel_span from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest from mcp.shared.exceptions import MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher @@ -91,10 +91,10 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: def otel_middleware(next_on_request: OnRequest) -> OnRequest: """Dispatch-tier middleware that wraps each request in an OpenTelemetry span. - Mirrors the span shape of the existing `Server._handle_request`: span name - `"MCP handle []"`, `mcp.method.name` attribute, W3C - trace context extracted from `params._meta` (SEP-414), and an ERROR - status if the handler raises. + Span name: `"MCP handle []"`. Attributes follow the GenAI + semconv for MCP: `gen_ai.operation.name`, `gen_ai.tool.name`, `rpc.system`, + `mcp.method.name`, `jsonrpc.request.id`. W3C trace context is extracted from + `params._meta` (SEP-414) to parent the span under the client span. """ async def wrapped( @@ -114,7 +114,7 @@ async def wrapped( parent = None span_name = f"MCP handle {method}{f' {target}' if target else ''}" # `otel_middleware` wraps `on_request` only, so `request_id` is always set. - attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)} + attributes = build_span_attributes(method, dctx.request_id, params=params) with otel_span( span_name, kind=SpanKind.SERVER, diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 9b2002419..d5703660a 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from contextlib import contextmanager -from typing import Any +from typing import Any, cast from opentelemetry.context import Context from opentelemetry.propagate import extract, inject @@ -13,6 +13,53 @@ _tracer = get_tracer("mcp-python-sdk") +def build_span_attributes( + method: str, + request_id: Any, + *, + params: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build OTel span attributes for an MCP request. + + Produces the base set of semantic convention attributes shared by both + client (`SpanKind.CLIENT`) and server (`SpanKind.SERVER`) spans. + + Per the GenAI MCP semconv spec, `gen_ai.operation.name` SHOULD be set to + `execute_tool` for `tools/call` and SHOULD NOT be set for other methods. + https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md + """ + attrs: dict[str, Any] = { + "rpc.system": "mcp", + "mcp.method.name": method, + "jsonrpc.request.id": str(request_id), + } + + if params is not None: + if method == "tools/call": + # gen_ai.operation.name SHOULD be set to execute_tool for tools/call only. + attrs["gen_ai.operation.name"] = "execute_tool" + name = params.get("name") + if isinstance(name, str): + attrs["gen_ai.tool.name"] = name + + elif method == "prompts/get": + name = params.get("name") + if isinstance(name, str): + attrs["gen_ai.prompt.name"] = name + + # mcp.resource.uri — resources/read, resources/subscribe, resources/unsubscribe, + # notifications/resources/updated, and completion/complete via ref.uri + uri: Any = params.get("uri") + if uri is None: + ref = params.get("ref") + if isinstance(ref, dict): + uri = cast(dict[str, Any], ref).get("uri") + if uri is not None: + attrs["mcp.resource.uri"] = str(uri) + + return attrs + + @contextmanager def otel_span( name: str, diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 457e6b6f7..4d29f490f 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -32,7 +32,7 @@ from opentelemetry.trace import SpanKind from pydantic import ValidationError -from mcp.shared._otel import inject_trace_context, otel_span +from mcp.shared._otel import build_span_attributes, inject_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.dispatcher import CallOptions, Dispatcher, OnNotify, OnRequest, ProgressFnT from mcp.shared.exceptions import MCPError, NoBackChannelError @@ -334,7 +334,7 @@ async def send_raw_request( with otel_span( span_name, kind=SpanKind.CLIENT, - attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)}, + attributes=build_span_attributes(method, request_id, params=out_params), ): # Inject W3C trace context into _meta (SEP-414). With a no-op # tracer this writes nothing, but `_meta` itself is still diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 61279ad8b..8fdb04db9 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -13,7 +13,7 @@ from typing_extensions import Self from mcp.shared._compat import resync_tracer -from mcp.shared._otel import inject_trace_context, otel_span +from mcp.shared._otel import build_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 @@ -219,7 +219,11 @@ async def send_request( with otel_span( span_name, kind=SpanKind.CLIENT, - attributes={"mcp.method.name": request.method, "jsonrpc.request.id": str(request_id)}, + attributes=build_span_attributes( + request.method, + 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", {}) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index a7df4c429..77e202b11 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -6,10 +6,36 @@ from mcp import types from mcp.client.client import Client from mcp.server.mcpserver import MCPServer +from mcp.shared._otel import build_span_attributes pytestmark = pytest.mark.anyio +def test_build_span_attributes_ref_uri() -> None: + """build_span_attributes extracts mcp.resource.uri from nested ref.uri.""" + attrs = build_span_attributes( + "completion/complete", + "1", + params={"ref": {"uri": "test://doc"}}, + ) + assert attrs["mcp.resource.uri"] == "test://doc" + assert "gen_ai.operation.name" not in attrs + + +def test_build_span_attributes_tools_call_no_name() -> None: + """tools/call without a name param omits gen_ai.tool.name.""" + attrs = build_span_attributes("tools/call", "1", params={}) + assert attrs["gen_ai.operation.name"] == "execute_tool" + assert "gen_ai.tool.name" not in attrs + + +def test_build_span_attributes_prompts_get_no_name() -> None: + """prompts/get without a name param omits gen_ai.prompt.name.""" + attrs = build_span_attributes("prompts/get", "1", params={}) + assert "gen_ai.prompt.name" not in attrs + assert "gen_ai.operation.name" not in attrs + + async def test_client_and_server_spans(capfire: CaptureLogfire): """Verify that calling a tool produces client and server spans with correct attributes.""" server = MCPServer("test") @@ -34,8 +60,90 @@ 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") + # Base RPC + MCP attributes + assert client_span["attributes"]["rpc.system"] == "mcp" assert client_span["attributes"]["mcp.method.name"] == "tools/call" + assert client_span["attributes"]["jsonrpc.request.id"] is not None + assert server_span["attributes"]["rpc.system"] == "mcp" assert server_span["attributes"]["mcp.method.name"] == "tools/call" + # GenAI semconv attributes — execute_tool only on tools/call + assert client_span["attributes"]["gen_ai.operation.name"] == "execute_tool" + assert client_span["attributes"]["gen_ai.tool.name"] == "greet" + assert server_span["attributes"]["gen_ai.operation.name"] == "execute_tool" + assert server_span["attributes"]["gen_ai.tool.name"] == "greet" + # 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"] + + +async def test_list_tools_spans(capfire: CaptureLogfire): + """Verify that listing tools produces spans without gen_ai.operation.name.""" + server = MCPServer("test") + + async with Client(server) as client: + await client.list_tools() + + spans = capfire.exporter.exported_spans_as_dict() + + client_span = next(s for s in spans if s["name"] == "MCP send tools/list") + server_span = next(s for s in spans if s["name"] == "MCP handle tools/list") + + # gen_ai.operation.name SHOULD NOT be set for non-tool-call methods per spec + assert "gen_ai.operation.name" not in client_span["attributes"] + assert "gen_ai.operation.name" not in server_span["attributes"] + assert "gen_ai.tool.name" not in client_span["attributes"] + assert "gen_ai.tool.name" not in server_span["attributes"] + + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] + + +async def test_resource_read_spans(capfire: CaptureLogfire): + """Verify that reading a resource produces spans with resource URI.""" + server = MCPServer("test") + + @server.resource("test://greeting") + def greeting() -> str: + return "hello" + + async with Client(server) as client: + await client.read_resource("test://greeting") + + 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"]["mcp.resource.uri"] == "test://greeting" + assert server_span["attributes"]["mcp.resource.uri"] == "test://greeting" + # gen_ai.operation.name SHOULD NOT be set for resources/read per spec + assert "gen_ai.operation.name" not in client_span["attributes"] + assert "gen_ai.operation.name" not in server_span["attributes"] + + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] + + +async def test_prompt_get_spans(capfire: CaptureLogfire): + """Verify that getting a prompt produces spans with gen_ai.prompt.name.""" + server = MCPServer("test") + + @server.prompt() + def summarize() -> str: + """Summarize text.""" + return "Summarize the following: " + + async with Client(server) as client: + await client.get_prompt("summarize", {}) + + spans = capfire.exporter.exported_spans_as_dict() + + client_span = next(s for s in spans if s["name"] == "MCP send prompts/get summarize") + server_span = next(s for s in spans if s["name"] == "MCP handle prompts/get summarize") + + assert client_span["attributes"]["gen_ai.prompt.name"] == "summarize" + assert server_span["attributes"]["gen_ai.prompt.name"] == "summarize" + # gen_ai.operation.name SHOULD NOT be set for prompts/get per spec + assert "gen_ai.operation.name" not in client_span["attributes"] + assert "gen_ai.operation.name" not in server_span["attributes"] + + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]