mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Server (servers/tasks.mdx) and client (clients/tasks.mdx) docs rewritten for
the extension model: add_extension(TasksExtension()), the guard pattern for
in-task input (no imperative ctx.elicit()), tools-only, and the modern-protocol
requirement (the inverse of the old SEP-1686 legacy-only note). Mechanical
fixes elsewhere for the same reason: telemetry.mdx's tasks/{operation} method
list (get/update/cancel, not result/list), client.mdx's legacy-only feature
list (tasks moved to modern-only) and extension-composition paragraph
(describes the tasks ClientExtension, not the removed notification binding),
and stale SEP-1686 references in the FastMCP 2 upgrade guide. v4-notes status
lines updated to Shipped (#4602, #4603).
401 lines
15 KiB
Text
401 lines
15 KiB
Text
---
|
|
title: OpenTelemetry
|
|
sidebarTitle: Telemetry
|
|
description: Native OpenTelemetry instrumentation for distributed tracing.
|
|
icon: chart-line
|
|
tag: NEW
|
|
---
|
|
|
|
FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, resource template, and task management operations, providing visibility into server behavior, request handling, and provider delegation chains.
|
|
|
|
## How It Works
|
|
|
|
FastMCP uses the OpenTelemetry API for instrumentation. This means:
|
|
|
|
- **On by default** - Instrumentation is active out of the box, no opt-in required
|
|
- **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.
|
|
|
|
Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection.
|
|
|
|
### Turning Telemetry Off
|
|
|
|
To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured.
|
|
|
|
## Enabling Telemetry
|
|
|
|
The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically:
|
|
|
|
```bash
|
|
pip install opentelemetry-distro opentelemetry-exporter-otlp
|
|
opentelemetry-bootstrap -a install
|
|
```
|
|
|
|
Then run your server with tracing enabled:
|
|
|
|
```bash
|
|
opentelemetry-instrument \
|
|
--service_name my-fastmcp-server \
|
|
--exporter_otlp_endpoint http://localhost:4317 \
|
|
fastmcp run server.py
|
|
```
|
|
|
|
Or configure via environment variables:
|
|
|
|
```bash
|
|
export OTEL_SERVICE_NAME=my-fastmcp-server
|
|
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
|
|
|
opentelemetry-instrument fastmcp run server.py
|
|
```
|
|
|
|
This works with any OTLP-compatible backend (Jaeger, Zipkin, Grafana Tempo, Datadog, etc.) and requires no changes to your FastMCP code.
|
|
|
|
<Card title="OpenTelemetry Python Documentation" icon="book" href="https://opentelemetry.io/docs/languages/python/">
|
|
Learn more about the OpenTelemetry Python SDK, auto-instrumentation, and available exporters.
|
|
</Card>
|
|
|
|
## Tracing
|
|
|
|
FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling.
|
|
|
|
### Server Spans
|
|
|
|
The server creates spans for each operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
|
|
|
|
| Span Name | Description |
|
|
|-----------|-------------|
|
|
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
|
|
| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
|
|
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
|
|
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/update`, or `tasks/cancel`) |
|
|
|
|
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
|
|
|
|
### Client Spans
|
|
|
|
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`, and `tasks/{operation}`).
|
|
|
|
### Span Hierarchy
|
|
|
|
Spans form a hierarchy showing the request flow. For mounted servers:
|
|
|
|
```
|
|
tools/call weather_forecast (CLIENT)
|
|
└── tools/call weather_forecast (SERVER, provider=FastMCPProvider)
|
|
└── delegate get_weather (INTERNAL)
|
|
└── tools/call get_weather (SERVER, provider=LocalProvider)
|
|
```
|
|
|
|
For proxy providers connecting to remote servers:
|
|
|
|
```
|
|
tools/call remote_search (CLIENT)
|
|
└── tools/call remote_search (SERVER, provider=ProxyProvider)
|
|
└── [remote server spans via trace context propagation]
|
|
```
|
|
|
|
### Background tasks
|
|
|
|
Background task traces have two parts:
|
|
|
|
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/update`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
|
|
- Deferred execution runs in a Docket worker. Docket records its `CONSUMER` span as a new trace root with a span link to the submission context, rather than making it a child of the submission span. Custom spans created inside the task are children of that worker span.
|
|
|
|
Span links preserve the causal relationship without forcing worker sampling to inherit the submit trace's sampling decision. Some tracing backends do not display links prominently, so the worker trace may look disconnected even though the link is present.
|
|
|
|
Frequent status polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
|
|
|
|
```python
|
|
from opentelemetry import trace
|
|
from opentelemetry.sdk.trace import TracerProvider
|
|
from opentelemetry.sdk.trace.sampling import (
|
|
ALWAYS_ON,
|
|
Decision,
|
|
ParentBased,
|
|
Sampler,
|
|
SamplingResult,
|
|
)
|
|
|
|
|
|
class DropTaskPolls(Sampler):
|
|
def __init__(self):
|
|
self._delegate = ParentBased(ALWAYS_ON)
|
|
|
|
def should_sample(self, parent_context, trace_id, name, *args, **kwargs):
|
|
if name in {"tasks/get"}:
|
|
return SamplingResult(Decision.DROP)
|
|
return self._delegate.should_sample(
|
|
parent_context,
|
|
trace_id,
|
|
name,
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
|
|
def get_description(self):
|
|
return "DropTaskPolls"
|
|
|
|
|
|
provider = TracerProvider(sampler=DropTaskPolls())
|
|
trace.set_tracer_provider(provider)
|
|
```
|
|
|
|
The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled.
|
|
|
|
## Programmatic Configuration
|
|
|
|
For more control, configure the SDK in your Python code before importing FastMCP:
|
|
|
|
```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(endpoint="http://localhost:4317"))
|
|
provider.add_span_processor(processor)
|
|
trace.set_tracer_provider(provider)
|
|
|
|
# Now import and use FastMCP - traces will be exported automatically
|
|
from fastmcp import FastMCP
|
|
|
|
mcp = FastMCP("my-server")
|
|
|
|
@mcp.tool()
|
|
def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
```
|
|
|
|
<Tip>
|
|
The SDK must be configured **before** importing FastMCP to ensure the tracer provider is set when FastMCP initializes.
|
|
</Tip>
|
|
|
|
### Local Development
|
|
|
|
For quick local trace visualization, [otel-desktop-viewer](https://github.com/CtrlSpice/otel-desktop-viewer) is a lightweight single-binary tool:
|
|
|
|
```bash
|
|
# macOS
|
|
brew install nico-barbas/brew/otel-desktop-viewer
|
|
|
|
# Or download from GitHub releases
|
|
```
|
|
|
|
Run it alongside your server:
|
|
|
|
```bash
|
|
# Terminal 1: Start the viewer (UI at http://localhost:8000, OTLP on :4317)
|
|
otel-desktop-viewer
|
|
|
|
# Terminal 2: Run your server with tracing
|
|
opentelemetry-instrument fastmcp run server.py
|
|
```
|
|
|
|
For more features, use [Jaeger](https://www.jaegertracing.io/):
|
|
|
|
```bash
|
|
docker run -d --name jaeger \
|
|
-p 16686:16686 \
|
|
-p 4317:4317 \
|
|
jaegertracing/all-in-one:latest
|
|
```
|
|
|
|
Then view traces at http://localhost:16686
|
|
|
|
## 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
|
|
```
|
|
|
|
### Where custom spans help most
|
|
|
|
Custom spans are most useful around work that is expensive or hard to debug:
|
|
|
|
- External calls such as databases, vector stores, HTTP APIs, or queue operations
|
|
- Multi-step tool logic where one stage dominates latency
|
|
- Prompt or resource generation that fans out to other systems
|
|
- Sampling calls made from inside a tool via `ctx.sample(...)`
|
|
|
|
Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret.
|
|
|
|
### Recommended naming and attributes
|
|
|
|
- Use `{tool_name}.{operation}` or `{resource_name}.{operation}` for child spans such as `search.fetch`, `search.rank`, or `docs.render`
|
|
- Add attributes that explain workload shape, such as counts, sizes, cache hits, or IDs
|
|
- Do not record secrets, prompts with sensitive user data, or raw tokens as span attributes
|
|
- Let exceptions propagate unless you have a specific recovery path; FastMCP's server spans already mark failures and record exceptions
|
|
|
|
### Instrumenting tools, prompts, and resources
|
|
|
|
```python
|
|
from fastmcp import FastMCP
|
|
from fastmcp.telemetry import get_tracer
|
|
|
|
mcp = FastMCP("my-server")
|
|
|
|
@mcp.tool
|
|
async def search(query: str) -> str:
|
|
tracer = get_tracer()
|
|
|
|
with tracer.start_as_current_span("search.fetch") as span:
|
|
span.set_attribute("search.query_length", len(query))
|
|
results = await fetch_results(query)
|
|
span.set_attribute("search.result_count", len(results))
|
|
|
|
with tracer.start_as_current_span("search.rank"):
|
|
ranked = rank_results(results)
|
|
|
|
return format_results(ranked)
|
|
|
|
@mcp.prompt
|
|
async def summarize_prompt(topic: str) -> str:
|
|
tracer = get_tracer()
|
|
with tracer.start_as_current_span("summarize_prompt.render") as span:
|
|
span.set_attribute("prompt.topic_length", len(topic))
|
|
return f"Summarize the latest updates about {topic}."
|
|
|
|
@mcp.resource("docs://{slug}")
|
|
async def docs_resource(slug: str) -> str:
|
|
tracer = get_tracer()
|
|
with tracer.start_as_current_span("docs_resource.load") as span:
|
|
span.set_attribute("docs.slug", slug)
|
|
return await load_doc(slug)
|
|
```
|
|
|
|
### Sampling calls inside tools
|
|
|
|
If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together.
|
|
|
|
For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span.
|
|
|
|
### Exporter choices
|
|
|
|
- For local debugging, `ConsoleSpanExporter` or `otel-desktop-viewer` gives quick feedback with minimal setup
|
|
- For shared environments, use OTLP exporters to backends like Logfire, Jaeger, Tempo, Datadog, or New Relic
|
|
- If traces are too noisy, tune sampling in your OpenTelemetry SDK instead of removing FastMCP instrumentation
|
|
|
|
## 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 with exception message as description
|
|
# - error.type = "tool_error" (or exception class name for non-tool errors)
|
|
# - exception event with stack trace
|
|
```
|
|
|
|
## Attributes Reference
|
|
|
|
<Warning>
|
|
**Migrating from v3.1 or earlier:** The `rpc.system`, `rpc.service`, and `rpc.method` span attributes were removed in favor of the [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) listed below. If you have dashboards or alerts keyed on those `rpc.*` attributes, update them to use `mcp.method.name` and the `fastmcp.*` attributes instead.
|
|
</Warning>
|
|
|
|
### MCP Semantic Conventions
|
|
|
|
FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
|
|
|
|
| Attribute | Description |
|
|
|-----------|-------------|
|
|
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`, `tasks/get`, etc.) |
|
|
| `mcp.protocol.version` | The negotiated MCP protocol version for the request |
|
|
| `mcp.session.id` | Session identifier for the MCP connection |
|
|
| `mcp.resource.uri` | The resource URI (for resource operations) |
|
|
| `gen_ai.tool.name` | Tool name (on `tools/call` spans) |
|
|
| `gen_ai.prompt.name` | Prompt name (on `prompts/get` spans) |
|
|
| `error.type` | Error classification (`tool_error` for ToolError, otherwise exception class name) |
|
|
|
|
### Auth Attributes
|
|
|
|
Standard [identity attributes](https://opentelemetry.io/docs/specs/semconv/attributes-registry/enduser/):
|
|
|
|
| Attribute | Description |
|
|
|-----------|-------------|
|
|
| `enduser.id` | Client ID from access token (when authenticated) |
|
|
| `enduser.scope` | Space-separated OAuth scopes (when authenticated) |
|
|
|
|
### FastMCP Custom Attributes
|
|
|
|
All custom attributes use the `fastmcp.` prefix for features unique to FastMCP:
|
|
|
|
| Attribute | Description |
|
|
|-----------|-------------|
|
|
| `fastmcp.server.name` | Server name |
|
|
| `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `resource_template` |
|
|
| `fastmcp.component.key` | Full component key, including type and version delimiter (e.g., `tool:greet@` or `tool:greet@v2`) |
|
|
| `fastmcp.provider.type` | Provider class (`LocalProvider`, `FastMCPProvider`, `ProxyProvider`) |
|
|
|
|
Provider-specific attributes for delegation context:
|
|
|
|
| Attribute | Description |
|
|
|-----------|-------------|
|
|
| `fastmcp.delegate.original_name` | Original tool/prompt name before namespacing |
|
|
| `fastmcp.delegate.original_uri` | Original resource URI before namespacing |
|
|
| `fastmcp.proxy.backend_name` | Remote server tool/prompt name |
|
|
| `fastmcp.proxy.backend_uri` | Remote server resource URI |
|
|
|
|
## Testing with Telemetry
|
|
|
|
For testing, use the in-memory exporter:
|
|
|
|
```python
|
|
import pytest
|
|
from collections.abc import Generator
|
|
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() -> Generator[InMemorySpanExporter, None, None]:
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
original_provider = trace.get_tracer_provider()
|
|
trace.set_tracer_provider(provider)
|
|
yield exporter
|
|
exporter.clear()
|
|
trace.set_tracer_provider(original_provider)
|
|
|
|
async def test_tool_creates_span(trace_exporter: InMemorySpanExporter) -> None:
|
|
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 == "tools/call hello" for s in spans)
|
|
```
|