Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/more/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |

## Telemetry

| Environment Variable | Type | Default | Description |
|---|---|---|---|
| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only"]` | `native` | Controls FastMCP's native OpenTelemetry span creation. `native` emits FastMCP MCP spans and propagates trace context. `propagation_only` keeps `_meta` trace propagation but suppresses FastMCP's own spans so another instrumentation layer can own the MCP span hierarchy. |

## Transport & HTTP

These control how the server listens when running with an HTTP transport.
Expand Down
29 changes: 26 additions & 3 deletions docs/servers/telemetry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ FastMCP includes native OpenTelemetry instrumentation for observability. Traces

FastMCP uses the OpenTelemetry API for instrumentation. This means:

- **Zero configuration required** - Instrumentation is always active
- **Zero configuration required** - Native FastMCP instrumentation is available by default
- **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.

FastMCP also propagates OpenTelemetry context through MCP `params._meta`, including `traceparent`, `tracestate`, and `baggage` when present.

## Enabling Telemetry

The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically:
Expand Down Expand Up @@ -52,11 +54,11 @@ This works with any OTLP-compatible backend (Jaeger, Zipkin, Grafana Tempo, Data

## Tracing

FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling.
FastMCP creates spans for the MCP operations it currently instruments, 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/):
The server creates spans for each supported operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):

| Span Name | Description |
|-----------|-------------|
Expand Down Expand Up @@ -119,6 +121,27 @@ def greet(name: str) -> str:
The SDK must be configured **before** importing FastMCP to ensure the tracer provider is set when FastMCP initializes.
</Tip>

## Interoperability Mode

If another MCP-aware instrumentation layer should own the MCP spans, switch FastMCP to propagation-only mode:

```bash
export FASTMCP_TELEMETRY_MODE=propagation_only
```

In this mode, FastMCP still injects and extracts trace context through MCP `_meta`, but it stops creating its own MCP request spans and `delegate` spans.

Library authors can suppress only FastMCP's native spans programmatically without disabling unrelated nested instrumentation:

```python
from fastmcp.telemetry import suppress_fastmcp_telemetry

with suppress_fastmcp_telemetry():
result = await client.call_tool("search", {"query": "otel"})
```

FastMCP server spans follow the MCP semantic conventions for mixed transport environments: when propagated MCP trace context is present in `_meta`, FastMCP uses that extracted context as the server-span parent and records any ambient transport span (for example an HTTP request span) as a span link instead.

### Local Development

For quick local trace visualization, [otel-desktop-viewer](https://github.com/CtrlSpice/otel-desktop-viewer) is a lightweight single-binary tool:
Expand Down
95 changes: 92 additions & 3 deletions src/fastmcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
import mcp.types
from exceptiongroup import catch
from mcp import ClientSession, McpError
from mcp.client.session import (
SUPPORTED_PROTOCOL_VERSIONS,
_default_elicitation_callback,
_default_list_roots_callback,
_default_sampling_callback,
)
from mcp.types import GetTaskResult, TaskStatusNotification
from pydantic import AnyUrl

Expand Down Expand Up @@ -51,8 +57,10 @@
TaskNotificationHandler,
ToolTask,
)
from fastmcp.client.telemetry import client_span
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.logging import get_logger
from fastmcp.utilities.timeout import (
Expand Down Expand Up @@ -518,12 +526,93 @@ async def initialize(
timeout = normalize_timeout_to_seconds(timeout)

try:
with anyio.fail_after(timeout):
self._session_state.initialize_result = await self.session.initialize()
return self._session_state.initialize_result
with client_span(
"initialize",
"initialize",
"",
session_id=self.transport.get_session_id(),
):
with anyio.fail_after(timeout):
propagated_meta = inject_trace_context()
if propagated_meta is None:
self._session_state.initialize_result = (
await self.session.initialize()
)
else:
self._session_state.initialize_result = (
await self._initialize_session_with_meta(propagated_meta)
)
return self._session_state.initialize_result
except TimeoutError as e:
raise RuntimeError("Failed to initialize server session") from e

async def _initialize_session_with_meta(
self,
meta: dict[str, Any],
) -> mcp.types.InitializeResult:
"""Send an InitializeRequest that preserves the client's capability config.

This method accesses private session attributes (``_sampling_capabilities``,
``_elicitation_callback``, etc.) to reconstruct an InitializeRequest that
preserves the client's original capability configuration. This is necessary
because the MCP SDK requires explicit capability objects at initialization
time, and there is no public API to retrieve the currently configured
capabilities from a session. This fragility is a known limitation of the
underlying MCP client SDK; any API surface to expose these would need to
be proposed to the MCP spec/SDK project.
"""
session = self.session

sampling = (
(session._sampling_capabilities or mcp.types.SamplingCapability())
if session._sampling_callback is not _default_sampling_callback
else None
)
elicitation = (
mcp.types.ElicitationCapability(
form=mcp.types.FormElicitationCapability(),
url=mcp.types.UrlElicitationCapability(),
)
if session._elicitation_callback is not _default_elicitation_callback
else None
)
roots = (
mcp.types.RootsCapability(listChanged=True)
if session._list_roots_callback is not _default_list_roots_callback
else None
)

result = await session.send_request(
mcp.types.ClientRequest(
mcp.types.InitializeRequest(
params=mcp.types.InitializeRequestParams(
protocolVersion=mcp.types.LATEST_PROTOCOL_VERSION,
capabilities=mcp.types.ClientCapabilities(
sampling=sampling,
elicitation=elicitation,
experimental=None,
roots=roots,
tasks=session._task_handlers.build_capability(),
),
clientInfo=session._client_info,
_meta=meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument]
)
)
),
mcp.types.InitializeResult,
)

if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
raise RuntimeError(
f"Unsupported protocol version from the server: {result.protocolVersion}"
)

session._server_capabilities = result.capabilities
await session.send_notification(
mcp.types.ClientNotification(mcp.types.InitializedNotification())
)
return result

async def __aenter__(self):
return await self._connect()

Expand Down
10 changes: 9 additions & 1 deletion src/fastmcp/client/mixins/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,16 @@ async def list_prompts_mcp(
):
logger.debug(f"[{self.name}] called list_prompts")

propagated_meta = inject_trace_context()
params = None
if cursor is not None or propagated_meta is not None:
params = mcp.types.PaginatedRequestParams(
cursor=cursor,
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument]
)

