Add GenAI semantic-convention attributes to OpenTelemetryMiddleware (#2970)

This commit is contained in:
Marcelo Trylesinski 2026-06-25 16:43:54 +02:00 • committed by GitHub
commit 1b1abf6ab6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 15 deletions

View file

@ -8,28 +8,32 @@ from pydantic import ValidationError
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared.exceptions import MCPError
from mcp.types import INVALID_PARAMS, CallToolResult
class OpenTelemetryMiddleware(ServerMiddleware[Any]):
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span.
Span name `"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
trace context extracted from `params._meta` (SEP-414), and an ERROR status if
the handler raises. Requests and notifications both get a span;
`jsonrpc.request.id` is set only when `ctx.request_id` is present (notifications
have none).
"""
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span."""
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
name = ctx.params.get("name") if ctx.params else None
target = name if isinstance(name, str) else None
attributes: dict[str, Any] = {"mcp.method.name": ctx.method}
attributes: dict[str, Any] = {
"mcp.method.name": ctx.method,
"mcp.protocol.version": ctx.protocol_version,
}
if ctx.request_id is not None:
attributes["jsonrpc.request.id"] = str(ctx.request_id)
if ctx.method == "tools/call":
attributes["gen_ai.operation.name"] = "execute_tool"
if target is not None:
attributes["gen_ai.tool.name"] = target
elif ctx.method == "prompts/get" and target is not None:
attributes["gen_ai.prompt.name"] = target
with otel_span(
name=f"MCP handle {ctx.method}{f' {target}' if target else ''}",
name=f"{ctx.method}{f' {target}' if target else ''}",
kind=SpanKind.SERVER,
attributes=attributes,
context=extract_trace_context(ctx.meta),
@ -37,15 +41,28 @@ class OpenTelemetryMiddleware(ServerMiddleware[Any]):
set_status_on_exception=False,
) as span:
try:
return await call_next(ctx)
result = await call_next(ctx)
except MCPError as e:
code = str(e.error.code)
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
span.set_status(StatusCode.ERROR, e.error.message)
raise
except ValidationError:
# Mirror the sanitized wire response; pydantic messages carry client input.
code = str(INVALID_PARAMS)
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
span.set_status(StatusCode.ERROR, "Invalid request parameters")
raise
except Exception as e:
span.set_attribute("error.type", type(e).__qualname__)
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
raise
if ctx.method == "tools/call":
match result:
case CallToolResult(is_error=True) | {"isError": True} | {"is_error": True}:
span.set_attribute("error.type", "tool_error")
span.set_status(StatusCode.ERROR)
case _:
pass
return result

View file

@ -13,7 +13,17 @@ from mcp.server.lowlevel.server import Server
from mcp.server.runner import otel_middleware
from mcp.shared._otel import inject_trace_context
from mcp.shared.exceptions import MCPError
from mcp.types import CallToolRequestParams, ListToolsResult, NotificationParams, PaginatedRequestParams, Tool
from mcp.types import (
INVALID_PARAMS,
CallToolRequestParams,
CallToolResult,
GetPromptRequestParams,
GetPromptResult,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
Tool,
)
from .conftest import SpanCapture
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
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
assert result == {"content": [], "isError": False}
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "MCP handle tools/call mytool"
assert span.name == "tools/call mytool"
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "tools/call"
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
assert span.attributes["gen_ai.tool.name"] == "mytool"
assert isinstance(span.attributes["jsonrpc.request.id"], str)
assert span.status.status_code == StatusCode.UNSET
@pytest.mark.anyio
async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert span.status.status_code == StatusCode.ERROR
@pytest.mark.anyio
async def test_tool_error_model_result_sets_error_type(server: SrvT, spans: SpanCapture):
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[], is_error=True)
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert span.status.status_code == StatusCode.ERROR
@pytest.mark.anyio
async def test_tool_error_snake_case_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "is_error": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert span.status.status_code == StatusCode.ERROR
@pytest.mark.anyio
async def test_named_non_tool_prompt_method_omits_gen_ai_attrs(server: SrvT, spans: SpanCapture):
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": False}
server.add_request_handler("custom/op", CallToolRequestParams, custom)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "custom/op thing"
assert span.attributes is not None
assert "gen_ai.operation.name" not in span.attributes
assert "gen_ai.tool.name" not in span.attributes
assert "gen_ai.prompt.name" not in span.attributes
@pytest.mark.anyio
async def test_prompt_get_sets_prompt_name(server: SrvT, spans: SpanCapture):
async def get_prompt(ctx: Ctx, params: GetPromptRequestParams) -> GetPromptResult:
return GetPromptResult(messages=[])
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("prompts/get", {"name": "myprompt"})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "prompts/get myprompt"
assert span.attributes is not None
assert span.attributes["gen_ai.prompt.name"] == "myprompt"
assert "gen_ai.operation.name" not in span.attributes
@pytest.mark.anyio
async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCapture):
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
@ -59,7 +154,7 @@ async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCaptu
await client.notify("notifications/roots/list_changed", None)
await anyio.wait_all_tasks_blocked()
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "MCP handle notifications/roots/list_changed"
assert span.name == "notifications/roots/list_changed"
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "notifications/roots/list_changed"
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
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Method not found"
assert span.attributes is not None
assert span.attributes["error.type"] == str(exc.value.error.code)
assert span.attributes["rpc.response.status_code"] == str(exc.value.error.code)
assert not [e for e in span.events if e.name == "exception"]
@ -160,6 +258,11 @@ async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: Spa
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Invalid request parameters"
assert span.attributes is not None
assert span.attributes["error.type"] == str(INVALID_PARAMS)
assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS)
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
assert "gen_ai.tool.name" not in span.attributes
assert not span.events
@ -177,6 +280,8 @@ async def test_records_error_status_on_handler_exception(server: SrvT, spans: Sp
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "handler blew up"
assert span.attributes is not None
assert span.attributes["error.type"] == "ValueError"
[event] = [e for e in span.events if e.name == "exception"]
assert event.attributes is not None
assert event.attributes["exception.type"] == "ValueError"
@ -202,4 +307,4 @@ async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
assert seen_arguments == {"x": 1, "injected": True}
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "MCP handle tools/call mytool"
assert span.name == "tools/call mytool"