Skip to content

Commit 96e05bc

Browse files
committed
Address review: restrict gen_ai.operation.name to tools/call per spec, add gen_ai.prompt.name for prompts/get
1 parent 6930f92 commit 96e05bc

2 files changed

Lines changed: 67 additions & 30 deletions

File tree

src/mcp/shared/_otel.py

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,6 @@
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-
2815
def build_span_attributes(
2916
method: str,
3017
request_id: Any,
@@ -35,24 +22,32 @@ def build_span_attributes(
3522
3623
Produces the base set of semantic convention attributes shared by both
3724
client (`SpanKind.CLIENT`) and server (`SpanKind.SERVER`) spans.
25+
26+
Per the GenAI MCP semconv spec, `gen_ai.operation.name` SHOULD be set to
27+
`execute_tool` for `tools/call` and SHOULD NOT be set for other methods.
28+
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md
3829
"""
3930
attrs: dict[str, Any] = {
4031
"rpc.system": "mcp",
4132
"mcp.method.name": method,
4233
"jsonrpc.request.id": str(request_id),
4334
}
4435

45-
operation = _METHOD_TO_GEN_AI_OPERATION.get(method)
46-
if operation is not None:
47-
attrs["gen_ai.operation.name"] = operation
48-
4936
if params is not None:
50-
# gen_ai.tool.name — present on tools/call, prompts/get
51-
name = params.get("name")
52-
if isinstance(name, str):
53-
attrs["gen_ai.tool.name"] = name
54-
55-
# mcp.resource.uri — present on resources/read; also on completion/complete via ref.uri
37+
if method == "tools/call":
38+
# gen_ai.operation.name SHOULD be set to execute_tool for tools/call only.
39+
attrs["gen_ai.operation.name"] = "execute_tool"
40+
name = params.get("name")
41+
if isinstance(name, str):
42+
attrs["gen_ai.tool.name"] = name
43+
44+
elif method == "prompts/get":
45+
name = params.get("name")
46+
if isinstance(name, str):
47+
attrs["gen_ai.prompt.name"] = name
48+
49+
# mcp.resource.uri — resources/read, resources/subscribe, resources/unsubscribe,
50+
# notifications/resources/updated, and completion/complete via ref.uri
5651
uri: Any = params.get("uri")
5752
if uri is None:
5853
ref = params.get("ref")

tests/shared/test_otel.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ def test_build_span_attributes_ref_uri() -> None:
1919
params={"ref": {"uri": "test://doc"}},
2020
)
2121
assert attrs["mcp.resource.uri"] == "test://doc"
22+
assert "gen_ai.operation.name" not in attrs
23+
24+
25+
def test_build_span_attributes_tools_call_no_name() -> None:
26+
"""tools/call without a name param omits gen_ai.tool.name."""
27+
attrs = build_span_attributes("tools/call", "1", params={})
28+
assert attrs["gen_ai.operation.name"] == "execute_tool"
29+
assert "gen_ai.tool.name" not in attrs
30+
31+
32+
def test_build_span_attributes_prompts_get_no_name() -> None:
33+
"""prompts/get without a name param omits gen_ai.prompt.name."""
34+
attrs = build_span_attributes("prompts/get", "1", params={})
35+
assert "gen_ai.prompt.name" not in attrs
36+
assert "gen_ai.operation.name" not in attrs
2237

2338

2439
async def test_client_and_server_spans(capfire: CaptureLogfire):
@@ -52,7 +67,7 @@ def greet(name: str) -> str:
5267
assert server_span["attributes"]["rpc.system"] == "mcp"
5368
assert server_span["attributes"]["mcp.method.name"] == "tools/call"
5469

55-
# GenAI semconv attributes
70+
# GenAI semconv attributes — execute_tool only on tools/call
5671
assert client_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
5772
assert client_span["attributes"]["gen_ai.tool.name"] == "greet"
5873
assert server_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
@@ -63,7 +78,7 @@ def greet(name: str) -> str:
6378

6479

6580
async def test_list_tools_spans(capfire: CaptureLogfire):
66-
"""Verify that listing tools produces spans with list_tools operation."""
81+
"""Verify that listing tools produces spans without gen_ai.operation.name."""
6782
server = MCPServer("test")
6883

6984
async with Client(server) as client:
@@ -74,9 +89,9 @@ async def test_list_tools_spans(capfire: CaptureLogfire):
7489
client_span = next(s for s in spans if s["name"] == "MCP send tools/list")
7590
server_span = next(s for s in spans if s["name"] == "MCP handle tools/list")
7691

77-
assert client_span["attributes"]["gen_ai.operation.name"] == "list_tools"
78-
assert server_span["attributes"]["gen_ai.operation.name"] == "list_tools"
79-
# No tool name on list — no specific tool targeted
92+
# gen_ai.operation.name SHOULD NOT be set for non-tool-call methods per spec
93+
assert "gen_ai.operation.name" not in client_span["attributes"]
94+
assert "gen_ai.operation.name" not in server_span["attributes"]
8095
assert "gen_ai.tool.name" not in client_span["attributes"]
8196
assert "gen_ai.tool.name" not in server_span["attributes"]
8297

@@ -99,9 +114,36 @@ def greeting() -> str:
99114
client_span = next(s for s in spans if s["name"] == "MCP send resources/read")
100115
server_span = next(s for s in spans if s["name"] == "MCP handle resources/read")
101116

102-
assert client_span["attributes"]["gen_ai.operation.name"] == "read_resource"
103117
assert client_span["attributes"]["mcp.resource.uri"] == "test://greeting"
104-
assert server_span["attributes"]["gen_ai.operation.name"] == "read_resource"
105118
assert server_span["attributes"]["mcp.resource.uri"] == "test://greeting"
119+
# gen_ai.operation.name SHOULD NOT be set for resources/read per spec
120+
assert "gen_ai.operation.name" not in client_span["attributes"]
121+
assert "gen_ai.operation.name" not in server_span["attributes"]
122+
123+
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
124+
125+
126+
async def test_prompt_get_spans(capfire: CaptureLogfire):
127+
"""Verify that getting a prompt produces spans with gen_ai.prompt.name."""
128+
server = MCPServer("test")
129+
130+
@server.prompt()
131+
def summarize() -> str:
132+
"""Summarize text."""
133+
return "Summarize the following: "
134+
135+
async with Client(server) as client:
136+
await client.get_prompt("summarize", {})
137+
138+
spans = capfire.exporter.exported_spans_as_dict()
139+
140+
client_span = next(s for s in spans if s["name"] == "MCP send prompts/get summarize")
141+
server_span = next(s for s in spans if s["name"] == "MCP handle prompts/get summarize")
142+
143+
assert client_span["attributes"]["gen_ai.prompt.name"] == "summarize"
144+
assert server_span["attributes"]["gen_ai.prompt.name"] == "summarize"
145+
# gen_ai.operation.name SHOULD NOT be set for prompts/get per spec
146+
assert "gen_ai.operation.name" not in client_span["attributes"]
147+
assert "gen_ai.operation.name" not in server_span["attributes"]
106148

107149
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]

0 commit comments

Comments
 (0)