Skip to content

Commit c0ab4e9

Browse files
committed
Stop flagging snake_case is_error results as tool errors in OTel span
The tools/call result match arm treated {"is_error": True} as a tool error, but serialize_server_result validates with by_name=False (alias-only), so the snake_case key is dropped and the client receives a success. The span then contradicted the wire response. Only CallToolResult and the camelCase {"isError": True} dict survive serialization as errors; drop the snake_case arm and assert the result stays a success.
1 parent 1b1abf6 commit c0ab4e9

2 files changed

Lines changed: 9 additions & 5 deletions

File tree

src/mcp/server/_otel.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex
5959
span.set_status(StatusCode.ERROR, str(e))
6060
raise
6161
if ctx.method == "tools/call":
62+
# Only shapes that survive wire serialization (alias-only) are real tool errors.
6263
match result:
63-
case CallToolResult(is_error=True) | {"isError": True} | {"is_error": True}:
64+
case CallToolResult(is_error=True) | {"isError": True}:
6465
span.set_attribute("error.type", "tool_error")
6566
span.set_status(StatusCode.ERROR)
6667
case _:

tests/server/test_otel.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,22 @@ async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult:
9292

9393

9494
@pytest.mark.anyio
95-
async def test_tool_error_snake_case_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
95+
async def test_snake_case_dict_result_is_not_a_tool_error(server: SrvT, spans: SpanCapture):
96+
# `is_error` is alias-only on the wire, so serialization drops it; the result reaches the
97+
# client as a success and the span must not contradict that.
9698
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
9799
return {"content": [], "is_error": True}
98100

99101
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
100102
server.middleware.append(OpenTelemetryMiddleware())
101103
async with connected_runner(server) as (client, _):
102104
spans.clear()
103-
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
105+
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
106+
assert result == {"content": []}
104107
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
105108
assert span.attributes is not None
106-
assert span.attributes["error.type"] == "tool_error"
107-
assert span.status.status_code == StatusCode.ERROR
109+
assert "error.type" not in span.attributes
110+
assert span.status.status_code == StatusCode.UNSET
108111

109112

110113
@pytest.mark.anyio

0 commit comments

Comments
 (0)