Skip to content

Commit cdd6bb2

Browse files
committed
Add GenAI semantic-convention attributes to OpenTelemetryMiddleware
Set gen_ai.operation.name (execute_tool), gen_ai.tool.name, and gen_ai.prompt.name per the MCP OTel semantic conventions, plus error.type and rpc.response.status_code on failures (tool_error for a tools/call result carrying is_error). Span name now follows {mcp.method.name} {target}.
1 parent a527142 commit cdd6bb2

2 files changed

Lines changed: 139 additions & 12 deletions

File tree

src/mcp/server/_otel.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,70 @@
88
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
99
from mcp.shared._otel import extract_trace_context, otel_span
1010
from mcp.shared.exceptions import MCPError
11+
from mcp.types import CallToolResult
1112

1213

1314
class OpenTelemetryMiddleware(ServerMiddleware[Any]):
1415
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span.
1516
16-
Span name `"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
17-
trace context extracted from `params._meta` (SEP-414), and an ERROR status if
18-
the handler raises. Requests and notifications both get a span;
19-
`jsonrpc.request.id` is set only when `ctx.request_id` is present (notifications
20-
have none).
17+
Span name `"<method> [<target>]"`, `mcp.method.name` attribute, W3C trace context extracted from
18+
`params._meta` (SEP-414), and an ERROR status if the handler raises. Requests and notifications both get a span;
19+
`jsonrpc.request.id` is set only when `ctx.request_id` is present (notifications have none).
20+
21+
Tool and prompt operations additionally carry the GenAI semantic-convention attributes `gen_ai.tool.name` /
22+
`gen_ai.prompt.name`, and `gen_ai.operation.name` is set to `execute_tool` for `tools/call`. Failures set
23+
`error.type` and `rpc.response.status_code` to the JSON-RPC error code, or `error.type` to `tool_error` for a
24+
`tools/call` result carrying `is_error`.
2125
"""
2226

2327
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
2428
name = ctx.params.get("name") if ctx.params else None
2529
target = name if isinstance(name, str) else None
2630

27-
attributes: dict[str, Any] = {"mcp.method.name": ctx.method}
31+
attributes: dict[str, Any] = {
32+
"mcp.method.name": ctx.method,
33+
"mcp.protocol.version": ctx.protocol_version,
34+
}
2835
if ctx.request_id is not None:
2936
attributes["jsonrpc.request.id"] = str(ctx.request_id)
3037

38+
if target is not None:
39+
if ctx.method == "tools/call":
40+
attributes["gen_ai.operation.name"] = "execute_tool"
41+
attributes["gen_ai.tool.name"] = target
42+
elif ctx.method == "prompts/get":
43+
attributes["gen_ai.prompt.name"] = target
44+
3145
with otel_span(
32-
name=f"MCP handle {ctx.method}{f' {target}' if target else ''}",
46+
name=f"{ctx.method}{f' {target}' if target else ''}",
3347
kind=SpanKind.SERVER,
3448
attributes=attributes,
3549
context=extract_trace_context(ctx.meta),
3650
record_exception=False,
3751
set_status_on_exception=False,
3852
) as span:
3953
try:
40-
return await call_next(ctx)
54+
result = await call_next(ctx)
4155
except MCPError as e:
56+
code = str(e.error.code)
57+
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
4258
span.set_status(StatusCode.ERROR, e.error.message)
4359
raise
4460
except ValidationError:
4561
# Mirror the sanitized wire response; pydantic messages carry client input.
62+
span.set_attribute("error.type", "ValidationError")
4663
span.set_status(StatusCode.ERROR, "Invalid request parameters")
4764
raise
4865
except Exception as e:
66+
span.set_attribute("error.type", type(e).__qualname__)
4967
span.record_exception(e)
5068
span.set_status(StatusCode.ERROR, str(e))
5169
raise
70+
if ctx.method == "tools/call":
71+
match result:
72+
case CallToolResult(is_error=True) | {"isError": True} | {"is_error": True}:
73+
span.set_attribute("error.type", "tool_error")
74+
span.set_status(StatusCode.ERROR)
75+
case _:
76+
pass
77+
return result

tests/server/test_otel.py

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
from mcp.server.runner import otel_middleware
1414
from mcp.shared._otel import inject_trace_context
1515
from mcp.shared.exceptions import MCPError
16-
from mcp.types import CallToolRequestParams, ListToolsResult, NotificationParams, PaginatedRequestParams, Tool
16+
from mcp.types import (
17+
CallToolRequestParams,
18+
CallToolResult,
19+
GetPromptRequestParams,
20+
GetPromptResult,
21+
ListToolsResult,
22+
NotificationParams,
23+
PaginatedRequestParams,
24+
Tool,
25+
)
1726

1827
from .conftest import SpanCapture
1928
from .test_runner import Ctx, SrvT, connected_runner
@@ -40,13 +49,98 @@ async def test_emits_server_span_with_method_and_target(server: SrvT, spans: Spa
4049
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
4150
assert result == {"content": [], "isError": False}
4251
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
43-
assert span.name == "MCP handle tools/call mytool"
52+
assert span.name == "tools/call mytool"
4453
assert span.attributes is not None
4554
assert span.attributes["mcp.method.name"] == "tools/call"
55+
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
56+
assert span.attributes["gen_ai.tool.name"] == "mytool"
4657
assert isinstance(span.attributes["jsonrpc.request.id"], str)
4758
assert span.status.status_code == StatusCode.UNSET
4859

4960

61+
@pytest.mark.anyio
62+
async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
63+
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
64+
return {"content": [], "isError": True}
65+
66+
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
67+
server.middleware.append(OpenTelemetryMiddleware())
68+
async with connected_runner(server) as (client, _):
69+
spans.clear()
70+
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
71+
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
72+
assert span.attributes is not None
73+
assert span.attributes["error.type"] == "tool_error"
74+
assert span.status.status_code == StatusCode.ERROR
75+
76+
77+
@pytest.mark.anyio
78+
async def test_tool_error_model_result_sets_error_type(server: SrvT, spans: SpanCapture):
79+
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult:
80+
return CallToolResult(content=[], is_error=True)
81+
82+
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
83+
server.middleware.append(OpenTelemetryMiddleware())
84+
async with connected_runner(server) as (client, _):
85+
spans.clear()
86+
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
87+
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
88+
assert span.attributes is not None
89+
assert span.attributes["error.type"] == "tool_error"
90+
assert span.status.status_code == StatusCode.ERROR
91+
92+
93+
@pytest.mark.anyio
94+
async def test_tool_error_snake_case_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
95+
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
96+
return {"content": [], "is_error": True}
97+
98+
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
99+
server.middleware.append(OpenTelemetryMiddleware())
100+
async with connected_runner(server) as (client, _):
101+
spans.clear()
102+
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
103+
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
104+
assert span.attributes is not None
105+
assert span.attributes["error.type"] == "tool_error"
106+
assert span.status.status_code == StatusCode.ERROR
107+
108+
109+
@pytest.mark.anyio
110+
async def test_named_non_tool_prompt_method_omits_gen_ai_attrs(server: SrvT, spans: SpanCapture):
111+
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
112+
return {"content": [], "isError": False}
113+
114+
server.add_request_handler("custom/op", CallToolRequestParams, custom)
115+
server.middleware.append(OpenTelemetryMiddleware())
116+
async with connected_runner(server) as (client, _):
117+
spans.clear()
118+
await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}})
119+
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
120+
assert span.name == "custom/op thing"
121+
assert span.attributes is not None
122+
assert "gen_ai.operation.name" not in span.attributes
123+
assert "gen_ai.tool.name" not in span.attributes
124+
assert "gen_ai.prompt.name" not in span.attributes
125+
126+
127+
@pytest.mark.anyio
128+
async def test_prompt_get_sets_prompt_name(server: SrvT, spans: SpanCapture):
129+
async def get_prompt(ctx: Ctx, params: GetPromptRequestParams) -> GetPromptResult:
130+
return GetPromptResult(messages=[])
131+
132+
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
133+
server.middleware.append(OpenTelemetryMiddleware())
134+
async with connected_runner(server) as (client, _):
135+
spans.clear()
136+
await client.send_raw_request("prompts/get", {"name": "myprompt"})
137+
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
138+
assert span.name == "prompts/get myprompt"
139+
assert span.attributes is not None
140+
assert span.attributes["gen_ai.prompt.name"] == "myprompt"
141+
assert "gen_ai.operation.name" not in span.attributes
142+
143+
50144
@pytest.mark.anyio
51145
async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCapture):
52146
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
@@ -59,7 +153,7 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
59153
await client.notify("notifications/roots/list_changed", None)
60154
await anyio.wait_all_tasks_blocked()
61155
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
62-
assert span.name == "MCP handle notifications/roots/list_changed"
156+
assert span.name == "notifications/roots/list_changed"
63157
assert span.attributes is not None
64158
assert span.attributes["mcp.method.name"] == "notifications/roots/list_changed"
65159
assert "jsonrpc.request.id" not in span.attributes
@@ -146,6 +240,9 @@ async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCaptur
146240
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
147241
assert span.status.status_code == StatusCode.ERROR
148242
assert span.status.description == "Method not found"
243+
assert span.attributes is not None
244+
assert span.attributes["error.type"] == str(exc.value.error.code)
245+
assert span.attributes["rpc.response.status_code"] == str(exc.value.error.code)
149246
assert not [e for e in span.events if e.name == "exception"]
150247

151248

@@ -160,6 +257,8 @@ async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: Spa
160257
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
161258
assert span.status.status_code == StatusCode.ERROR
162259
assert span.status.description == "Invalid request parameters"
260+
assert span.attributes is not None
261+
assert span.attributes["error.type"] == "ValidationError"
163262
assert not span.events
164263

165264

@@ -177,6 +276,8 @@ async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
177276
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
178277
assert span.status.status_code == StatusCode.ERROR
179278
assert span.status.description == "handler blew up"
279+
assert span.attributes is not None
280+
assert span.attributes["error.type"] == "ValueError"
180281
[event] = [e for e in span.events if e.name == "exception"]
181282
assert event.attributes is not None
182283
assert event.attributes["exception.type"] == "ValueError"
@@ -202,4 +303,4 @@ async def inject_arg(ctx: Ctx, call_next: CallNext) -> Any:
202303
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
203304
assert seen_arguments == {"x": 1, "injected": True}
204305
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
205-
assert span.name == "MCP handle tools/call mytool"
306+
assert span.name == "tools/call mytool"

0 commit comments

Comments
 (0)