fastmcp/tests/server/telemetry/test_server_tracing.py
Bill Easton 7184a4ca21
OTEL: Fix attribute compliance with MCP semantic conventions (#3889)
* OTEL: Fix attribute compliance and improve telemetry helpers

Attribute compliance:
- Remove rpc.system/service/method (MCP is not traditional RPC)
- Add gen_ai.tool.name on tools/call spans
- Add gen_ai.prompt.name on prompts/get spans
- Fix session_id check (truthy -> is not None)

Telemetry helper improvements:
- Add is_recording() guards to skip work on non-recording spans
- Add error.type attribute with __qualname__ on error spans
- Use isinstance check for ToolError to set "tool_error" error type
- Include exception message in span status description
- Add tool_name/prompt_name params to server_span and client_span

Client call_tool enrichment:
- Reflect tool-level errors (result.isError) on client span status
  so callers see ERROR even though the MCP protocol call succeeded

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove resource URI from span names to avoid high-cardinality

Per MCP semantic conventions, resource URIs SHOULD NOT be included in
span names by default since they can be unbounded (especially with
templates like users://{id}/profile). The URI remains available via
the mcp.resource.uri attribute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing gen_ai/mcp attributes to proxy and delegate spans

- Proxy tool spans: add gen_ai.tool.name
- Proxy prompt spans: add gen_ai.prompt.name
- All delegate spans: add mcp.method.name
- Docs: remove rpc.* references, update span names and attributes table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Hoist ToolError imports to module level, add rpc.* migration note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:28:47 -04:00

352 lines
12 KiB
Python

"""Tests for server-level OpenTelemetry tracing."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import SpanKind, StatusCode
from fastmcp import FastMCP
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.auth import AccessToken
class TestToolTracing:
async def test_call_tool_creates_span(self, trace_exporter: InMemorySpanExporter):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
result = await mcp.call_tool("greet", {"name": "World"})
assert "Hello, World!" in str(result)
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "tools/call greet"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "tools/call"
# gen_ai semantic conventions
assert span.attributes["gen_ai.tool.name"] == "greet"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
assert span.attributes["fastmcp.component.key"] == "tool:greet@"
async def test_call_tool_with_error_sets_status(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def failing_tool() -> str:
raise ValueError("Something went wrong")
with pytest.raises(ToolError):
await mcp.call_tool("failing_tool", {})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "tools/call failing_tool"
assert span.status.status_code == StatusCode.ERROR
assert span.status.description is not None
assert "Something went wrong" in span.status.description
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert len(span.events) > 0 # Exception recorded
async def test_call_nonexistent_tool_sets_error(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
with pytest.raises(NotFoundError):
await mcp.call_tool("nonexistent", {})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "tools/call nonexistent"
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
# NotFoundError is not a ToolError, so uses class name as fallback
assert span.attributes["error.type"] == "NotFoundError"
class TestResourceTracing:
async def test_read_resource_creates_span(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.resource("config://app")
def get_config() -> str:
return "app_config_data"
result = await mcp.read_resource("config://app")
assert "app_config_data" in str(result)
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "config://app"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
assert span.attributes["fastmcp.component.key"] == "resource:config://app@"
async def test_read_resource_template_creates_span(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
return f"profile for {user_id}"
result = await mcp.read_resource("users://123/profile")
assert "profile for 123" in str(result)
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "users://123/profile"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.method" not in span.attributes
# Template component type is set by get_span_attributes
assert span.attributes["fastmcp.component.type"] == "resource_template"
assert (
span.attributes["fastmcp.component.key"]
== "template:users://{user_id}/profile@"
)
async def test_read_nonexistent_resource_sets_error(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
with pytest.raises(NotFoundError):
await mcp.read_resource("nonexistent://resource")
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read"
assert span.status.status_code == StatusCode.ERROR
class TestPromptTracing:
async def test_render_prompt_creates_span(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.prompt()
def greeting(name: str) -> str:
return f"Hello, {name}!"
result = await mcp.render_prompt("greeting", {"name": "World"})
assert "Hello, World!" in str(result)
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "prompts/get greeting"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "prompts/get"
# gen_ai semantic conventions
assert span.attributes["gen_ai.prompt.name"] == "greeting"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"
assert span.attributes["fastmcp.component.key"] == "prompt:greeting@"
async def test_render_nonexistent_prompt_sets_error(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
with pytest.raises(NotFoundError):
await mcp.render_prompt("nonexistent", {})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "prompts/get nonexistent"
assert span.status.status_code == StatusCode.ERROR
class TestAuthAttributesOnSpans:
async def test_tool_span_includes_auth_attributes_when_authenticated(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
test_token = AccessToken(
token="test-token",
client_id="test-client-123",
scopes=["read", "write"],
)
with patch(
"fastmcp.server.dependencies.get_access_token", return_value=test_token
):
await mcp.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["enduser.id"] == "test-client-123"
assert span.attributes["enduser.scope"] == "read write"
async def test_resource_span_includes_auth_attributes_when_authenticated(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.resource("config://app")
def get_config() -> str:
return "config_data"
test_token = AccessToken(
token="test-token",
client_id="user-456",
scopes=["config:read"],
)
with patch(
"fastmcp.server.dependencies.get_access_token", return_value=test_token
):
await mcp.read_resource("config://app")
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["enduser.id"] == "user-456"
assert span.attributes["enduser.scope"] == "config:read"
async def test_prompt_span_includes_auth_attributes_when_authenticated(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.prompt()
def greeting(name: str) -> str:
return f"Hello, {name}!"
test_token = AccessToken(
token="test-token",
client_id="prompt-user",
scopes=["prompts"],
)
with patch(
"fastmcp.server.dependencies.get_access_token", return_value=test_token
):
await mcp.render_prompt("greeting", {"name": "World"})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["enduser.id"] == "prompt-user"
assert span.attributes["enduser.scope"] == "prompts"
async def test_span_omits_auth_attributes_when_not_authenticated(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
# No mock - get_access_token returns None by default (no auth context)
await mcp.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
# Auth attributes should not be present
assert "enduser.id" not in span.attributes
assert "enduser.scope" not in span.attributes
async def test_span_omits_scope_when_no_scopes(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
test_token = AccessToken(
token="test-token",
client_id="client-no-scopes",
scopes=[], # Empty scopes
)
with patch(
"fastmcp.server.dependencies.get_access_token", return_value=test_token
):
await mcp.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["enduser.id"] == "client-no-scopes"
# Scope attribute should not be present when scopes list is empty
assert "enduser.scope" not in span.attributes