Skip to content

Commit 1b1abf6

Browse files
authored
Add GenAI semantic-convention attributes to OpenTelemetryMiddleware (#2970)
1 parent f226d00 commit 1b1abf6

2 files changed

Lines changed: 137 additions & 15 deletions

File tree

src/mcp/server/_otel.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,61 @@
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 INVALID_PARAMS, CallToolResult
1112

1213

1314
class OpenTelemetryMiddleware(ServerMiddleware[Any]):
14-
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span.
15-
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).
21-
"""
15+
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span."""
2216

2317
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
2418
name = ctx.params.get("name") if ctx.params else None
2519
target = name if isinstance(name, str) else None
2620

27-
attributes: dict[str, Any] = {"mcp.method.name": ctx.method}
21+
attributes: dict[str, Any] = {
22+
"mcp.method.name": ctx.method,
23+
"mcp.protocol.version": ctx.protocol_version,
24+
}
2825
if ctx.request_id is not None:
2926
attributes["jsonrpc.request.id"] = str(ctx.request_id)
3027

28+
if ctx.method == "tools/call":
29+
attributes["gen_ai.operation.name"] = "execute_tool"
30+
if target is not None:
31+
attributes["gen_ai.tool.name"] = target
32+
elif ctx.method == "prompts/get" and target is not None:
33+
attributes["gen_ai.prompt.name"] = target
34+
3135
with otel_span(
32-
name=f"MCP handle {ctx.method}{f' {target}' if target else ''}",
36+
name=f"{ctx.method}{f' {target}' if target else ''}",
3337
kind=SpanKind.SERVER,
3438
attributes=attributes,
3539
context=extract_trace_context(ctx.meta),
3640
record_exception=False,
3741
set_status_on_exception=False,
3842
) as span:
3943
try:
40-
return await call_next(ctx)
44+
result = await call_next(ctx)
4145
except MCPError as e:
46+
code = str(e.error.code)
47+
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
4248
span.set_status(StatusCode.ERROR, e.error.message)
4349
raise
4450
except ValidationError:
4551
# Mirror the sanitized wire response; pydantic messages carry client input.
52+
code = str(INVALID_PARAMS)
53+
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
4654
span.set_status(StatusCode.ERROR, "Invalid request parameters")
4755
raise
4856
except Exception as e:
57+
span.set_attribute("error.type", type(e).__qualname__)
4958
span.record_exception(e)
5059
span.set_status(StatusCode.ERROR, str(e))
5160
raise
61+
if ctx.method == "tools/call":
62+
match result:
63+
case CallToolResult(is_error=True) | {"isError": True} | {"is_error": True}:
64+
span.set_attribute("error.type", "tool_error")
65+
span.set_status(StatusCode.ERROR)
66+
case _:
67+
pass
68+
return result

tests/server/test_otel.py

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,17 @@
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+
INVALID_PARAMS,
18+
CallToolRequestParams,
19+
CallToolResult,
20+
GetPromptRequestParams,
21+
GetPromptResult,
22+
ListToolsResult,
23+
NotificationParams,
24+
PaginatedRequestParams,
25+
Tool,
26+
)
1727

1828
from .conftest import SpanCapture
1929
from .test_runner import Ctx, SrvT, connected_runner
@@ -40,13 +50,98 @@ async def test_emits_server_span_with_method_and_target(server: SrvT, spans: Spa
4050
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
4151
assert result == {"content": [], "isError": False}
4252
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
43-
assert span.name == "MCP handle tools/call mytool"
53+
assert span.name == "tools/call mytool"
4454
assert span.attributes is not None
4555
assert span.attributes["mcp.method.name"] == "tools/call"
56+
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
57+
assert span.attributes["gen_ai.tool.name"] == "mytool"
4658
assert isinstance(span.attributes["jsonrpc.request.id"], str)
4759
assert span.status.status_code == StatusCode.UNSET
4860

4961

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

151249

@@ -160,6 +258,11 @@ async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: Spa
160258
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
161259
assert span.status.status_code == StatusCode.ERROR
162260
assert span.status.description == "Invalid request parameters"
261+
assert span.attributes is not None
262+
assert span.attributes["error.type"] == str(INVALID_PARAMS)
263+
assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS)
264+
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
265+
assert "gen_ai.tool.name" not in span.attributes
163266
assert not span.events
164267

165268

@@ -177,6 +280,8 @@ async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
177280
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
178281
assert span.status.status_code == StatusCode.ERROR
179282
assert span.status.description == "handler blew up"
283+
assert span.attributes is not None
284+
assert span.attributes["error.type"] == "ValueError"
180285
[event] = [e for e in span.events if e.name == "exception"]
181286
assert event.attributes is not None
182287
assert event.attributes["exception.type"] == "ValueError"
@@ -202,4 +307,4 @@ async def inject_arg(ctx: Ctx, call_next: CallNext) -> Any:
202307
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
203308
assert seen_arguments == {"x": 1, "injected": True}
204309
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
205-
assert span.name == "MCP handle tools/call mytool"
310+
assert span.name == "tools/call mytool"

0 commit comments

Comments
 (0)