result = await self._await_with_session_monitoring(
self.session.list_prompts(cursor=cursor)
self.session.list_prompts(params=params)
)
return result

Expand Down
20 changes: 18 additions & 2 deletions src/fastmcp/client/mixins/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,16 @@ async def list_resources_mcp(
):
logger.debug(f"[{self.name}] called list_resources")

propagated_meta = inject_trace_context()
params = None
if cursor is not None or propagated_meta is not None:
params = mcp.types.PaginatedRequestParams(
cursor=cursor,
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument]
)

result = await self._await_with_session_monitoring(
self.session.list_resources(cursor=cursor)
self.session.list_resources(params=params)
)
return result

Expand Down Expand Up @@ -132,8 +140,16 @@ async def list_resource_templates_mcp(
):
logger.debug(f"[{self.name}] called list_resource_templates")

propagated_meta = inject_trace_context()
params = None
if cursor is not None or propagated_meta is not None:
params = mcp.types.PaginatedRequestParams(
cursor=cursor,
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument]
)

result = await self._await_with_session_monitoring(
self.session.list_resource_templates(cursor=cursor)
self.session.list_resource_templates(params=params)
)
return result

Expand Down
10 changes: 9 additions & 1 deletion src/fastmcp/client/mixins/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,16 @@ async def list_tools_mcp(
):
logger.debug(f"[{self.name}] called list_tools")

propagated_meta = inject_trace_context()
params = None
if cursor is not None or propagated_meta is not None:
params = mcp.types.PaginatedRequestParams(
cursor=cursor,
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument]
)

result = await self._await_with_session_monitoring(
self.session.list_tools(cursor=cursor)
self.session.list_tools(params=params)
)
return result

Expand Down
6 changes: 5 additions & 1 deletion src/fastmcp/client/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from opentelemetry.trace import Span, SpanKind, Status, StatusCode

from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import get_tracer
from fastmcp.telemetry import get_noop_span, get_tracer, native_telemetry_enabled


@contextmanager
Expand All @@ -23,6 +23,10 @@ def client_span(

Automatically records any exception on the span and sets error status.
"""
if not native_telemetry_enabled():
yield get_noop_span()
return

tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
if span.is_recording():
Expand Down
8 changes: 8 additions & 0 deletions src/fastmcp/server/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
EventStore,
)
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from opentelemetry import trace
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
Expand All @@ -28,6 +29,8 @@

logger = get_logger(__name__)

AMBIENT_SPAN_CONTEXT_SCOPE_KEY = "fastmcp.ambient_span_context"


class StreamableHTTPASGIApp:
"""ASGI application wrapper for Streamable HTTP server transport."""
Expand All @@ -41,6 +44,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
raise RuntimeError(
"Task group is not initialized. Make sure to use run()."
)
ambient_span_context = trace.get_current_span().get_span_context()
if ambient_span_context.is_valid:
scope[AMBIENT_SPAN_CONTEXT_SCOPE_KEY] = ambient_span_context
else:
scope.pop(AMBIENT_SPAN_CONTEXT_SCOPE_KEY, None)
await self.session_manager.handle_request(scope, receive, send)
except RuntimeError as e:
if str(e) == "Task group is not initialized. Make sure to use run().":
Expand Down
Loading
Loading