Add OpenTelemetry tracing support

Adds opt-in distributed tracing via OpenTelemetry for observability into
FastMCP server and client operations.

Server spans are created for tool calls, resource reads, and prompt
renders with attributes like component key, component type, provider
type, session ID, and auth context. Client spans wrap outgoing calls
with trace context propagation via W3C headers in request meta.

Components provide their own span attributes through a `get_span_attributes()`
method that subclasses override - this lets LocalProvider, FastMCPProvider,
and ProxyProvider each include relevant context (original names, backend URIs).

To enable: configure an OpenTelemetry SDK with a TracerProvider before
importing fastmcp. Traces export to any OTLP-compatible backend.

Closes ENG-2813

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Guidry 2026-01-13 16:39:59 -05:00
commit 046f845ddf
30 changed files with 3164 additions and 693 deletions

View file

@ -128,6 +128,7 @@
"servers/sampling",
"servers/storage-backends",
"servers/tasks",
"servers/telemetry",
"servers/visibility"
]
},

326
docs/servers/telemetry.mdx Normal file
View file

@ -0,0 +1,326 @@
---
title: OpenTelemetry
sidebarTitle: Telemetry
description: Native OpenTelemetry instrumentation for tracing and metrics.
icon: chart-line
---
FastMCP includes native OpenTelemetry instrumentation for observability. Traces and metrics are automatically generated for all MCP operations, providing visibility into server behavior, request latencies, and provider delegation chains.
## How It Works
FastMCP uses the OpenTelemetry API (not the SDK) for instrumentation. This means:
- **Zero configuration required** - Instrumentation is always active
- **No overhead when unused** - Without an SDK, all operations are no-ops
- **Bring your own SDK** - You control collection, export, and sampling
- **Works with any OTEL backend** - Jaeger, Zipkin, Datadog, New Relic, etc.
## Enabling Telemetry
To collect telemetry data, install the OpenTelemetry SDK and configure an exporter:
```bash
pip install opentelemetry-sdk opentelemetry-exporter-otlp
```
Then configure the SDK before starting your server:
```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Configure the SDK with OTLP exporter
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
# Now start your FastMCP server
from fastmcp import FastMCP
mcp = FastMCP("my-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
# Traces will now be exported to your OTLP collector
```
## Tracing
FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling.
### Server Spans
The server creates spans for core MCP operations:
| Span Name | Description |
|-----------|-------------|
| `tool {name}` | Tool execution (e.g., `tool get_weather`) |
| `resource {uri}` | Resource read (e.g., `resource config://database`) |
| `prompt {name}` | Prompt render (e.g., `prompt greeting`) |
Each span includes these attributes:
| Attribute | Description |
|-----------|-------------|
| `rpc.system` | Always `"mcp"` |
| `rpc.service` | Server name |
| `rpc.method` | MCP method (e.g., `tools/call`, `resources/read`) |
| `fastmcp.server.name` | Server name |
| `fastmcp.component.type` | Component type (`tool`, `resource`, `prompt`) |
| `fastmcp.component.key` | Full component key |
### Provider Spans
When using mounted servers or proxy providers, additional spans show the delegation chain:
| Span Name | Description |
|-----------|-------------|
| `delegate {name}` | FastMCPProvider delegation to child server |
| `proxy tool {name}` | ProxyProvider remote tool call |
| `proxy resource {uri}` | ProxyProvider remote resource read |
| `proxy prompt {name}` | ProxyProvider remote prompt get |
Provider spans include:
| Attribute | Description |
|-----------|-------------|
| `fastmcp.provider.type` | Provider class name (e.g., `FastMCPProvider`, `ProxyProvider`) |
| `fastmcp.component.key` | Backend component identifier |
### Client Spans
The FastMCP client also creates spans for outgoing requests:
| Span Name | Description |
|-----------|-------------|
| `tool {name}` | Client tool call |
| `resource {uri}` | Client resource read |
| `prompt {name}` | Client prompt get |
Client spans include `rpc.method` to indicate the MCP protocol method being called.
### Span Hierarchy
Spans form a hierarchy showing the request flow:
```
tool weather_forecast (server)
└── delegate get_weather (FastMCPProvider)
└── tool get_weather (child server)
```
For proxy providers, the hierarchy shows remote calls:
```
tool remote_search (server)
└── proxy tool search (ProxyProvider)
```
## Metrics
FastMCP records metrics for request counts, durations, and active tasks.
### Available Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `fastmcp.requests` | Counter | Total MCP requests by method and status |
| `fastmcp.request.duration` | Histogram | Request duration in milliseconds |
| `fastmcp.provider.duration` | Histogram | Provider query duration in milliseconds |
| `fastmcp.tasks.active` | UpDownCounter | Number of active background tasks |
### Metric Dimensions
**`fastmcp.requests`**
- `method`: MCP method (e.g., `tools/call`)
- `status`: Request status (`success` or `error`)
**`fastmcp.request.duration`**
- `method`: MCP method
**`fastmcp.provider.duration`**
- `provider_type`: Provider class name
## Example: Jaeger Setup
Here's a complete example using Jaeger for trace visualization:
```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from fastmcp import FastMCP
# Configure Jaeger exporter
jaeger_exporter = JaegerExporter(
agent_host_name="localhost",
agent_port=6831,
)
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(jaeger_exporter))
trace.set_tracer_provider(provider)
# Create server with tools
mcp = FastMCP("demo-server")
@mcp.tool()
def calculate(expression: str) -> float:
"""Evaluate a math expression."""
return eval(expression)
@mcp.tool()
def fetch_data(url: str) -> str:
"""Fetch data from a URL."""
import httpx
return httpx.get(url).text
if __name__ == "__main__":
mcp.run()
```
View traces at http://localhost:16686 after running Jaeger:
```bash
docker run -d --name jaeger \
-p 16686:16686 \
-p 6831:6831/udp \
jaegertracing/all-in-one:latest
```
## Example: Prometheus Metrics
For metrics collection with Prometheus:
```python
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from fastmcp import FastMCP
# Configure Prometheus exporter
reader = PrometheusMetricReader()
provider = MeterProvider(metric_readers=[reader])
metrics.set_meter_provider(provider)
# Create server
mcp = FastMCP("metrics-demo")
@mcp.tool()
def process(data: str) -> str:
return data.upper()
# Metrics available at /metrics endpoint (requires prometheus-client)
```
## Custom Spans
You can add your own spans using the FastMCP tracer:
```python
from fastmcp import FastMCP
from fastmcp.telemetry import get_tracer
mcp = FastMCP("custom-spans")
@mcp.tool()
async def complex_operation(input: str) -> str:
tracer = get_tracer()
with tracer.start_as_current_span("parse_input") as span:
span.set_attribute("input.length", len(input))
parsed = parse(input)
with tracer.start_as_current_span("process_data") as span:
span.set_attribute("data.count", len(parsed))
result = process(parsed)
return result
```
## Error Handling
When errors occur, spans are automatically marked with error status and the exception is recorded:
```python
@mcp.tool()
def risky_operation() -> str:
raise ValueError("Something went wrong")
# The span will have:
# - status = ERROR
# - exception event with stack trace
```
## Attributes Reference
### Standard Semantic Conventions
FastMCP uses OpenTelemetry semantic conventions where applicable:
| Attribute | Value |
|-----------|-------|
| `rpc.system` | `"mcp"` |
| `rpc.service` | Server name |
| `rpc.method` | MCP protocol method |
| `error.type` | Exception class name (on errors) |
### FastMCP Custom Attributes
All custom attributes use the `fastmcp.` prefix:
| Attribute | Description |
|-----------|-------------|
| `fastmcp.server.name` | Server name |
| `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `template` |
| `fastmcp.component.key` | Full component identifier |
| `fastmcp.provider.type` | Provider class name |
| `fastmcp.provider.name` | Provider instance name |
| `fastmcp.task.id` | Background task identifier |
| `fastmcp.task.mode` | Task execution mode |
| `fastmcp.session.id` | Client session identifier |
## Testing with Telemetry
For testing, use the in-memory exporter:
```python
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp import FastMCP
@pytest.fixture
def trace_exporter():
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
yield exporter
exporter.clear()
async def test_tool_creates_span(trace_exporter):
mcp = FastMCP("test")
@mcp.tool()
def hello() -> str:
return "world"
await mcp.call_tool("hello", {})
spans = trace_exporter.get_finished_spans()
assert any(s.name == "tool hello" for s in spans)
```

View file

@ -0,0 +1 @@
"""FastMCP Diagnostics example - for testing tracing, errors, and observability."""

View file

@ -0,0 +1,161 @@
#!/usr/bin/env python
"""Client script to exercise all diagnostics server components with tracing.
Usage:
# First, start the diagnostics server with tracing in one terminal:
uv run examples/run_with_tracing.py examples/diagnostics/server.py --transport sse --port 8001
# Then run this client in another terminal:
uv run examples/diagnostics/client_with_tracing.py
# View traces in otel-desktop-viewer (http://localhost:8000):
otel-desktop-viewer
This script exercises all 8 components:
- 4 successful: ping, diag://status, diag://echo/{message}, greet prompt
- 4 error: fail_tool, diag://error, diag://error/{code}, fail prompt
"""
from __future__ import annotations
import asyncio
import os
# Configure OTEL SDK before importing fastmcp
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_tracing():
"""Set up OpenTelemetry tracing with OTLP export."""
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
service_name = os.environ.get("OTEL_SERVICE_NAME", "fastmcp-diagnostics-client")
resource = Resource.create({"service.name": service_name})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(endpoint=endpoint, insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
print(f"Tracing enabled: OTLP {endpoint}")
print(f"Service: {service_name}")
print("View traces: otel-desktop-viewer (http://localhost:8000)\n")
async def main():
setup_tracing()
from fastmcp import Client
server_url = os.environ.get("DIAGNOSTICS_SERVER_URL", "http://localhost:8001/sse")
print(f"Connecting to: {server_url}\n")
async with Client(server_url) as client:
# List all available components
tools = await client.list_tools()
resources = await client.list_resources()
prompts = await client.list_prompts()
print(f"Found {len(tools)} tools: {[t.name for t in tools]}")
print(f"Found {len(resources)} resources: {[r.uri for r in resources]}")
print(f"Found {len(prompts)} prompts: {[p.name for p in prompts]}\n")
# === SUCCESSFUL OPERATIONS ===
print("=" * 60)
print("SUCCESSFUL OPERATIONS")
print("=" * 60)
# Local successful components
print("\n--- Local Tools ---")
result = await client.call_tool("ping", {})
print(f"ping: {result}")
print("\n--- Local Resources ---")
result = await client.read_resource("diag://status")
print(f"diag://status: {result}")
result = await client.read_resource("diag://echo/hello-world")
print(f"diag://echo/hello-world: {result}")
print("\n--- Local Prompts ---")
result = await client.get_prompt("greet", {"name": "Diagnostics"})
print(
f"greet: {result.messages[0].content.text if result.messages else result}"
)
# Proxied components from echo server
print("\n--- Proxied Tools ---")
try:
result = await client.call_tool(
"proxied_echo_tool", {"text": "proxied test"}
)
print(f"proxied_echo_tool: {result}")
except Exception as e:
print(f"proxied_echo_tool: ERROR - {e}")
print("\n--- Proxied Resources ---")
try:
# Resource: echo://static -> echo://proxied/static
result = await client.read_resource("echo://proxied/static")
print(f"echo://proxied/static: {result}")
except Exception as e:
print(f"echo://proxied/static: ERROR - {e}")
try:
# Template: echo://{text} -> echo://proxied/{text}
result = await client.read_resource("echo://proxied/test-message")
print(f"echo://proxied/test-message: {result}")
except Exception as e:
print(f"echo://proxied/test-message: ERROR - {e}")
print("\n--- Proxied Prompts ---")
try:
result = await client.get_prompt("proxied_echo", {"text": "proxied prompt"})
print(
f"proxied_echo: {result.messages[0].content.text if result.messages else result}"
)
except Exception as e:
print(f"proxied_echo: ERROR - {e}")
# === ERROR OPERATIONS ===
print("\n" + "=" * 60)
print("ERROR OPERATIONS (expected to fail)")
print("=" * 60)
print("\n--- Error Tools ---")
try:
await client.call_tool("fail_tool", {})
print("fail_tool: UNEXPECTED SUCCESS")
except Exception as e:
print(f"fail_tool: {type(e).__name__} - {e}")
print("\n--- Error Resources ---")
try:
await client.read_resource("diag://error")
print("diag://error: UNEXPECTED SUCCESS")
except Exception as e:
print(f"diag://error: {type(e).__name__} - {e}")
try:
await client.read_resource("diag://error/500")
print("diag://error/500: UNEXPECTED SUCCESS")
except Exception as e:
print(f"diag://error/500: {type(e).__name__} - {e}")
print("\n--- Error Prompts ---")
try:
await client.get_prompt("fail", {})
print("fail: UNEXPECTED SUCCESS")
except Exception as e:
print(f"fail: {type(e).__name__} - {e}")
print("\n" + "=" * 60)
print("DONE - Check otel-desktop-viewer for traces")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,118 @@
"""FastMCP Diagnostics Server - for testing tracing, errors, and observability."""
import os
import subprocess
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from fastmcp import FastMCP
from fastmcp.server import create_proxy
ECHO_SERVER_PORT = 8002
ECHO_SERVER_URL = f"http://localhost:{ECHO_SERVER_PORT}/sse"
@asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[None]:
"""Start echo server subprocess and mount proxy to it."""
echo_path = Path(__file__).parent.parent / "echo.py"
# Pass OTEL config to subprocess with different service name
env = os.environ.copy()
env["OTEL_SERVICE_NAME"] = "fastmcp-echo-server"
# Start echo server as subprocess using run_with_tracing.py for OTEL export
run_with_tracing = Path(__file__).parent.parent / "run_with_tracing.py"
proc = subprocess.Popen(
[
"uv",
"run",
str(run_with_tracing),
str(echo_path),
"--transport",
"sse",
"--port",
str(ECHO_SERVER_PORT),
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for server to be ready
for _ in range(50):
try:
httpx.get(f"http://localhost:{ECHO_SERVER_PORT}/sse", timeout=0.1)
break
except Exception:
time.sleep(0.1)
# Mount proxy to the running echo server
echo_proxy = create_proxy(ECHO_SERVER_URL, name="Echo Proxy")
server.mount(echo_proxy, namespace="proxied")
try:
yield
finally:
proc.terminate()
proc.wait(timeout=5)
mcp = FastMCP("Diagnostics Server", lifespan=lifespan)
# === SUCCESSFUL COMPONENTS ===
@mcp.tool
def ping() -> str:
"""Simple ping tool - always succeeds."""
return "pong"
@mcp.resource("diag://status")
def status_resource() -> str:
"""Status resource - always succeeds."""
return "OK"
@mcp.resource("diag://echo/{message}")
def echo_template(message: str) -> str:
"""Echo template - always succeeds."""
return f"Echo: {message}"
@mcp.prompt("greet")
def greet_prompt(name: str = "World") -> str:
"""Greeting prompt - always succeeds."""
return f"Hello, {name}!"
# === ERROR COMPONENTS ===
@mcp.tool
def fail_tool(message: str = "Intentional tool failure") -> str:
"""Tool that always raises ValueError - for error tracing."""
raise ValueError(message)
@mcp.resource("diag://error")
def error_resource() -> str:
"""Resource that always raises ValueError."""
raise ValueError("Intentional resource failure")
@mcp.resource("diag://error/{code}")
def error_template(code: str) -> str:
"""Template that always raises ValueError."""
raise ValueError(f"Intentional template failure: {code}")
@mcp.prompt("fail")
def fail_prompt() -> str:
"""Prompt that always raises ValueError."""
raise ValueError("Intentional prompt failure")

59
examples/run_with_tracing.py Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python
"""Run a FastMCP server with OpenTelemetry tracing enabled.
Usage:
uv run examples/run_with_tracing.py examples/echo.py --transport sse --port 8001
All arguments after the script name are passed to `fastmcp run`.
Traces are exported via OTLP to localhost:4317.
To view traces, run otel-desktop-viewer in another terminal:
otel-desktop-viewer
# Trace UI at http://localhost:8000, OTLP receiver on :4317
Install otel-desktop-viewer:
brew install nico-barbas/brew/otel-desktop-viewer
"""
import os
import sys
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
# Configure OTEL SDK before importing fastmcp
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
service_name = os.environ.get(
"OTEL_SERVICE_NAME",
f"fastmcp-{os.path.basename(sys.argv[1]).replace('.py', '')}",
)
# Set up tracer provider with OTLP exporter
resource = Resource.create({"service.name": service_name})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(endpoint=endpoint, insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
print(f"Tracing enabled → OTLP {endpoint}", flush=True)
print(f"Service: {service_name}", flush=True)
print("View traces: otel-desktop-viewer (http://localhost:8000)\n", flush=True)
# Now run fastmcp CLI
from fastmcp.cli.cli import app
sys.argv = ["fastmcp", "run"] + sys.argv[1:]
app()
if __name__ == "__main__":
main()

368
loq.toml
View file

@ -1,103 +1,319 @@
# loq configuration - file size enforcement
# Run `loq baseline` to update when files exceed limits
default_max_lines = 1000
default_max_lines = 500
respect_gitignore = true
exclude = ["**/uv.lock", ".git/**", "docs/**"]
[[rules]]
path = "tests/**"
max_lines = 1000
[[rules]]
path = "tests/server/providers/test_local_provider_tools.py"
max_lines = 1554
[[rules]]
path = "tests/client/test_client.py"
max_lines = 1438
[[rules]]
path = "tests/server/test_auth_integration.py"
max_lines = 1242
[[rules]]
path = "tests/server/auth/test_oauth_proxy.py"
max_lines = 1899
[[rules]]
path = "tests/server/middleware/test_middleware.py"
max_lines = 1066
[[rules]]
path = "src/fastmcp/server/context.py"
max_lines = 1272
[[rules]]
path = "tests/tools/test_tool_transform.py"
max_lines = 1748
[[rules]]
path = "tests/server/test_mount.py"
max_lines = 1560
[[rules]]
path = "tests/utilities/test_inspect.py"
max_lines = 1111
[[rules]]
path = "tests/resources/test_resource_template.py"
max_lines = 1009
[[rules]]
path = "tests/server/auth/test_oauth_consent_flow.py"
max_lines = 1056
[[rules]]
path = "src/fastmcp/server/server.py"
max_lines = 2942
[[rules]]
path = "tests/tools/test_tool.py"
max_lines = 1923
[[rules]]
path = "tests/client/test_elicitation.py"
max_lines = 1132
[[rules]]
path = "src/fastmcp/client/client.py"
max_lines = 1722
[[rules]]
path = "tests/utilities/test_json_schema_type.py"
max_lines = 1584
exclude = [
"**/uv.lock",
".git/",
"docs/",
]
[[rules]]
path = "src/fastmcp/server/auth/oauth_proxy.py"
max_lines = 2282
[[rules]]
path = "src/fastmcp/client/transports.py"
max_lines = 1208
path = "tests/client/test_client.py"
max_lines = 1438
[[rules]]
path = "src/fastmcp/client/tasks.py"
max_lines = 551
[[rules]]
path = "tests/server/test_auth_integration.py"
max_lines = 1242
[[rules]]
path = "tests/server/test_dependencies.py"
max_lines = 1046
[[rules]]
path = "tests/prompts/test_prompt.py"
max_lines = 552
[[rules]]
path = "tests/client/test_elicitation.py"
max_lines = 1132
[[rules]]
path = "src/fastmcp/tools/tool_transform.py"
max_lines = 952
[[rules]]
path = "tests/server/middleware/test_tool_injection.py"
max_lines = 508
[[rules]]
path = "tests/server/middleware/test_middleware.py"
max_lines = 1066
[[rules]]
path = "tests/server/auth/providers/test_azure.py"
max_lines = 809
[[rules]]
path = "docs/servers/auth/oauth-proxy.mdx"
max_lines = 590
[[rules]]
path = "src/fastmcp/server/context.py"
max_lines = 1246
[[rules]]
path = "tests/test_mcp_config.py"
max_lines = 930
[[rules]]
path = "docs/deployment/http.mdx"
max_lines = 736
[[rules]]
path = "src/fastmcp/server/auth/providers/jwt.py"
max_lines = 527
[[rules]]
path = "src/fastmcp/server/dependencies.py"
max_lines = 908
[[rules]]
path = "docs/changelog.mdx"
max_lines = 2228
[[rules]]
path = "tests/server/tasks/test_task_mount.py"
max_lines = 965
[[rules]]
path = "src/fastmcp/utilities/json_schema_type.py"
max_lines = 648
[[rules]]
path = "tests/utilities/openapi/test_schemas.py"
max_lines = 641
[[rules]]
path = "src/fastmcp/prompts/prompt.py"
max_lines = 785
[[rules]]
path = "src/fastmcp/utilities/openapi/schemas.py"
max_lines = 593
[[rules]]
path = "src/fastmcp/server/middleware/caching.py"
max_lines = 523
[[rules]]
path = "docs/servers/middleware.mdx"
max_lines = 846
[[rules]]
path = "tests/contrib/test_component_manager.py"
max_lines = 767
[[rules]]
path = "src/fastmcp/server/auth/auth.py"
max_lines = 558
[[rules]]
path = "docs/patterns/tool-transformation.mdx"
max_lines = 694
[[rules]]
path = "tests/server/providers/test_local_provider_resources.py"
max_lines = 974
[[rules]]
path = "docs/deployment/server-configuration.mdx"
max_lines = 640
[[rules]]
path = "tests/cli/test_run.py"
max_lines = 628
[[rules]]
path = "tests/utilities/openapi/test_transitive_references.py"
max_lines = 842
[[rules]]
path = "src/fastmcp/resources/template.py"
max_lines = 571
[[rules]]
path = "tests/server/providers/openapi/test_comprehensive.py"
max_lines = 741
[[rules]]
path = "tests/client/test_sampling.py"
max_lines = 1002
[[rules]]
path = "tests/resources/test_resource_template.py"
max_lines = 1009
[[rules]]
path = "tests/server/providers/test_local_provider.py"
max_lines = 738
[[rules]]
path = "tests/server/middleware/test_caching.py"
max_lines = 616
[[rules]]
path = "tests/server/test_mount.py"
max_lines = 1548
[[rules]]
path = "tests/utilities/test_inspect.py"
max_lines = 1111
[[rules]]
path = "src/fastmcp/utilities/openapi/parser.py"
max_lines = 821
[[rules]]
path = "docs/python-sdk/fastmcp-server-context.mdx"
max_lines = 550
[[rules]]
path = "src/fastmcp/server/providers/local_provider.py"
max_lines = 738
[[rules]]
path = "tests/client/test_notifications.py"
max_lines = 556
[[rules]]
path = "tests/server/middleware/test_logging.py"
max_lines = 670
[[rules]]
path = "tests/utilities/test_json_schema.py"
max_lines = 543
[[rules]]
path = "tests/server/auth/test_jwt_provider.py"
max_lines = 1101
[[rules]]
path = "docs/servers/tools.mdx"
max_lines = 1040
path = "tests/server/auth/test_oauth_proxy.py"
max_lines = 1899
[[rules]]
path = "docs/changelog.mdx"
max_lines = 2280
path = "tests/utilities/test_types.py"
max_lines = 673
[[rules]]
path = "docs/servers/resources.mdx"
max_lines = 727
[[rules]]
path = "src/fastmcp/client/client.py"
max_lines = 2000
[[rules]]
path = "src/fastmcp/server/providers/proxy.py"
max_lines = 882
[[rules]]
path = "src/fastmcp/tools/tool.py"
max_lines = 956
[[rules]]
path = "docs/python-sdk/fastmcp-server-server.mdx"
max_lines = 999
[[rules]]
path = "tests/server/auth/test_oauth_consent_flow.py"
max_lines = 1056
[[rules]]
path = "README.md"
max_lines = 515
[[rules]]
path = "tests/server/providers/proxy/test_proxy_server.py"
max_lines = 711
[[rules]]
path = "tests/tools/test_tool_transform.py"
max_lines = 1741
[[rules]]
path = "src/fastmcp/utilities/ui.py"
max_lines = 626
[[rules]]
path = "tests/server/auth/test_oidc_proxy.py"
max_lines = 878
[[rules]]
path = "docs/patterns/cli.mdx"
max_lines = 581
[[rules]]
path = "tests/cli/test_cli.py"
max_lines = 618
[[rules]]
path = "tests/server/tasks/test_task_return_types.py"
max_lines = 662
[[rules]]
path = "tests/server/providers/test_local_provider_tools.py"
max_lines = 1538
[[rules]]
path = "src/fastmcp/server/providers/fastmcp_provider.py"
max_lines = 581
[[rules]]
path = "tests/server/middleware/test_error_handling.py"
max_lines = 652
[[rules]]
path = "tests/tools/test_tool.py"
max_lines = 1923
[[rules]]
path = "docs/python-sdk/fastmcp-client-client.mdx"
max_lines = 704
[[rules]]
path = "docs/servers/tools.mdx"
max_lines = 1019
[[rules]]
path = "src/fastmcp/server/server.py"
max_lines = 3500
[[rules]]
path = "tests/deprecated/test_import_server.py"
max_lines = 713
[[rules]]
path = "src/fastmcp/client/transports.py"
max_lines = 1210
[[rules]]
path = "docs/docs.json"
max_lines = 593
[[rules]]
path = "src/fastmcp/cli/cli.py"
max_lines = 957
[[rules]]
path = "tests/utilities/test_json_schema_type.py"
max_lines = 1584
[[rules]]
path = "docs/servers/context.mdx"
max_lines = 623
[[rules]]
path = "src/fastmcp/resources/resource.py"
max_lines = 633

View file

@ -9,6 +9,7 @@ dependencies = [
"httpx>=0.28.1",
"mcp>=1.24.0,<2.0",
"openapi-pydantic>=0.5.1",
"opentelemetry-api>=1.20.0",
"platformdirs>=4.0.0",
"rich>=13.9.4",
"cyclopts>=4.0.0",
@ -58,6 +59,7 @@ dev = [
"fastmcp[anthropic,openai,tasks]",
# add optional dependencies for fastmcp dev
"fastapi>=0.115.12",
"opentelemetry-sdk>=1.20.0",
"inline-snapshot[dirty-equals]>=0.27.2",
"ipython>=8.12.3",
"pdbpp>=0.11.7",
@ -78,6 +80,7 @@ dev = [
"ty>=0.0.7",
"prek>=0.2.12",
"loq>=0.1.0a3",
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",
]
[project.scripts]

View file

@ -57,9 +57,11 @@ from fastmcp.client.tasks import (
TaskNotificationHandler,
ToolTask,
)
from fastmcp.client.telemetry import client_span
from fastmcp.exceptions import ToolError
from fastmcp.mcp_config import MCPConfig
from fastmcp.server import FastMCP
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.exceptions import get_catch_handlers
from fastmcp.utilities.json_schema_type import json_schema_to_type
from fastmcp.utilities.logging import get_logger
@ -877,33 +879,42 @@ class Client(Generic[ClientTransportT]):
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
logger.debug(f"[{self.name}] called read_resource: {uri}")
uri_str = str(uri)
with client_span(
f"resource {uri_str}",
"resources/read",
uri_str,
session_id=self.transport.get_session_id(),
):
logger.debug(f"[{self.name}] called read_resource: {uri}")
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl
# If meta provided, use send_request for SEP-1686 task support
if meta:
task_dict = meta.get("modelcontextprotocol.io/task")
request = mcp.types.ReadResourceRequest(
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(**task_dict)
if task_dict
else None, # SEP-1686: task as direct param (spec-compliant)
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
# If meta provided, use send_request for SEP-1686 task support
if propagated_meta:
task_dict = propagated_meta.get("modelcontextprotocol.io/task")
request = mcp.types.ReadResourceRequest(
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
_meta=propagated_meta, # ty: ignore[unknown-argument]
)
)
)
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=mcp.types.ReadResourceResult,
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=mcp.types.ReadResourceResult,
)
)
)
else:
result = await self._await_with_session_monitoring(
self.session.read_resource(uri)
)
return result
else:
result = await self._await_with_session_monitoring(
self.session.read_resource(uri)
)
return result
@overload
async def read_resource(
@ -1089,44 +1100,52 @@ class Client(Generic[ClientTransportT]):
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
logger.debug(f"[{self.name}] called get_prompt: {name}")
with client_span(
f"prompt {name}",
"prompts/get",
name,
session_id=self.transport.get_session_id(),
):
logger.debug(f"[{self.name}] called get_prompt: {name}")
# Serialize arguments for MCP protocol - convert non-string values to JSON
serialized_arguments: dict[str, str] | None = None
if arguments:
serialized_arguments = {}
for key, value in arguments.items():
if isinstance(value, str):
serialized_arguments[key] = value
else:
# Use pydantic_core.to_json for consistent serialization
serialized_arguments[key] = pydantic_core.to_json(value).decode(
"utf-8"
# Serialize arguments for MCP protocol - convert non-string values to JSON
serialized_arguments: dict[str, str] | None = None
if arguments:
serialized_arguments = {}
for key, value in arguments.items():
if isinstance(value, str):
serialized_arguments[key] = value
else:
# Use pydantic_core.to_json for consistent serialization
serialized_arguments[key] = pydantic_core.to_json(value).decode(
"utf-8"
)
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
# If meta provided, use send_request for SEP-1686 task support
if propagated_meta:
task_dict = propagated_meta.get("modelcontextprotocol.io/task")
request = mcp.types.GetPromptRequest(
params=mcp.types.GetPromptRequestParams(
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
_meta=propagated_meta, # ty: ignore[unknown-argument]
)
# If meta provided, use send_request for SEP-1686 task support
if meta:
task_dict = meta.get("modelcontextprotocol.io/task")
request = mcp.types.GetPromptRequest(
params=mcp.types.GetPromptRequestParams(
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(**task_dict)
if task_dict
else None, # SEP-1686: task as direct param (spec-compliant)
)
)
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=mcp.types.GetPromptResult,
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=mcp.types.GetPromptResult,
)
)
)
else:
result = await self._await_with_session_monitoring(
self.session.get_prompt(name=name, arguments=serialized_arguments)
)
return result
else:
result = await self._await_with_session_monitoring(
self.session.get_prompt(name=name, arguments=serialized_arguments)
)
return result
@overload
async def get_prompt(
@ -1373,22 +1392,31 @@ class Client(Generic[ClientTransportT]):
RuntimeError: If called while the client is not connected.
McpError: If the tool call requests results in a TimeoutError | JSONRPCError
"""
logger.debug(f"[{self.name}] called call_tool: {name}")
with client_span(
f"tool {name}",
"tools/call",
name,
session_id=self.transport.get_session_id(),
):
logger.debug(f"[{self.name}] called call_tool: {name}")
# Convert timeout to timedelta if needed
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=float(timeout))
# Convert timeout to timedelta if needed
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=float(timeout))
result = await self._await_with_session_monitoring(
self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=timeout,
progress_callback=progress_handler or self._progress_handler,
meta=meta,
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
result = await self._await_with_session_monitoring(
self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=timeout,
progress_callback=progress_handler or self._progress_handler,
meta=propagated_meta if propagated_meta else None,
)
)
)
return result
return result
async def _parse_call_tool_result(
self, name: str, result: mcp.types.CallToolResult, raise_on_error: bool = False

View file

@ -0,0 +1,40 @@
"""Client-side telemetry helpers."""
from collections.abc import Generator
from contextlib import contextmanager
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
from fastmcp.telemetry import get_tracer
@contextmanager
def client_span(
name: str,
method: str,
component_key: str,
session_id: str | None = None,
) -> Generator[Span, None, None]:
"""Create a CLIENT span with standard MCP attributes.
Automatically records any exception on the span and sets error status.
"""
tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
attrs: dict[str, str] = {
"rpc.system": "mcp",
"rpc.method": method,
"fastmcp.component.key": component_key,
}
if session_id:
attrs["fastmcp.session.id"] = session_id
span.set_attributes(attrs)
try:
yield span
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
raise
__all__ = ["client_span"]

View file

@ -121,6 +121,10 @@ class ClientTransport(abc.ABC):
async def close(self): # noqa: B027
"""Close the transport."""
def get_session_id(self) -> str | None:
"""Get the session ID for this transport, if available."""
return None
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
if auth is not None:
raise ValueError("This transport does not support auth")

View file

@ -392,6 +392,12 @@ class Prompt(FastMCPComponent):
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(arguments)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "prompt",
"fastmcp.provider.type": "LocalProvider",
}
__all__ = [
"Message",

View file

@ -409,6 +409,12 @@ class Resource(FastMCPComponent):
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)()
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "resource",
"fastmcp.provider.type": "LocalProvider",
}
__all__ = [
"Resource",

View file

@ -315,6 +315,12 @@ class ResourceTemplate(FastMCPComponent):
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(params)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "resource_template",
"fastmcp.provider.type": "LocalProvider",
}
class FunctionResourceTemplate(ResourceTemplate):
"""A template for dynamically creating resources."""

View file

@ -24,6 +24,7 @@ from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.telemetry import delegate_span
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
@ -111,9 +112,12 @@ class FastMCPProviderTool(Tool):
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
"""
return await self._server.call_tool(
self._original_name, arguments, task_meta=task_meta
)
with delegate_span(
self._original_name or "", "FastMCPProvider", self._original_name or ""
):
return await self._server.call_tool(
self._original_name, arguments, task_meta=task_meta
)
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Delegate to child server's call_tool() without task_meta.
@ -129,6 +133,12 @@ class FastMCPProviderTool(Tool):
)
return result
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "FastMCPProvider",
"fastmcp.delegate.original_name": self._original_name,
}
class FastMCPProviderResource(Resource):
"""Resource that delegates reading to a wrapped server's read_resource().
@ -180,7 +190,18 @@ class FastMCPProviderResource(Resource):
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
"""
return await self._server.read_resource(self._original_uri, task_meta=task_meta)
with delegate_span(
self._original_uri or "", "FastMCPProvider", self._original_uri or ""
):
return await self._server.read_resource(
self._original_uri, task_meta=task_meta
)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "FastMCPProvider",
"fastmcp.delegate.original_uri": self._original_uri,
}
class FastMCPProviderPrompt(Prompt):
@ -241,9 +262,12 @@ class FastMCPProviderPrompt(Prompt):
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
"""
return await self._server.render_prompt(
self._original_name, arguments, task_meta=task_meta
)
with delegate_span(
self._original_name or "", "FastMCPProvider", self._original_name or ""
):
return await self._server.render_prompt(
self._original_name, arguments, task_meta=task_meta
)
async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult:
"""Delegate to child server's render_prompt() without task_meta.
@ -259,6 +283,12 @@ class FastMCPProviderPrompt(Prompt):
)
return result
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "FastMCPProvider",
"fastmcp.delegate.original_name": self._original_name,
}
class FastMCPProviderResourceTemplate(ResourceTemplate):
"""Resource template that creates FastMCPProviderResources.
@ -339,7 +369,10 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
# Expand the original template with params to get internal URI
original_uri = _expand_uri_template(self._original_uri_template or "", params)
return await self._server.read_resource(original_uri, task_meta=task_meta)
with delegate_span(
original_uri, "FastMCPProvider", self._original_uri_template or ""
):
return await self._server.read_resource(original_uri, task_meta=task_meta)
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
"""Read the resource content for background task execution.
@ -381,6 +414,12 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**params)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "FastMCPProvider",
"fastmcp.delegate.original_uri_template": self._original_uri_template,
}
# -----------------------------------------------------------------------------
# FastMCPProvider

View file

@ -30,6 +30,7 @@ from fastmcp.client.client import Client, FastMCP1Server
from fastmcp.client.elicitation import ElicitResult
from fastmcp.client.logging import LogMessage
from fastmcp.client.roots import RootsList
from fastmcp.client.telemetry import client_span
from fastmcp.client.transports import ClientTransportT
from fastmcp.exceptions import ResourceError, ToolError
from fastmcp.mcp_config import MCPConfig
@ -112,40 +113,51 @@ class ProxyTool(Tool):
context: Context | None = None,
) -> ToolResult:
"""Executes the tool by making a call through the client."""
client = await self._get_client()
async with client:
context = get_context()
# Build meta dict from request context
meta: dict[str, Any] | None = None
if hasattr(context, "request_context"):
req_ctx = context.request_context
# Start with existing meta if present
if hasattr(req_ctx, "meta") and req_ctx.meta:
meta = dict(req_ctx.meta)
# Add task metadata if this is a task request
if (
hasattr(req_ctx, "experimental")
and hasattr(req_ctx.experimental, "is_task")
and req_ctx.experimental.is_task
):
task_metadata = req_ctx.experimental.task_metadata
if task_metadata:
meta = meta or {}
meta["modelcontextprotocol.io/task"] = task_metadata.model_dump(
exclude_none=True
)
backend_name = self._backend_name or self.name
with client_span(
f"proxy tool {backend_name}", "tools/call", backend_name
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
async with client:
context = get_context()
# Build meta dict from request context
meta: dict[str, Any] | None = None
if hasattr(context, "request_context"):
req_ctx = context.request_context
# Start with existing meta if present
if hasattr(req_ctx, "meta") and req_ctx.meta:
meta = dict(req_ctx.meta)
# Add task metadata if this is a task request
if (
hasattr(req_ctx, "experimental")
and hasattr(req_ctx.experimental, "is_task")
and req_ctx.experimental.is_task
):
task_metadata = req_ctx.experimental.task_metadata
if task_metadata:
meta = meta or {}
meta["modelcontextprotocol.io/task"] = (
task_metadata.model_dump(exclude_none=True)
)
result = await client.call_tool_mcp(
name=self._backend_name or self.name, arguments=arguments, meta=meta
result = await client.call_tool_mcp(
name=backend_name, arguments=arguments, meta=meta
)
if result.isError:
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
# Preserve backend's meta (includes task metadata for background tasks)
return ToolResult(
content=result.content,
structured_content=result.structuredContent,
meta=result.meta,
)
if result.isError:
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
# Preserve backend's meta (includes task metadata for background tasks)
return ToolResult(
content=result.content,
structured_content=result.structuredContent,
meta=result.meta,
)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_name": self._backend_name,
}
class ProxyResource(Resource):
@ -209,37 +221,47 @@ class ProxyResource(Resource):
return self._cached_content
backend_uri = self._backend_uri or str(self.uri)
client = await self._get_client()
async with client:
result = await client.read_resource(backend_uri)
if not result:
raise ResourceError(
f"Remote server returned empty content for {backend_uri}"
)
# Process all items in the result list, not just the first one
contents: list[ResourceContent] = []
for item in result:
if isinstance(item, TextResourceContents):
contents.append(
ResourceContent(
content=item.text,
mime_type=item.mimeType,
meta=item.meta,
)
with client_span(
f"proxy resource {backend_uri}", "resources/read", backend_uri
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
async with client:
result = await client.read_resource(backend_uri)
if not result:
raise ResourceError(
f"Remote server returned empty content for {backend_uri}"
)
elif isinstance(item, BlobResourceContents):
contents.append(
ResourceContent(
content=base64.b64decode(item.blob),
mime_type=item.mimeType,
meta=item.meta,
)
)
else:
raise ResourceError(f"Unsupported content type: {type(item)}")
return ResourceResult(contents=contents)
# Process all items in the result list, not just the first one
contents: list[ResourceContent] = []
for item in result:
if isinstance(item, TextResourceContents):
contents.append(
ResourceContent(
content=item.text,
mime_type=item.mimeType,
meta=item.meta,
)
)
elif isinstance(item, BlobResourceContents):
contents.append(
ResourceContent(
content=base64.b64decode(item.blob),
mime_type=item.mimeType,
meta=item.meta,
)
)
else:
raise ResourceError(f"Unsupported content type: {type(item)}")
return ResourceResult(contents=contents)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_uri": self._backend_uri,
}
class ProxyTemplate(ResourceTemplate):
@ -350,6 +372,12 @@ class ProxyTemplate(ResourceTemplate):
_cached_content=cached_content,
)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_uri_template": self._backend_uri_template,
}
class ProxyPrompt(Prompt):
"""A Prompt that represents and renders a prompt from a remote server."""
@ -404,18 +432,31 @@ class ProxyPrompt(Prompt):
async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override]
"""Render the prompt by making a call through the client."""
client = await self._get_client()
async with client:
result = await client.get_prompt(self._backend_name or self.name, arguments)
# Convert GetPromptResult to PromptResult, preserving runtime meta from the result
# (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages
messages = [Message(content=m.content, role=m.role) for m in result.messages]
return PromptResult(
messages=messages,
description=result.description,
meta=result.meta,
)
backend_name = self._backend_name or self.name
with client_span(
f"proxy prompt {backend_name}", "prompts/get", backend_name
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
async with client:
result = await client.get_prompt(backend_name, arguments)
# Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages
messages = [
Message(content=m.content, role=m.role) for m in result.messages
]
return PromptResult(
messages=messages,
description=result.description,
meta=result.meta,
)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_name": self._backend_name,
}
# -----------------------------------------------------------------------------

View file

@ -84,6 +84,7 @@ from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.providers import LocalProvider, Provider
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.server.telemetry import server_span
from fastmcp.server.transforms import (
Namespace,
ToolTransform,
@ -159,6 +160,7 @@ Transport = Literal["stdio", "http", "sse", "streamable-http"]
# Compiled URI parsing regex to split a URI into protocol and path components
URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
LifespanCallable = Callable[
["FastMCP[LifespanResultT]"], AbstractAsyncContextManager[LifespanResultT]
]
@ -1470,23 +1472,26 @@ class FastMCP(Generic[LifespanResultT]):
)
# Core logic: find and execute tool (providers queried in parallel)
tool = await self.get_tool(name)
# Set fn_key for background task routing
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=tool.key)
try:
return await tool._run(arguments or {}, task_meta=task_meta)
except FastMCPError:
logger.exception(f"Error calling tool {name!r}")
raise
except (ValidationError, PydanticValidationError):
logger.exception(f"Error validating tool {name!r}")
raise
except Exception as e:
logger.exception(f"Error calling tool {name!r}")
if self._mask_error_details:
raise ToolError(f"Error calling tool {name!r}") from e
raise ToolError(f"Error calling tool {name!r}: {e}") from e
with server_span(
f"tool {name}", "tools/call", self.name, "tool", name
) as span:
tool = await self.get_tool(name)
span.set_attributes(tool.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=tool.key)
try:
return await tool._run(arguments or {}, task_meta=task_meta)
except FastMCPError:
logger.exception(f"Error calling tool {name!r}")
raise
except (ValidationError, PydanticValidationError):
logger.exception(f"Error validating tool {name!r}")
raise
except Exception as e:
logger.exception(f"Error calling tool {name!r}")
if self._mask_error_details:
raise ToolError(f"Error calling tool {name!r}") from e
raise ToolError(f"Error calling tool {name!r}: {e}") from e
@overload
async def read_resource(
@ -1561,44 +1566,47 @@ class FastMCP(Generic[LifespanResultT]):
)
# Core logic: find and read resource (providers queried in parallel)
# Try concrete resources first
try:
resource = await self.get_resource(uri)
# Set fn_key for background task routing
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=resource.key)
return await resource._read(task_meta=task_meta)
except NotFoundError:
pass # Fall through to try templates
except (FastMCPError, McpError):
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
if self._mask_error_details:
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
with server_span(
f"resource {uri}", "resources/read", self.name, "resource", uri
) as span:
# Try concrete resources first
try:
resource = await self.get_resource(uri)
span.set_attributes(resource.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=resource.key)
return await resource._read(task_meta=task_meta)
except NotFoundError:
pass # Fall through to try templates
except (FastMCPError, McpError):
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
if self._mask_error_details:
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
# Try templates
try:
template = await self.get_resource_template(uri)
except NotFoundError:
raise NotFoundError(f"Unknown resource: {uri!r}") from None
params = template.matches(uri)
assert params is not None # get_resource_template already verified match
# Set fn_key for background task routing
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=template.key)
try:
return await template._read(uri, params, task_meta=task_meta)
except (FastMCPError, McpError):
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
if self._mask_error_details:
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
# Try templates
try:
template = await self.get_resource_template(uri)
except NotFoundError:
raise NotFoundError(f"Unknown resource: {uri!r}") from None
span.set_attributes(template.get_span_attributes())
params = template.matches(uri)
assert params is not None
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=template.key)
try:
return await template._read(uri, params, task_meta=task_meta)
except (FastMCPError, McpError):
logger.exception(f"Error reading resource {uri!r}")
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
if self._mask_error_details:
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
@overload
async def render_prompt(
@ -1672,20 +1680,23 @@ class FastMCP(Generic[LifespanResultT]):
)
# Core logic: find and render prompt (providers queried in parallel)
prompt = await self.get_prompt(name)
# Set fn_key for background task routing
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=prompt.key)
try:
return await prompt._render(arguments, task_meta=task_meta)
except (FastMCPError, McpError):
logger.exception(f"Error rendering prompt {name!r}")
raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
if self._mask_error_details:
raise PromptError(f"Error rendering prompt {name!r}") from e
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
with server_span(
f"prompt {name}", "prompts/get", self.name, "prompt", name
) as span:
prompt = await self.get_prompt(name)
span.set_attributes(prompt.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=prompt.key)
try:
return await prompt._render(arguments, task_meta=task_meta)
except (FastMCPError, McpError):
logger.exception(f"Error rendering prompt {name!r}")
raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
if self._mask_error_details:
raise PromptError(f"Error rendering prompt {name!r}") from e
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
def custom_route(
self,

View file

@ -0,0 +1,118 @@
"""Server-side telemetry helpers."""
from collections.abc import Generator
from contextlib import contextmanager
from mcp.server.lowlevel.server import request_ctx
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
from fastmcp.telemetry import extract_trace_context, get_tracer
def get_auth_span_attributes() -> dict[str, str]:
"""Get auth attributes for the current request, if authenticated."""
from fastmcp.server.dependencies import get_access_token
attrs: dict[str, str] = {}
try:
token = get_access_token()
if token:
if token.client_id:
attrs["enduser.id"] = token.client_id
if token.scopes:
attrs["enduser.scope"] = " ".join(token.scopes)
except RuntimeError:
pass
return attrs
def get_session_span_attributes() -> dict[str, str]:
"""Get session attributes for the current request."""
from fastmcp.server.dependencies import get_context
attrs: dict[str, str] = {}
try:
ctx = get_context()
if ctx.request_context is not None:
attrs["fastmcp.session.id"] = ctx.session_id
except RuntimeError:
pass
return attrs
def _get_parent_trace_context():
"""Get parent trace context from request meta for distributed tracing."""
try:
req_ctx = request_ctx.get()
if req_ctx and hasattr(req_ctx, "meta") and req_ctx.meta:
return extract_trace_context(dict(req_ctx.meta))
except LookupError:
pass
return None
@contextmanager
def server_span(
name: str,
method: str,
server_name: str,
component_type: str,
component_key: str,
) -> Generator[Span, None, None]:
"""Create a SERVER span with standard MCP attributes and auth context.
Automatically records any exception on the span and sets error status.
"""
tracer = get_tracer()
with tracer.start_as_current_span(
name,
context=_get_parent_trace_context(),
kind=SpanKind.SERVER,
) as span:
span.set_attributes(
{
"rpc.system": "mcp",
"rpc.service": server_name,
"rpc.method": method,
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
)
try:
yield span
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
raise
@contextmanager
def delegate_span(
name: str,
provider_type: str,
component_key: str,
) -> Generator[Span, None, None]:
"""Create an INTERNAL span for provider delegation.
Used by FastMCPProvider when delegating to mounted servers.
"""
tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span:
span.set_attributes(
{
"fastmcp.provider.type": provider_type,
"fastmcp.component.key": component_key,
}
)
yield span
__all__ = [
"delegate_span",
"get_auth_span_attributes",
"get_session_span_attributes",
"server_span",
]

125
src/fastmcp/telemetry.py Normal file
View file

@ -0,0 +1,125 @@
"""OpenTelemetry instrumentation for FastMCP.
This module provides native OpenTelemetry integration for FastMCP servers and clients.
It uses only the opentelemetry-api package, so telemetry is a no-op unless the user
installs an OpenTelemetry SDK and configures exporters.
Example usage with SDK:
```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
# Configure the SDK (user responsibility)
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
# Now FastMCP will emit traces
from fastmcp import FastMCP
mcp = FastMCP("my-server")
```
"""
from typing import Any
from opentelemetry import context as otel_context
from opentelemetry import propagate
from opentelemetry.context import Context
from opentelemetry.metrics import get_meter as otel_get_meter
from opentelemetry.trace import Span, Status, StatusCode, Tracer
from opentelemetry.trace import get_tracer as otel_get_tracer
INSTRUMENTATION_NAME = "fastmcp"
TRACE_PARENT_KEY = "fastmcp.traceparent"
TRACE_STATE_KEY = "fastmcp.tracestate"
def get_tracer(version: str | None = None) -> Tracer:
"""Get the FastMCP tracer for creating spans.
Args:
version: Optional version string for the instrumentation
Returns:
A tracer instance. Returns a no-op tracer if no SDK is configured.
"""
return otel_get_tracer(INSTRUMENTATION_NAME, version)
def get_meter(version: str | None = None):
"""Get the FastMCP meter for recording metrics.
Args:
version: Optional version string for the instrumentation
Returns:
A meter instance. Returns a no-op meter if no SDK is configured.
"""
return otel_get_meter(INSTRUMENTATION_NAME, version or "")
def inject_trace_context(meta: dict[str, Any] | None = None) -> dict[str, Any]:
"""Inject current trace context into a meta dict for MCP request propagation.
Args:
meta: Optional existing meta dict to merge with trace context
Returns:
A new dict containing the original meta (if any) plus trace context keys
"""
carrier: dict[str, str] = {}
propagate.inject(carrier)
trace_meta: dict[str, Any] = {}
if "traceparent" in carrier:
trace_meta[TRACE_PARENT_KEY] = carrier["traceparent"]
if "tracestate" in carrier:
trace_meta[TRACE_STATE_KEY] = carrier["tracestate"]
if trace_meta:
return {**(meta or {}), **trace_meta}
return meta or {}
def record_span_error(span: Span, exception: BaseException) -> None:
"""Record an exception on a span and set error status."""
span.record_exception(exception)
span.set_status(Status(StatusCode.ERROR))
def extract_trace_context(meta: dict[str, Any] | None) -> Context:
"""Extract trace context from an MCP request meta dict.
Args:
meta: The meta dict from an MCP request (ctx.request_context.meta)
Returns:
An OpenTelemetry Context with the extracted trace context,
or the current context if no trace context found
"""
if not meta:
return otel_context.get_current()
carrier: dict[str, str] = {}
if TRACE_PARENT_KEY in meta:
carrier["traceparent"] = str(meta[TRACE_PARENT_KEY])
if TRACE_STATE_KEY in meta:
carrier["tracestate"] = str(meta[TRACE_STATE_KEY])
if carrier:
return propagate.extract(carrier)
return otel_context.get_current()
__all__ = [
"INSTRUMENTATION_NAME",
"TRACE_PARENT_KEY",
"TRACE_STATE_KEY",
"extract_trace_context",
"get_meter",
"get_tracer",
"inject_trace_context",
"record_span_error",
]

View file

@ -394,6 +394,12 @@ class Tool(FastMCPComponent):
meta=meta,
)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "tool",
"fastmcp.provider.type": "LocalProvider",
}
def _serialize_with_fallback(
result: Any, serializer: ToolResultSerializerType | None = None

View file

@ -182,3 +182,10 @@ class FastMCPComponent(FastMCPBaseModel):
raise NotImplementedError(
f"{self.__class__.__name__} does not implement add_to_docket()"
)
def get_span_attributes(self) -> dict[str, Any]:
"""Return span attributes for telemetry.
Subclasses should call super() and merge their specific attributes.
"""
return {"fastmcp.component.key": self.key}

View file

View file

@ -0,0 +1,476 @@
"""Tests for client OpenTelemetry tracing."""
from __future__ import annotations
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 "tool greet" span
assert "tool 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 == "tool 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["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "tools/call"
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 "resource data://config" span
assert "resource 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("resource 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["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "resources/read"
# 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 "prompt greeting" span
assert "prompt 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 == "prompt 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["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "prompts/get"
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 == "tool 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 == "tool 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 server_span is not None, "Server should create a span"
# 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 == "tool 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 == "tool 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 == "tool 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 == "tool 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("resource 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("resource 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 == "prompt 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 == "prompt 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 == "tool 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 == "tool 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

View file

@ -1,11 +1,15 @@
import asyncio
import socket
import sys
from collections.abc import Callable
from collections.abc import Callable, Generator
from pathlib import Path
from typing import Any
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp.utilities.tests import temporary_settings
@ -81,3 +85,29 @@ def free_port_factory(worker_id):
return port
return get_port
@pytest.fixture(scope="session")
def otel_trace_provider() -> Generator[
tuple[TracerProvider, InMemorySpanExporter], None, None
]:
"""Configure OTEL SDK with in-memory span exporter for testing.
Session-scoped because TracerProvider can only be set once per process.
"""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
yield provider, exporter
@pytest.fixture
def trace_exporter(
otel_trace_provider: tuple[TracerProvider, InMemorySpanExporter],
) -> Generator[InMemorySpanExporter, None, None]:
"""Get the span exporter and clear it between tests."""
_, exporter = otel_trace_provider
exporter.clear()
yield exporter
exporter.clear()

View file

@ -0,0 +1 @@
"""Tests for FastMCP server telemetry."""

View file

@ -0,0 +1,129 @@
"""Tests for provider-level OpenTelemetry tracing."""
from __future__ import annotations
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp import FastMCP
class TestFastMCPProviderTracing:
"""Tests for FastMCPProvider delegation tracing."""
async def test_mounted_tool_creates_delegate_span(
self, trace_exporter: InMemorySpanExporter
):
# Create a child server with a tool
child = FastMCP("child-server")
@child.tool()
def child_tool() -> str:
return "child result"
# Create parent server and mount child with namespace
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
# Call the tool through the parent (namespace uses underscore, not slash)
result = await parent.call_tool("child_child_tool", {})
assert "child result" in str(result)
# Check spans: should have parent tool span, delegate span, and child tool span
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Parent server creates "tool child_child_tool"
assert "tool child_child_tool" in span_names
# FastMCPProvider creates "delegate child_tool"
assert "delegate child_tool" in span_names
# Child server creates "tool child_tool"
assert "tool child_tool" in span_names
# Verify delegate span has correct attributes
delegate_span = next(s for s in spans if s.name == "delegate child_tool")
assert delegate_span.attributes["fastmcp.provider.type"] == "FastMCPProvider"
assert delegate_span.attributes["fastmcp.component.key"] == "child_tool"
async def test_mounted_resource_creates_delegate_span(
self, trace_exporter: InMemorySpanExporter
):
# Create a child server with a resource
child = FastMCP("child-server")
@child.resource("data://config")
def child_config() -> str:
return "config data"
# Create parent server and mount child with namespace
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
# Read the resource through the parent (namespace is in the URI path)
result = await parent.read_resource("data://child/config")
assert "config data" in str(result)
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Should have delegate span for resource
assert any(
"delegate" in name and "data://config" in name for name in span_names
)
async def test_mounted_prompt_creates_delegate_span(
self, trace_exporter: InMemorySpanExporter
):
# Create a child server with a prompt
child = FastMCP("child-server")
@child.prompt()
def child_prompt() -> str:
return "Hello from child!"
# Create parent server and mount child with namespace
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
# Render the prompt through the parent (namespace uses underscore)
result = await parent.render_prompt("child_child_prompt", {})
assert "Hello from child!" in str(result)
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Should have delegate span for prompt
assert "delegate child_prompt" in span_names
# Verify delegate span has correct attributes
delegate_span = next(s for s in spans if s.name == "delegate child_prompt")
assert delegate_span.attributes["fastmcp.provider.type"] == "FastMCPProvider"
class TestProviderSpanHierarchy:
"""Tests for span parent-child relationships in mounted servers."""
async def test_delegate_span_is_child_of_server_span(
self, trace_exporter: InMemorySpanExporter
):
# Create nested server structure
child = FastMCP("child")
@child.tool()
def greet() -> str:
return "Hello"
parent = FastMCP("parent")
parent.mount(child, namespace="ns")
await parent.call_tool("ns_greet", {})
spans = trace_exporter.get_finished_spans()
# Find the spans
parent_span = next(s for s in spans if s.name == "tool ns_greet")
delegate_span = next(s for s in spans if s.name == "delegate greet")
child_span = next(s for s in spans if s.name == "tool greet")
# Verify parent-child relationships
assert delegate_span.parent.span_id == parent_span.context.span_id
assert child_span.parent.span_id == delegate_span.context.span_id

View file

@ -0,0 +1,323 @@
"""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 == "tool greet"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "tools/call"
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 == "tool failing_tool"
assert span.status.status_code == StatusCode.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 == "tool nonexistent"
assert span.status.status_code == StatusCode.ERROR
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 == "resource config://app"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "resources/read"
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 == "resource users://123/profile"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
assert span.attributes["rpc.method"] == "resources/read"
# 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 == "resource nonexistent://resource"
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 == "prompt greeting"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "prompts/get"
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 == "prompt 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

View file

@ -0,0 +1 @@
"""Tests for FastMCP telemetry module."""

View file

@ -0,0 +1,66 @@
"""Tests for the core telemetry module."""
from __future__ import annotations
from opentelemetry.metrics import Meter, NoOpMeter
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import NonRecordingSpan, Tracer
from fastmcp.server.telemetry import get_auth_span_attributes
from fastmcp.telemetry import INSTRUMENTATION_NAME, get_meter, get_tracer
class TestGetTracer:
def test_returns_tracer(self):
tracer = get_tracer()
assert isinstance(tracer, Tracer)
def test_returns_tracer_with_version(self):
tracer = get_tracer("1.0.0")
assert isinstance(tracer, Tracer)
def test_tracer_uses_instrumentation_name(
self, trace_exporter: InMemorySpanExporter
):
tracer = get_tracer()
with tracer.start_as_current_span("test-span"):
pass
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
scope = spans[0].instrumentation_scope
assert scope is not None
assert scope.name == INSTRUMENTATION_NAME
def test_tracer_noop_without_sdk(self):
# Without SDK configured, spans are non-recording
from opentelemetry.trace import ProxyTracer
tracer = get_tracer()
# ProxyTracer wraps real or no-op tracer
assert isinstance(tracer, (Tracer, ProxyTracer))
span = tracer.start_span("test")
# Non-recording spans don't capture data
assert isinstance(span, NonRecordingSpan) or span.is_recording()
class TestGetMeter:
def test_returns_meter(self):
meter = get_meter()
assert isinstance(meter, (Meter, NoOpMeter))
def test_returns_meter_with_version(self):
meter = get_meter("1.0.0")
assert isinstance(meter, (Meter, NoOpMeter))
class TestInstrumentationName:
def test_instrumentation_name(self):
assert INSTRUMENTATION_NAME == "fastmcp"
class TestGetAuthSpanAttributes:
def test_returns_empty_dict_when_no_context(self):
# No request context available
attrs = get_auth_span_attributes()
assert attrs == {}

967
uv.lock generated

File diff suppressed because it is too large Load diff