From 811b7ab77cc9b4a14589ebc9f99f042d7083f31c Mon Sep 17 00:00:00 2001 From: etserend Date: Fri, 12 Jun 2026 14:29:57 -0500 Subject: [PATCH 1/4] Add GenAI semconv attributes to native OTel spans --- src/mcp/server/runner.py | 12 +++--- src/mcp/shared/_otel.py | 60 ++++++++++++++++++++++++++++ src/mcp/shared/jsonrpc_dispatcher.py | 4 +- src/mcp/shared/session.py | 8 +++- tests/shared/test_otel.py | 60 ++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 9b10373229..c611e57bb4 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 9b20024194..b1e02a59e4 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -12,6 +12,66 @@ _tracer = get_tracer("mcp-python-sdk") +# Maps MCP JSON-RPC method names to GenAI semantic convention operation names. +# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md +_METHOD_TO_GEN_AI_OPERATION: dict[str, str] = { + "tools/call": "execute_tool", + "tools/list": "list_tools", + "resources/read": "read_resource", + "resources/list": "list_resources", + "resources/templates/list": "list_resources", + "prompts/get": "get_prompt", + "prompts/list": "list_prompts", +} + + +def build_span_attributes( + method: str, + request_id: Any, + *, + params: dict[str, Any] | None = None, + server_name: str | None = None, + session_id: str | 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. + Pass `server_name` and `session_id` for server-side spans. + """ + attrs: dict[str, Any] = { + "rpc.system": "mcp", + "mcp.method.name": method, + "jsonrpc.request.id": str(request_id), + } + + operation = _METHOD_TO_GEN_AI_OPERATION.get(method) + if operation is not None: + attrs["gen_ai.operation.name"] = operation + + if server_name is not None: + attrs["rpc.service"] = server_name + + if params is not None: + # gen_ai.tool.name — present on tools/call, prompts/get + name = params.get("name") + if isinstance(name, str): + attrs["gen_ai.tool.name"] = name + + # mcp.resource.uri — present on resources/read; also on completion/complete via ref.uri + uri = params.get("uri") + if uri is None: + ref = params.get("ref") + if isinstance(ref, dict): + uri = ref.get("uri") + if uri is not None: + attrs["mcp.resource.uri"] = str(uri) + + if session_id is not None: + attrs["mcp.session.id"] = session_id + + return attrs + @contextmanager def otel_span( diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 457e6b6f77..4d29f490f9 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 61279ad8b8..8fdb04db95 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 a7df4c4294..f57cd1f64d 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -34,8 +34,68 @@ 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 + 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 with list_tools operation.""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + 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") + + assert client_span["attributes"]["gen_ai.operation.name"] == "list_tools" + assert server_span["attributes"]["gen_ai.operation.name"] == "list_tools" + # No tool name on list — no specific tool targeted + 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"]["gen_ai.operation.name"] == "read_resource" + assert client_span["attributes"]["mcp.resource.uri"] == "test://greeting" + assert server_span["attributes"]["gen_ai.operation.name"] == "read_resource" + assert server_span["attributes"]["mcp.resource.uri"] == "test://greeting" + + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] From 6930f928c5b8a8c9c9e62e41b65d65870bdbc55b Mon Sep 17 00:00:00 2001 From: etserend Date: Fri, 12 Jun 2026 15:35:37 -0500 Subject: [PATCH 2/4] Fix pyright type errors and coverage gaps in OTel span attributes --- src/mcp/shared/_otel.py | 19 +++++-------------- tests/shared/test_otel.py | 16 +++++++++++----- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index b1e02a59e4..ac63f9a21c 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 @@ -29,15 +29,12 @@ def build_span_attributes( method: str, request_id: Any, *, - params: dict[str, Any] | None = None, - server_name: str | None = None, - session_id: str | None = None, + 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. - Pass `server_name` and `session_id` for server-side spans. """ attrs: dict[str, Any] = { "rpc.system": "mcp", @@ -49,9 +46,6 @@ def build_span_attributes( if operation is not None: attrs["gen_ai.operation.name"] = operation - if server_name is not None: - attrs["rpc.service"] = server_name - if params is not None: # gen_ai.tool.name — present on tools/call, prompts/get name = params.get("name") @@ -59,17 +53,14 @@ def build_span_attributes( attrs["gen_ai.tool.name"] = name # mcp.resource.uri — present on resources/read; also on completion/complete via ref.uri - uri = params.get("uri") + uri: Any = params.get("uri") if uri is None: ref = params.get("ref") if isinstance(ref, dict): - uri = ref.get("uri") + uri = cast(dict[str, Any], ref).get("uri") if uri is not None: attrs["mcp.resource.uri"] = str(uri) - if session_id is not None: - attrs["mcp.session.id"] = session_id - return attrs diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index f57cd1f64d..f7c387e1d7 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -6,10 +6,21 @@ 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" + + 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") @@ -55,11 +66,6 @@ async def test_list_tools_spans(capfire: CaptureLogfire): """Verify that listing tools produces spans with list_tools operation.""" server = MCPServer("test") - @server.tool() - def greet(name: str) -> str: - """Greet someone.""" - return f"Hello, {name}!" - async with Client(server) as client: await client.list_tools() From 96e05bcbc6c14b9558c6156f3d220b8f3b1a1b2d Mon Sep 17 00:00:00 2001 From: etserend Date: Fri, 12 Jun 2026 21:48:17 -0500 Subject: [PATCH 3/4] Address review: restrict gen_ai.operation.name to tools/call per spec, add gen_ai.prompt.name for prompts/get --- src/mcp/shared/_otel.py | 41 +++++++++++++--------------- tests/shared/test_otel.py | 56 ++++++++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index ac63f9a21c..54754c823a 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -12,19 +12,6 @@ _tracer = get_tracer("mcp-python-sdk") -# Maps MCP JSON-RPC method names to GenAI semantic convention operation names. -# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md -_METHOD_TO_GEN_AI_OPERATION: dict[str, str] = { - "tools/call": "execute_tool", - "tools/list": "list_tools", - "resources/read": "read_resource", - "resources/list": "list_resources", - "resources/templates/list": "list_resources", - "prompts/get": "get_prompt", - "prompts/list": "list_prompts", -} - - def build_span_attributes( method: str, request_id: Any, @@ -35,6 +22,10 @@ def build_span_attributes( 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", @@ -42,17 +33,21 @@ def build_span_attributes( "jsonrpc.request.id": str(request_id), } - operation = _METHOD_TO_GEN_AI_OPERATION.get(method) - if operation is not None: - attrs["gen_ai.operation.name"] = operation - if params is not None: - # gen_ai.tool.name — present on tools/call, prompts/get - name = params.get("name") - if isinstance(name, str): - attrs["gen_ai.tool.name"] = name - - # mcp.resource.uri — present on resources/read; also on completion/complete via ref.uri + 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") diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index f7c387e1d7..77e202b115 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -19,6 +19,21 @@ def test_build_span_attributes_ref_uri() -> None: 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): @@ -52,7 +67,7 @@ def greet(name: str) -> str: assert server_span["attributes"]["rpc.system"] == "mcp" assert server_span["attributes"]["mcp.method.name"] == "tools/call" - # GenAI semconv attributes + # 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" @@ -63,7 +78,7 @@ def greet(name: str) -> str: async def test_list_tools_spans(capfire: CaptureLogfire): - """Verify that listing tools produces spans with list_tools operation.""" + """Verify that listing tools produces spans without gen_ai.operation.name.""" server = MCPServer("test") async with Client(server) as client: @@ -74,9 +89,9 @@ async def test_list_tools_spans(capfire: CaptureLogfire): 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") - assert client_span["attributes"]["gen_ai.operation.name"] == "list_tools" - assert server_span["attributes"]["gen_ai.operation.name"] == "list_tools" - # No tool name on list — no specific tool targeted + # 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"] @@ -99,9 +114,36 @@ def greeting() -> str: 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"]["gen_ai.operation.name"] == "read_resource" assert client_span["attributes"]["mcp.resource.uri"] == "test://greeting" - assert server_span["attributes"]["gen_ai.operation.name"] == "read_resource" 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"] From e038fe218bdf86bcd5ea42c4f5c28427f3358ab0 Mon Sep 17 00:00:00 2001 From: etserend Date: Fri, 12 Jun 2026 21:57:20 -0500 Subject: [PATCH 4/4] Fix ruff formatting: add blank line before build_span_attributes --- src/mcp/shared/_otel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 54754c823a..d5703660ab 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -12,6 +12,7 @@ _tracer = get_tracer("mcp-python-sdk") + def build_span_attributes( method: str, request_id: Any,