Files
wehub-resource-sync 60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08:00

578 lines
21 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 Client, FastMCP
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
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
class TestSingleServerSpan:
"""Full-dispatch tests guarding against duplicate SERVER spans.
The SDK seeds its own `OpenTelemetryMiddleware` into every lowlevel server,
which would emit a second SERVER span per request alongside FastMCP's. These
tests exercise the real request path (through a `Client`) so a regression
that re-enables the SDK middleware would surface as an extra SERVER span.
Note the in-process `mcp.call_tool` tests above bypass the dispatcher and so
cannot catch this.
"""
async def test_tool_call_emits_single_server_span(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
# Exactly one SERVER span for the tools/call request. Match by method name
# (not just the "tools/call greet" name) so a seam-level span accidentally
# opened for this high-level method — which would be named "tools/call" —
# is also counted and would trip the assertion.
tool_call_server_spans = [
s
for s in spans
if s.kind == SpanKind.SERVER
and s.attributes is not None
and s.attributes.get("mcp.method.name") == "tools/call"
]
assert len(tool_call_server_spans) == 1
span = tool_call_server_spans[0]
assert span.name == "tools/call greet"
# The single span carries the rich component attributes that the
# high-level path enriches the seam span with (attribute parity: the
# enrichment must not lose the component context by opening a second
# span or by leaving the seam span bare).
assert span.attributes is not None
assert span.attributes["fastmcp.component.key"] == "tool:greet@"
assert span.attributes["gen_ai.tool.name"] == "greet"
assert span.attributes["fastmcp.component.type"] == "tool"
async def test_server_span_shares_client_trace(
self, trace_exporter: InMemorySpanExporter
):
"""FastMCP extracts inbound W3C trace context from `_meta`, so the single
SERVER span must still join the client's distributed trace."""
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
server_span = next(
s
for s in spans
if s.kind == SpanKind.SERVER and s.name == "tools/call greet"
)
client_span = next(
s
for s in spans
if s.kind == SpanKind.CLIENT and s.name == "MCP send tools/call greet"
)
assert server_span.context is not None
assert client_span.context is not None
assert server_span.context.trace_id == client_span.context.trace_id
assert server_span.parent is not None
class TestSeamServerSpan:
"""SERVER spans for methods outside the high-level tool/resource/prompt path.
FastMCP's rich SERVER spans are created deep in the high-level path, so
methods like `logging/setLevel`, `tasks/*`, `ping`, and `initialize` — which
never reach that code — would have no span at all once the SDK's
`OpenTelemetryMiddleware` is removed. The FastMCP middleware seam
(`FastMCPServerMiddleware`) emits a span for exactly those methods, so every
request method carries a SERVER span again.
"""
async def test_set_logging_level_emits_seam_span(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
async with Client(mcp) as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
seam_spans = [
s
for s in spans
if s.kind == SpanKind.SERVER and s.name == "logging/setLevel"
]
assert len(seam_spans) == 1
span = seam_spans[0]
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "logging/setLevel"
assert span.attributes["fastmcp.server.name"] == "test-server"
async def test_seam_method_emits_single_server_span(
self, trace_exporter: InMemorySpanExporter
):
"""A seam-spanned method must produce exactly one SERVER span, not two."""
mcp = FastMCP("test-server")
async with Client(mcp) as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
set_level_server_spans = [
s
for s in spans
if s.kind == SpanKind.SERVER
and s.attributes is not None
and s.attributes.get("mcp.method.name") == "logging/setLevel"
]
assert len(set_level_server_spans) == 1
class TestFailurePathServerSpan:
"""A tools/call rejected before the high-level handler must still be traced.
The rich SERVER span for tools/call is created deep in the high-level path,
after FastMCP middleware runs. A request rejected *before* that point (a
raising middleware, a not-found tool) never reaches it, so without the seam
span such failures would produce no SERVER span at all — the failure-path
observability regression this guards against. The seam span opened by
`FastMCPServerMiddleware` wraps the whole request, so every tools/call
carries a SERVER span with `mcp.method.name=tools/call`, and an exception
that propagates past the handler marks that span's status as an error.
"""
async def test_middleware_error_before_handler_emits_error_span(
self, trace_exporter: InMemorySpanExporter
):
"""An unexpected error raised in FastMCP middleware, before the
high-level path, propagates to the seam span, which records it as an
error — where previously this method produced no SERVER span at all."""
class RaisingMiddleware(Middleware):
async def on_call_tool(
self,
context: MiddlewareContext,
call_next: CallNext,
):
raise RuntimeError("rejected before handler")
mcp = FastMCP("test-server", middleware=[RaisingMiddleware()])
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
with pytest.raises(Exception):
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
tool_call_server_spans = [
s
for s in spans
if s.kind == SpanKind.SERVER
and s.attributes is not None
and s.attributes.get("mcp.method.name") == "tools/call"
]
assert len(tool_call_server_spans) == 1
span = tool_call_server_spans[0]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "tools/call"
assert span.attributes["error.type"] == "RuntimeError"
assert len(span.events) > 0 # exception recorded
async def test_tool_visible_rejection_before_handler_still_spans(
self, trace_exporter: InMemorySpanExporter
):
"""A tool-visible error raised in middleware is returned as an error
result (correct MCP semantics, so the span status is unset), but the
seam span still guarantees exactly one SERVER span for the tools/call —
the request is no longer invisible to tracing."""
class RejectingMiddleware(Middleware):
async def on_call_tool(
self,
context: MiddlewareContext,
call_next: CallNext,
):
raise ToolError("rejected before handler")
mcp = FastMCP("test-server", middleware=[RejectingMiddleware()])
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
tool_call_server_spans = [
s
for s in spans
if s.kind == SpanKind.SERVER
and s.attributes is not None
and s.attributes.get("mcp.method.name") == "tools/call"
]
assert len(tool_call_server_spans) == 1
assert (
tool_call_server_spans[0].attributes is not None
and tool_call_server_spans[0].attributes["mcp.method.name"] == "tools/call"
)