Skip to content

Commit 15ae21d

Browse files
committed
Add GenAI semconv attributes to native OTel spans
1 parent 7267818 commit 15ae21d

5 files changed

Lines changed: 134 additions & 10 deletions

File tree

src/mcp/server/runner.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
3131
from mcp.server.models import InitializationOptions
3232
from mcp.server.session import ServerSession
33-
from mcp.shared._otel import extract_trace_context, otel_span
33+
from mcp.shared._otel import build_span_attributes, extract_trace_context, otel_span
3434
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
3535
from mcp.shared.exceptions import MCPError
3636
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
@@ -91,10 +91,10 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
9191
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
9292
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
9393
94-
Mirrors the span shape of the existing `Server._handle_request`: span name
95-
`"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
96-
trace context extracted from `params._meta` (SEP-414), and an ERROR
97-
status if the handler raises.
94+
Span name: `"MCP handle <method> [<target>]"`. Attributes follow the GenAI
95+
semconv for MCP: `gen_ai.operation.name`, `gen_ai.tool.name`, `rpc.system`,
96+
`mcp.method.name`, `jsonrpc.request.id`. W3C trace context is extracted from
97+
`params._meta` (SEP-414) to parent the span under the client span.
9898
"""
9999

100100
async def wrapped(
@@ -114,7 +114,7 @@ async def wrapped(
114114
parent = None
115115
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
116116
# `otel_middleware` wraps `on_request` only, so `request_id` is always set.
117-
attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)}
117+
attributes = build_span_attributes(method, dctx.request_id, params=params)
118118
with otel_span(
119119
span_name,
120120
kind=SpanKind.SERVER,

src/mcp/shared/_otel.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,66 @@
1212

1313
_tracer = get_tracer("mcp-python-sdk")
1414

15+
# Maps MCP JSON-RPC method names to GenAI semantic convention operation names.
16+
# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md
17+
_METHOD_TO_GEN_AI_OPERATION: dict[str, str] = {
18+
"tools/call": "execute_tool",
19+
"tools/list": "list_tools",
20+
"resources/read": "read_resource",
21+
"resources/list": "list_resources",
22+
"resources/templates/list": "list_resources",
23+
"prompts/get": "get_prompt",
24+
"prompts/list": "list_prompts",
25+
}
26+
27+
28+
def build_span_attributes(
29+
method: str,
30+
request_id: Any,
31+
*,
32+
params: dict[str, Any] | None = None,
33+
server_name: str | None = None,
34+
session_id: str | None = None,
35+
) -> dict[str, Any]:
36+
"""Build OTel span attributes for an MCP request.
37+
38+
Produces the base set of semantic convention attributes shared by both
39+
client (`SpanKind.CLIENT`) and server (`SpanKind.SERVER`) spans.
40+
Pass `server_name` and `session_id` for server-side spans.
41+
"""
42+
attrs: dict[str, Any] = {
43+
"rpc.system": "mcp",
44+
"mcp.method.name": method,
45+
"jsonrpc.request.id": str(request_id),
46+
}
47+
48+
operation = _METHOD_TO_GEN_AI_OPERATION.get(method)
49+
if operation is not None:
50+
attrs["gen_ai.operation.name"] = operation
51+
52+
if server_name is not None:
53+
attrs["rpc.service"] = server_name
54+
55+
if params is not None:
56+
# gen_ai.tool.name — present on tools/call, prompts/get
57+
name = params.get("name")
58+
if isinstance(name, str):
59+
attrs["gen_ai.tool.name"] = name
60+
61+
# mcp.resource.uri — present on resources/read; also on completion/complete via ref.uri
62+
uri = params.get("uri")
63+
if uri is None:
64+
ref = params.get("ref")
65+
if isinstance(ref, dict):
66+
uri = ref.get("uri")
67+
if uri is not None:
68+
attrs["mcp.resource.uri"] = str(uri)
69+
70+
if session_id is not None:
71+
attrs["mcp.session.id"] = session_id
72+
73+
return attrs
74+
1575

1676
@contextmanager
1777
def otel_span(

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from opentelemetry.trace import SpanKind
3333
from pydantic import ValidationError
3434

35-
from mcp.shared._otel import inject_trace_context, otel_span
35+
from mcp.shared._otel import build_span_attributes, inject_trace_context, otel_span
3636
from mcp.shared._stream_protocols import ReadStream, WriteStream
3737
from mcp.shared.dispatcher import CallOptions, Dispatcher, OnNotify, OnRequest, ProgressFnT
3838
from mcp.shared.exceptions import MCPError, NoBackChannelError
@@ -334,7 +334,7 @@ async def send_raw_request(
334334
with otel_span(
335335
span_name,
336336
kind=SpanKind.CLIENT,
337-
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
337+
attributes=build_span_attributes(method, request_id, params=out_params),
338338
):
339339
# Inject W3C trace context into _meta (SEP-414). With a no-op
340340
# tracer this writes nothing, but `_meta` itself is still

src/mcp/shared/session.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing_extensions import Self
1414

1515
from mcp.shared._compat import resync_tracer
16-
from mcp.shared._otel import inject_trace_context, otel_span
16+
from mcp.shared._otel import build_span_attributes, inject_trace_context, otel_span
1717
from mcp.shared._stream_protocols import ReadStream, WriteStream
1818
from mcp.shared.exceptions import MCPError
1919
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
@@ -219,7 +219,11 @@ async def send_request(
219219
with otel_span(
220220
span_name,
221221
kind=SpanKind.CLIENT,
222-
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": str(request_id)},
222+
attributes=build_span_attributes(
223+
request.method,
224+
request_id,
225+
params=request_data.get("params"),
226+
),
223227
):
224228
# Inject W3C trace context into _meta (SEP-414).
225229
meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {})

tests/shared/test_otel.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,68 @@ def greet(name: str) -> str:
3434
client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet")
3535
server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet")
3636

37+
# Base RPC + MCP attributes
38+
assert client_span["attributes"]["rpc.system"] == "mcp"
3739
assert client_span["attributes"]["mcp.method.name"] == "tools/call"
40+
assert client_span["attributes"]["jsonrpc.request.id"] is not None
41+
assert server_span["attributes"]["rpc.system"] == "mcp"
3842
assert server_span["attributes"]["mcp.method.name"] == "tools/call"
3943

44+
# GenAI semconv attributes
45+
assert client_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
46+
assert client_span["attributes"]["gen_ai.tool.name"] == "greet"
47+
assert server_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
48+
assert server_span["attributes"]["gen_ai.tool.name"] == "greet"
49+
4050
# Server span should be in the same trace as the client span (context propagation).
4151
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
52+
53+
54+
async def test_list_tools_spans(capfire: CaptureLogfire):
55+
"""Verify that listing tools produces spans with list_tools operation."""
56+
server = MCPServer("test")
57+
58+
@server.tool()
59+
def greet(name: str) -> str:
60+
"""Greet someone."""
61+
return f"Hello, {name}!"
62+
63+
async with Client(server) as client:
64+
await client.list_tools()
65+
66+
spans = capfire.exporter.exported_spans_as_dict()
67+
68+
client_span = next(s for s in spans if s["name"] == "MCP send tools/list")
69+
server_span = next(s for s in spans if s["name"] == "MCP handle tools/list")
70+
71+
assert client_span["attributes"]["gen_ai.operation.name"] == "list_tools"
72+
assert server_span["attributes"]["gen_ai.operation.name"] == "list_tools"
73+
# No tool name on list — no specific tool targeted
74+
assert "gen_ai.tool.name" not in client_span["attributes"]
75+
assert "gen_ai.tool.name" not in server_span["attributes"]
76+
77+
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
78+
79+
80+
async def test_resource_read_spans(capfire: CaptureLogfire):
81+
"""Verify that reading a resource produces spans with resource URI."""
82+
server = MCPServer("test")
83+
84+
@server.resource("test://greeting")
85+
def greeting() -> str:
86+
return "hello"
87+
88+
async with Client(server) as client:
89+
await client.read_resource("test://greeting")
90+
91+
spans = capfire.exporter.exported_spans_as_dict()
92+
93+
client_span = next(s for s in spans if s["name"] == "MCP send resources/read")
94+
server_span = next(s for s in spans if s["name"] == "MCP handle resources/read")
95+
96+
assert client_span["attributes"]["gen_ai.operation.name"] == "read_resource"
97+
assert client_span["attributes"]["mcp.resource.uri"] == "test://greeting"
98+
assert server_span["attributes"]["gen_ai.operation.name"] == "read_resource"
99+
assert server_span["attributes"]["mcp.resource.uri"] == "test://greeting"
100+
101+
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]

0 commit comments

Comments
 (0)