mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-15 10:09:11 +02:00
* 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>
352 lines
12 KiB
Python
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
|