mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-12 00:29:11 +02:00
633 lines
21 KiB
Python
633 lines
21 KiB
Python
"""Tests for client OpenTelemetry tracing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncGenerator
|
|
|
|
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 ToolError
|
|
|
|
|
|
class TestClientToolTracing:
|
|
"""Tests for client tool call tracing."""
|
|
|
|
async def test_call_tool_creates_span(self, trace_exporter: InMemorySpanExporter):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.tool()
|
|
def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
result = await client.call_tool("greet", {"name": "World"})
|
|
assert "Hello, World!" in str(result)
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
span_names = [s.name for s in spans]
|
|
|
|
# Client should create "tools/call greet" span
|
|
assert "tools/call greet" in span_names
|
|
|
|
async def test_call_tool_span_attributes(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.tool()
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
await client.call_tool("add", {"a": 1, "b": 2})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span (doesn't have fastmcp.server.name)
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call add"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
assert client_span is not None
|
|
assert client_span.attributes is not None
|
|
# Standard MCP semantic conventions
|
|
assert client_span.attributes["mcp.method.name"] == "tools/call"
|
|
# Standard RPC semantic conventions
|
|
assert client_span.attributes["rpc.system"] == "mcp"
|
|
assert client_span.attributes["rpc.method"] == "tools/call"
|
|
# FastMCP-specific attributes
|
|
assert client_span.attributes["fastmcp.component.key"] == "add"
|
|
|
|
|
|
class TestClientResourceTracing:
|
|
"""Tests for client resource read tracing."""
|
|
|
|
async def test_read_resource_creates_span(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.resource("data://config")
|
|
def get_config() -> str:
|
|
return "config data"
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
result = await client.read_resource("data://config")
|
|
assert "config data" in str(result)
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
span_names = [s.name for s in spans]
|
|
|
|
# Client should create "resources/read data://config" span
|
|
assert "resources/read data://config" in span_names
|
|
|
|
async def test_read_resource_span_attributes(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.resource("data://config")
|
|
def get_config() -> str:
|
|
return "config value"
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
await client.read_resource("data://config")
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side resource span (doesn't have fastmcp.server.name)
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name.startswith("resources/read data://")
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
assert client_span is not None
|
|
assert client_span.attributes is not None
|
|
# Standard MCP semantic conventions
|
|
assert client_span.attributes["mcp.method.name"] == "resources/read"
|
|
assert "data://" in str(client_span.attributes["mcp.resource.uri"])
|
|
# Standard RPC semantic conventions
|
|
assert client_span.attributes["rpc.system"] == "mcp"
|
|
assert client_span.attributes["rpc.method"] == "resources/read"
|
|
# FastMCP-specific attributes
|
|
# The URI may be normalized with trailing slash
|
|
assert "data://" in str(client_span.attributes["fastmcp.component.key"])
|
|
|
|
|
|
class TestClientPromptTracing:
|
|
"""Tests for client prompt get tracing."""
|
|
|
|
async def test_get_prompt_creates_span(self, trace_exporter: InMemorySpanExporter):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.prompt()
|
|
def greeting() -> str:
|
|
return "Hello from prompt!"
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
result = await client.get_prompt("greeting")
|
|
assert "Hello from prompt!" in str(result)
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
span_names = [s.name for s in spans]
|
|
|
|
# Client should create "prompts/get greeting" span
|
|
assert "prompts/get greeting" in span_names
|
|
|
|
async def test_get_prompt_span_attributes(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
server = FastMCP("test-server")
|
|
|
|
@server.prompt()
|
|
def welcome(name: str) -> str:
|
|
return f"Welcome, {name}!"
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
await client.get_prompt("welcome", {"name": "Test"})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side prompt span (doesn't have fastmcp.server.name)
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "prompts/get welcome"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
assert client_span is not None
|
|
assert client_span.attributes is not None
|
|
# Standard MCP semantic conventions
|
|
assert client_span.attributes["mcp.method.name"] == "prompts/get"
|
|
# Standard RPC semantic conventions
|
|
assert client_span.attributes["rpc.system"] == "mcp"
|
|
assert client_span.attributes["rpc.method"] == "prompts/get"
|
|
# FastMCP-specific attributes
|
|
assert client_span.attributes["fastmcp.component.key"] == "welcome"
|
|
|
|
|
|
class TestClientServerSpanHierarchy:
|
|
"""Tests for span relationships between client and server."""
|
|
|
|
async def test_client_and_server_spans_created(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Both client and server should create spans for the same operation."""
|
|
server = FastMCP("test-server")
|
|
|
|
@server.tool()
|
|
def echo(message: str) -> str:
|
|
return message
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
await client.call_tool("echo", {"message": "test"})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client span (no fastmcp.server.name) and server span (has fastmcp.server.name)
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Both spans should exist
|
|
assert client_span is not None, "Client should create a span"
|
|
assert client_span.attributes is not None
|
|
assert server_span is not None, "Server should create a span"
|
|
assert server_span.attributes is not None
|
|
|
|
# Verify span kinds are correct
|
|
assert client_span.kind == SpanKind.CLIENT, "Client span should be CLIENT kind"
|
|
assert server_span.kind == SpanKind.SERVER, "Server span should be SERVER kind"
|
|
|
|
# Verify the spans have different characteristics
|
|
assert client_span.attributes["rpc.method"] == "tools/call"
|
|
assert server_span.attributes["fastmcp.server.name"] == "test-server"
|
|
|
|
async def test_trace_context_propagation(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Server span should be a child of client span via trace context propagation."""
|
|
server = FastMCP("test-server")
|
|
|
|
@server.tool()
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
await client.call_tool("add", {"a": 1, "b": 2})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client span and server span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call add"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call add"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert client_span is not None, "Client span should exist"
|
|
assert server_span is not None, "Server span should exist"
|
|
|
|
# Verify trace context propagation: server span should be child of client span
|
|
# Both should share the same trace_id
|
|
assert server_span.context.trace_id == client_span.context.trace_id, (
|
|
"Server and client spans should share the same trace_id"
|
|
)
|
|
|
|
# Server span's parent should be the client span
|
|
assert server_span.parent is not None, "Server span should have a parent"
|
|
assert server_span.parent.span_id == client_span.context.span_id, (
|
|
"Server span's parent should be the client span"
|
|
)
|
|
|
|
|
|
class TestClientErrorTracing:
|
|
"""Tests for client span creation during errors.
|
|
|
|
Note: MCP protocol errors are returned as successful responses with error content,
|
|
so client spans may not have ERROR status even when the operation fails. This is
|
|
different from server-side where exceptions happen inside the span.
|
|
|
|
The server-side span WILL have ERROR status because the exception occurs within
|
|
the server's span context. The client span represents the successful MCP protocol
|
|
round-trip, while application-level errors are communicated via the response.
|
|
"""
|
|
|
|
async def test_call_tool_error_creates_spans(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Both client and server spans should be created when tool fails."""
|
|
server = FastMCP("test-server")
|
|
|
|
@server.tool()
|
|
def failing_tool() -> str:
|
|
raise ValueError("Something went wrong")
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
with pytest.raises(ToolError):
|
|
await client.call_tool("failing_tool", {})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call failing_tool"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
# Find server-side span
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call failing_tool"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Both spans should exist
|
|
assert client_span is not None, "Client should create a span"
|
|
assert server_span is not None, "Server should create a span"
|
|
|
|
# Server span should have ERROR status (exception inside span)
|
|
assert server_span.status.status_code == StatusCode.ERROR
|
|
|
|
async def test_read_resource_error_creates_spans(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Both client and server spans should be created when resource read fails."""
|
|
server = FastMCP("test-server")
|
|
|
|
@server.resource("data://fail")
|
|
def failing_resource() -> str:
|
|
raise ValueError("Resource error")
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
with pytest.raises(Exception):
|
|
await client.read_resource("data://fail")
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name.startswith("resources/read data://fail")
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
# Find server-side span
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name.startswith("resources/read data://fail")
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Both spans should exist
|
|
assert client_span is not None, "Client should create a span"
|
|
assert server_span is not None, "Server should create a span"
|
|
|
|
# Server span should have ERROR status
|
|
assert server_span.status.status_code == StatusCode.ERROR
|
|
|
|
async def test_get_prompt_error_creates_spans(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Both client and server spans should be created when prompt get fails."""
|
|
server = FastMCP("test-server")
|
|
|
|
@server.prompt()
|
|
def failing_prompt() -> str:
|
|
raise ValueError("Prompt error")
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
with pytest.raises(Exception):
|
|
await client.get_prompt("failing_prompt", {})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "prompts/get failing_prompt"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
# Find server-side span
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "prompts/get failing_prompt"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Both spans should exist
|
|
assert client_span is not None, "Client should create a span"
|
|
assert server_span is not None, "Server should create a span"
|
|
|
|
# Server span should have ERROR status
|
|
assert server_span.status.status_code == StatusCode.ERROR
|
|
|
|
async def test_call_nonexistent_tool_creates_spans(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Both client and server spans should be created for nonexistent tool."""
|
|
server = FastMCP("test-server")
|
|
|
|
client = Client(server)
|
|
async with client:
|
|
with pytest.raises(Exception):
|
|
await client.call_tool("nonexistent", {})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call nonexistent"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
# Find server-side span
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call nonexistent"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Both spans should exist
|
|
assert client_span is not None, "Client should create a span"
|
|
assert server_span is not None, "Server should create a span"
|
|
|
|
# Server span should have ERROR status
|
|
assert server_span.status.status_code == StatusCode.ERROR
|
|
|
|
|
|
class TestSessionIdOnSpans:
|
|
"""Tests for session ID capture on client and server spans.
|
|
|
|
Session IDs are only available with HTTP transport (StreamableHttp).
|
|
"""
|
|
|
|
@pytest.fixture
|
|
async def http_server_url(self) -> AsyncGenerator[str, None]:
|
|
"""Start an HTTP server and return its URL."""
|
|
from fastmcp.utilities.tests import run_server_async
|
|
|
|
server = FastMCP("session-test-server")
|
|
|
|
@server.tool()
|
|
def echo(message: str) -> str:
|
|
return message
|
|
|
|
async with run_server_async(server) as url:
|
|
yield url
|
|
|
|
async def test_client_span_includes_session_id(
|
|
self,
|
|
trace_exporter: InMemorySpanExporter,
|
|
http_server_url: str,
|
|
):
|
|
"""Client span should include session ID when using HTTP transport."""
|
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
|
|
transport = StreamableHttpTransport(http_server_url)
|
|
client = Client(transport=transport)
|
|
async with client:
|
|
await client.call_tool("echo", {"message": "test"})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find client-side span
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert client_span is not None, "Client should create a span"
|
|
assert client_span.attributes is not None
|
|
assert "mcp.session.id" in client_span.attributes
|
|
assert client_span.attributes["mcp.session.id"] is not None
|
|
|
|
async def test_server_span_includes_session_id(
|
|
self,
|
|
trace_exporter: InMemorySpanExporter,
|
|
http_server_url: str,
|
|
):
|
|
"""Server span should include session ID when called via HTTP."""
|
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
|
|
transport = StreamableHttpTransport(http_server_url)
|
|
client = Client(transport=transport)
|
|
async with client:
|
|
await client.call_tool("echo", {"message": "test"})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find server-side span
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert server_span is not None, "Server should create a span"
|
|
assert server_span.attributes is not None
|
|
assert "mcp.session.id" in server_span.attributes
|
|
assert server_span.attributes["mcp.session.id"] is not None
|
|
|
|
async def test_client_and_server_share_same_session_id(
|
|
self,
|
|
trace_exporter: InMemorySpanExporter,
|
|
http_server_url: str,
|
|
):
|
|
"""Client and server spans should have the same session ID."""
|
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
|
|
transport = StreamableHttpTransport(http_server_url)
|
|
client = Client(transport=transport)
|
|
async with client:
|
|
await client.call_tool("echo", {"message": "test"})
|
|
|
|
spans = trace_exporter.get_finished_spans()
|
|
|
|
# Find both spans
|
|
client_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" not in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
server_span = next(
|
|
(
|
|
s
|
|
for s in spans
|
|
if s.name == "tools/call echo"
|
|
and s.attributes is not None
|
|
and "fastmcp.server.name" in s.attributes
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert client_span is not None
|
|
assert client_span.attributes is not None
|
|
assert server_span is not None
|
|
assert server_span.attributes is not None
|
|
|
|
# Both should have session IDs and they should match
|
|
client_session = client_span.attributes.get("mcp.session.id")
|
|
server_session = server_span.attributes.get("mcp.session.id")
|
|
|
|
assert client_session is not None, "Client span should have session ID"
|
|
assert server_session is not None, "Server span should have session ID"
|
|
assert client_session == server_session, (
|
|
"Client and server should share the same session ID"
|
|
)
|