feat: Add telemetry interop mode for FastMCP (#4046)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Bill Easton 2026-07-27 15:05:29 -05:00 committed by GitHub
commit 75b9f92504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 494 additions and 45 deletions

View file

@ -176,11 +176,13 @@ SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`.
### Telemetry on by default, with an explicit off-switch — Absorbed
### Telemetry on by default, with a three-way mode setting — Absorbed
FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions.
FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. `FASTMCP_TELEMETRY_MODE` (`fastmcp.settings.telemetry_mode`, default `native`) controls how much is active: `native` emits spans and propagates trace context; `propagation_only` emits no FastMCP spans but still extracts the incoming `_meta` context and attaches it, so downstream spans are parented to the calling trace; `off` is a full pass-through that touches neither spans nor context. The setting governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a single block for library authors who own the MCP hierarchy for one operation rather than process-wide; it cannot override `off`. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions.
*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`.
`propagation_only` is applied at the seam span, which is where the incoming `_meta` parent context is established for the whole request; suppressing only the deeper `server_span` would leave the per-request SERVER span intact and defeat the mode.
*Verify:* `fastmcp_slim/fastmcp/settings.py` (`telemetry_mode`); `fastmcp_slim/fastmcp/telemetry.py` (`telemetry_mode`, `get_tracer`, `suppress_fastmcp_telemetry`); `fastmcp_slim/fastmcp/server/telemetry.py` (`_propagation_only_span`, `seam_span`, `get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`; `tests/telemetry/test_interop.py`.
### Spec-correct error codes via a central translator — Breaking (wire error code)

View file

@ -103,7 +103,7 @@ This workstream also owns the server-side statelessness design holes — `ctx.se
A cluster of protocol features tracked for v4. Their statuses have diverged:
- **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache.
- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_ENABLE_TELEMETRY=false` off-switch.
- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_TELEMETRY_MODE` setting (`native` / `propagation_only` / `off`).
- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2).
- **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus.

View file

@ -45,7 +45,7 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
| **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). |
| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. |
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_TELEMETRY_MODE` selects `native`, `propagation_only` (interop with an outer MCP instrumentation layer), or `off`. |
| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](background-tasks.md) for the design and [servers/tasks](https://gofastmcp.com/servers/tasks) for usage. |
## Still in the program

View file

@ -77,7 +77,7 @@ These control how the server listens when running with an HTTP transport.
| Environment Variable | Type | Default | Description |
|---|---|---|---|
| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. |
| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only", "off"]` | `native` | Controls FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry). `native` emits FastMCP's MCP spans and propagates trace context; because FastMCP uses only the OpenTelemetry API, this costs almost nothing unless an SDK and exporter are configured. `propagation_only` keeps `_meta` trace propagation and still parents downstream spans from the incoming context, but emits none of FastMCP's own spans, so another instrumentation layer can own the MCP span hierarchy. `off` is a full pass-through: no spans, and no trace context extracted or attached. |
## Tasks (Docket)

View file

@ -21,11 +21,21 @@ FastMCP uses the OpenTelemetry API for instrumentation. This means:
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
### Telemetry Modes
<VersionBadge version="4.0.0" />
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.
`FASTMCP_TELEMETRY_MODE` (or `fastmcp.settings.telemetry_mode`) controls how much of the instrumentation is active:
| Mode | FastMCP spans | Trace context |
|---|---|---|
| `native` (default) | Emitted | Propagated |
| `propagation_only` | Suppressed | Propagated |
| `off` | Suppressed | Untouched |
Use `off` to disable FastMCP's instrumentation entirely. No spans are created even if an SDK is configured, and FastMCP leaves the surrounding OpenTelemetry context exactly as it found it.
Use `propagation_only` when another instrumentation layer already owns the MCP span hierarchy — see [Interoperability](#interoperability) below.
## Enabling Telemetry
@ -148,6 +158,37 @@ 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.
## Interoperability
<VersionBadge version="4.0.0" />
FastMCP assumes it owns the MCP span hierarchy. When something else already owns it — an MCP-aware OpenTelemetry instrumentation library, or a service mesh that understands the protocol — FastMCP's spans duplicate what that layer already emits, and the same request shows up twice in your traces.
Setting `propagation_only` resolves the duplication in FastMCP's favor of the other layer:
```bash
export FASTMCP_TELEMETRY_MODE=propagation_only
```
The distinction from `off` matters here. Both emit no FastMCP spans, but `off` is fully transparent, while `propagation_only` still extracts the trace context arriving in `_meta` and attaches it for the duration of the request. Spans created downstream — by your tool handlers, or by the instrumentation layer that owns the hierarchy — are parented to the calling trace rather than starting a new one. Outbound requests still carry `traceparent` and `tracestate` in `_meta`.
### Suppressing spans for a single block
Library authors embedding FastMCP inside their own instrumented stack often want to own the hierarchy for one specific operation rather than process-wide. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a block:
```python
from fastmcp import Client
from fastmcp.telemetry import suppress_fastmcp_telemetry
async def search(client: Client, query: str):
with suppress_fastmcp_telemetry():
return await client.call_tool("search", {"query": query})
```
This is narrower than OpenTelemetry's global instrumentation suppression: only FastMCP's spans are skipped, so nested instrumentation for HTTP clients, databases, and everything else keeps emitting normally.
The context manager has no effect when `telemetry_mode` is already `off`. A request to skip FastMCP's spans cannot re-enable the context propagation that `off` deliberately omits.
## Programmatic Configuration
For more control, configure the SDK in your Python code before importing FastMCP:

View file

@ -4,14 +4,23 @@ from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from opentelemetry import context as otel_context
from opentelemetry.context import Context
from opentelemetry.trace import Span, SpanKind, Status, StatusCode, get_current_span
from opentelemetry.trace import (
INVALID_SPAN,
Span,
SpanKind,
Status,
StatusCode,
get_current_span,
)
from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import (
extract_trace_context,
get_tracer,
restore_dropped_attributes,
telemetry_mode,
)
# Marker attribute set on the SERVER span opened at the FastMCP middleware seam
@ -87,6 +96,32 @@ def _get_parent_trace_context() -> Context | None:
return None
@contextmanager
def _propagation_only_span() -> Generator[Span, None, None]:
"""Attach the incoming `_meta` trace context without creating a span.
This is what separates `propagation_only` from `off`. Both create no
FastMCP spans, but `off` is fully transparent while `propagation_only`
still has to *parent* whatever the request goes on to do: without the
attach here, the trace context carried in `_meta` would be extracted and
then thrown away, and a span created inside a tool handler by the user or
by the outer instrumentation layer that owns the MCP hierarchy would
start a brand new trace instead of continuing the caller's.
Yields `INVALID_SPAN`, which is non-recording, so callers' `is_recording()`
guards skip attribute and error bookkeeping on it.
"""
parent_context = _get_parent_trace_context()
if parent_context is None:
yield INVALID_SPAN
return
token = otel_context.attach(parent_context)
try:
yield INVALID_SPAN
finally:
otel_context.detach(token)
def _build_server_span_attrs(
method: str,
server_name: str,
@ -138,7 +173,16 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
opening a second one. Exceptions raised anywhere below the seam including
rejections *before* the high-level path (auth, not-found, middleware vetoes)
that would otherwise produce no SERVER span at all are recorded here.
In `propagation_only` mode no span is opened at all this is the one place
that has to know the difference, because the seam is where the incoming
`_meta` parent context is applied for the whole request.
"""
if telemetry_mode() == "propagation_only":
with _propagation_only_span() as span:
yield span
return
attrs = {
SEAM_SPAN_MARKER: True,
"mcp.method.name": method,
@ -198,7 +242,17 @@ def server_span(
new SERVER span as before.
Automatically records any exception on the span and sets error status.
In `propagation_only` mode no span is opened or enriched. The seam has
normally already attached the incoming parent context for this request;
doing it again here is a no-op, and covers the in-process callers that
bypass the dispatcher and so never reach the seam at all.
"""
if telemetry_mode() == "propagation_only":
with _propagation_only_span() as span:
yield span
return
attrs = _build_server_span_attrs(
method,
server_name,

View file

@ -20,6 +20,8 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env")
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
TELEMETRY_MODE = Literal["native", "propagation_only", "off"]
MCP_LOG_LEVEL = Literal[
"debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"
]
@ -104,24 +106,6 @@ class Settings(BaseSettings):
),
] = True
enable_telemetry: Annotated[
bool,
Field(
description=inspect.cleandoc(
"""
Whether FastMCP's native OpenTelemetry instrumentation is active.
Enabled by default: FastMCP uses only the OpenTelemetry API, so
span creation is a no-op with negligible overhead unless an
OpenTelemetry SDK and exporter are configured. Set to False to
turn instrumentation off entirely, in which case FastMCP's span
helpers become a transparent pass-through: no FastMCP spans are
created even when an SDK is configured, and the surrounding OTel
trace context is left untouched.
"""
)
),
] = True
deprecation_warnings: Annotated[
bool,
Field(
@ -167,6 +151,31 @@ class Settings(BaseSettings):
),
] = True
telemetry_mode: Annotated[
TELEMETRY_MODE,
Field(
description=inspect.cleandoc(
"""
Controls FastMCP's native OpenTelemetry instrumentation.
- `native` (default): FastMCP creates MCP spans and propagates
trace context through request `_meta`. FastMCP uses only the
OpenTelemetry API, so span creation is a no-op with negligible
overhead unless an SDK and exporter are configured.
- `propagation_only`: FastMCP still injects and extracts trace
context, and still parents downstream spans from the incoming
`_meta` context, but creates none of its own MCP spans. Use
this when another instrumentation layer owns the MCP span
hierarchy and FastMCP's spans would duplicate it.
- `off`: FastMCP's span helpers become a transparent
pass-through. No spans are created even when an SDK is
configured, and the surrounding OTel context is left
untouched no trace context is extracted or attached.
"""
),
),
] = "native"
client_init_timeout: Annotated[
float | None,
Field(

View file

@ -23,7 +23,7 @@ Example usage with SDK:
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import Any, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from opentelemetry import context as otel_context
from opentelemetry import propagate, trace
@ -40,6 +40,9 @@ from opentelemetry.trace import (
from opentelemetry.trace import get_tracer as otel_get_tracer
from opentelemetry.util import types as otel_types
if TYPE_CHECKING:
from fastmcp.settings import TELEMETRY_MODE as TelemetryMode
INSTRUMENTATION_NAME = "fastmcp"
TRACE_PARENT_KEY = "traceparent"
@ -77,28 +80,71 @@ class _DisabledTracer(NoOpTracer):
_DISABLED_TRACER = _DisabledTracer()
_SUPPRESS_KEY = otel_context.create_key("fastmcp_suppress_telemetry")
def telemetry_mode() -> "TelemetryMode":
"""Resolve the effective telemetry mode for the current context.
This is `fastmcp.settings.telemetry_mode`, except that an active
`suppress_fastmcp_telemetry()` block downgrades `native` to
`propagation_only`. Suppression never upgrades or overrides `off`: `off`
means FastMCP touches nothing, and a narrower request to skip FastMCP's
spans cannot re-enable the context propagation `off` deliberately omits.
"""
import fastmcp
mode: TelemetryMode = fastmcp.settings.telemetry_mode
if mode == "native" and otel_context.get_value(_SUPPRESS_KEY):
return "propagation_only"
return mode
def native_spans_enabled() -> bool:
"""Whether FastMCP should create its own spans right now."""
return telemetry_mode() == "native"
@contextmanager
def suppress_fastmcp_telemetry() -> Iterator[None]:
"""Suppress FastMCP's own spans without disabling trace propagation.
Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that
embed FastMCP inside their own instrumented stack and want to own the MCP
span hierarchy for a specific block. Narrower than OpenTelemetry's global
instrumentation suppression: only FastMCP's spans are skipped, so nested
instrumentation (HTTP clients, databases) keeps emitting, and trace context
still flows through `_meta` so those spans are parented correctly.
Has no effect when `telemetry_mode` is already `off`.
"""
token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True))
try:
yield
finally:
otel_context.detach(token)
def get_tracer(version: str | None = None) -> Tracer:
"""Get the FastMCP tracer for creating spans.
Instrumentation is on by default. FastMCP uses only the OpenTelemetry API,
so span creation is a no-op with negligible overhead unless an OpenTelemetry
SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to
False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off
entirely, in which case this returns a pass-through tracer that leaves the
current OTel context untouched even when an SDK is configured.
SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is
`propagation_only` or `off` or the caller is inside a
`suppress_fastmcp_telemetry()` block this returns a pass-through tracer
that creates no spans and leaves the current OTel context untouched even
when an SDK is configured.
Args:
version: Optional version string for the instrumentation
Returns:
A tracer instance. Returns a non-attaching pass-through tracer if
telemetry is disabled; span creation is otherwise a no-op unless an SDK
is configured.
A tracer instance. Returns a non-attaching pass-through tracer when
FastMCP's own spans are disabled; span creation is otherwise a no-op
unless an SDK is configured.
"""
import fastmcp
if not fastmcp.settings.enable_telemetry:
if not native_spans_enabled():
return _DISABLED_TRACER
return otel_get_tracer(INSTRUMENTATION_NAME, version)
@ -115,6 +161,11 @@ def inject_trace_context(
A new dict containing the original meta (if any) plus trace context keys,
or None if no trace context to inject and meta was None
"""
# `off` means FastMCP touches nothing, outbound propagation included.
# `propagation_only` still injects — carrying context is the whole point.
if telemetry_mode() == "off":
return meta
carrier: dict[str, str] = {}
propagate.inject(carrier)
@ -222,6 +273,10 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context:
An OpenTelemetry Context with the extracted trace context,
or the current context if no trace context found or already in a trace
"""
# `off` means FastMCP touches nothing, including the surrounding context.
if telemetry_mode() == "off":
return otel_context.get_current()
# Don't override existing trace context (e.g., from HTTP propagation)
current_span = trace.get_current_span()
if current_span.get_span_context().is_valid:
@ -237,7 +292,12 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context:
carrier["tracestate"] = str(meta[TRACE_STATE_KEY])
if carrier:
return propagate.extract(carrier)
# Extract *onto the current context* rather than a fresh root, so the
# incoming parent is added without discarding context values the
# caller already established — active baggage, and FastMCP's own
# suppression marker, which would otherwise be dropped the moment the
# extracted context is attached.
return propagate.extract(carrier, context=otel_context.get_current())
return otel_context.get_current()
@ -248,6 +308,9 @@ __all__ = [
"extract_trace_context",
"get_tracer",
"inject_trace_context",
"native_spans_enabled",
"record_span_error",
"restore_dropped_attributes",
"suppress_fastmcp_telemetry",
"telemetry_mode",
]

View file

@ -587,14 +587,14 @@ class TestTelemetryEnabledByDefault:
"""Instrumentation is on by default and controllable via the off-switch.
FastMCP uses only the OpenTelemetry API, so spans are created unconditionally
and light up when an SDK is configured. `FASTMCP_ENABLE_TELEMETRY=false`
(`fastmcp.settings.enable_telemetry`) turns span creation off entirely, so no
and light up when an SDK is configured. `FASTMCP_TELEMETRY_MODE=off`
(`fastmcp.settings.telemetry_mode`) turns span creation off entirely, so no
FastMCP spans are exported even with an SDK configured.
"""
async def test_spans_fire_by_default(self, trace_exporter: InMemorySpanExporter):
"""No opt-in required: a tool call produces a span out of the box."""
assert fastmcp.settings.enable_telemetry is True
assert fastmcp.settings.telemetry_mode == "native"
mcp = FastMCP("test-server")
@ -615,7 +615,7 @@ class TestTelemetryEnabledByDefault:
):
"""With telemetry disabled, no spans are created even with an SDK
configured (the exporter fixture installs one)."""
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
mcp = FastMCP("test-server")
@ -641,7 +641,7 @@ class TestTelemetryEnabledByDefault:
are governed by the user's OpenTelemetry SDK, not FastMCP's off-switch,
so they may still appear the assertion filters them out.
"""
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
mcp = FastMCP("test-server")
@ -676,7 +676,7 @@ class TestTelemetryEnabledByDefault:
`trace.get_current_span()` inside a handler must still return the
caller's enclosing span, and attributes written there must land on it.
"""
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
tracer = trace.get_tracer("test-enclosing")
captured: dict[str, Span] = {}

View file

@ -0,0 +1,280 @@
"""Tests for telemetry interoperability modes.
Validates that FastMCP's own spans can be suppressed — globally via
`telemetry_mode` or per-block via `suppress_fastmcp_telemetry()` while trace
context propagation keeps working in `propagation_only` mode and is fully
disabled in `off` mode.
"""
from __future__ import annotations
import pytest
from opentelemetry import context as otel_context
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import INVALID_SPAN, SpanKind
import fastmcp
from fastmcp import Client, Context, FastMCP
from fastmcp.client.telemetry import client_span
from fastmcp.server.telemetry import delegate_span, server_span
from fastmcp.telemetry import (
extract_trace_context,
inject_trace_context,
native_spans_enabled,
suppress_fastmcp_telemetry,
telemetry_mode,
)
# A well-formed W3C traceparent for extraction tests.
TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
@pytest.fixture
def mode(monkeypatch: pytest.MonkeyPatch):
"""Set `fastmcp.settings.telemetry_mode` for the duration of a test."""
def _set(value: str) -> None:
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", value)
return _set
def fastmcp_spans(exporter: InMemorySpanExporter) -> list[str]:
"""Names of spans emitted by FastMCP's own instrumentation scope."""
return [
s.name
for s in exporter.get_finished_spans()
if s.instrumentation_scope is not None
and s.instrumentation_scope.name == "fastmcp"
]
class TestTelemetryModeResolution:
def test_native_by_default(self):
assert telemetry_mode() == "native"
assert native_spans_enabled()
@pytest.mark.parametrize("value", ["propagation_only", "off"])
def test_setting_disables_native_spans(self, value: str, mode):
mode(value)
assert telemetry_mode() == value
assert not native_spans_enabled()
def test_suppress_downgrades_native_to_propagation_only(self):
with suppress_fastmcp_telemetry():
assert telemetry_mode() == "propagation_only"
assert not native_spans_enabled()
assert telemetry_mode() == "native"
def test_suppress_cannot_override_off(self, mode):
"""`off` means FastMCP touches nothing. A narrower request to skip
FastMCP's spans must not re-enable the propagation `off` omits."""
mode("off")
with suppress_fastmcp_telemetry():
assert telemetry_mode() == "off"
def test_suppress_nests(self):
with suppress_fastmcp_telemetry():
with suppress_fastmcp_telemetry():
assert not native_spans_enabled()
# Outer suppression still active after the inner block exits.
assert not native_spans_enabled()
assert native_spans_enabled()
def test_suppress_restores_on_exception(self):
with pytest.raises(RuntimeError):
with suppress_fastmcp_telemetry():
raise RuntimeError("boom")
assert native_spans_enabled()
class TestSpanHelperSuppression:
"""Every FastMCP span helper goes quiet when its own spans are disabled."""
@pytest.fixture
def helpers(self):
return {
"server": lambda: server_span(
name="test_op",
method="tools/call",
server_name="test-server",
component_type="tool",
component_key="tool://test",
),
"client": lambda: client_span(
name="test_client",
method="tools/call",
component_key="tool://test",
),
"delegate": lambda: delegate_span(
name="test_delegate",
provider_type="FastMCPProvider",
component_key="tool://test",
),
}
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
@pytest.mark.parametrize("value", ["propagation_only", "off"])
def test_helper_emits_nothing(
self,
helper: str,
value: str,
helpers,
mode,
trace_exporter: InMemorySpanExporter,
):
mode(value)
with helpers[helper]() as span:
assert span is INVALID_SPAN
assert trace_exporter.get_finished_spans() == ()
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
def test_helper_emits_nothing_under_suppress(
self, helper: str, helpers, trace_exporter: InMemorySpanExporter
):
with suppress_fastmcp_telemetry():
with helpers[helper]() as span:
assert span is INVALID_SPAN
assert trace_exporter.get_finished_spans() == ()
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
def test_helper_emits_by_default(
self, helper: str, helpers, trace_exporter: InMemorySpanExporter
):
with helpers[helper]():
pass
assert len(trace_exporter.get_finished_spans()) == 1
class TestContextPropagation:
"""`propagation_only` keeps trace context flowing; `off` does not."""
def test_extract_preserves_current_context_values(self):
"""Regression: extracting the incoming traceparent must not discard
context values the caller already established. Extracting onto a fresh
root would drop FastMCP's own suppression marker (and any baggage), so
attaching the result would silently re-enable FastMCP's spans.
"""
with suppress_fastmcp_telemetry():
parent = extract_trace_context({"traceparent": TRACEPARENT})
token = otel_context.attach(parent)
try:
assert telemetry_mode() == "propagation_only"
finally:
otel_context.detach(token)
def test_extract_applies_incoming_parent(self, mode):
mode("propagation_only")
parent = extract_trace_context({"traceparent": TRACEPARENT})
token = otel_context.attach(parent)
try:
span_context = otel_trace.get_current_span().get_span_context()
assert format(span_context.trace_id, "032x") == (
"4bf92f3577b34da6a3ce929d0e0e4736"
)
finally:
otel_context.detach(token)
def test_off_ignores_incoming_parent(self, mode):
"""`off` is a full pass-through: the incoming context is not applied."""
mode("off")
parent = extract_trace_context({"traceparent": TRACEPARENT})
assert parent is otel_context.get_current()
def test_off_does_not_inject(self, mode, trace_exporter: InMemorySpanExporter):
mode("off")
with otel_trace.get_tracer("test").start_as_current_span("root"):
assert inject_trace_context({"existing": 1}) == {"existing": 1}
def test_propagation_only_still_injects(
self, mode, trace_exporter: InMemorySpanExporter
):
mode("propagation_only")
with otel_trace.get_tracer("test").start_as_current_span("root"):
meta = inject_trace_context()
assert meta is not None and "traceparent" in meta
class TestEndToEnd:
"""A real in-process client drives a real server — nothing monkeypatched
beyond the setting itself."""
async def test_propagation_only_parents_downstream_user_spans(
self, mode, trace_exporter: InMemorySpanExporter
):
mode("propagation_only")
captured: dict[str, int] = {}
server = FastMCP("interop-server")
@server.tool
async def work(ctx: Context) -> str:
# A span the *user* creates inside their handler.
tracer = otel_trace.get_tracer("user-code")
with tracer.start_as_current_span("user-span") as span:
captured["downstream"] = span.get_span_context().trace_id
return "done"
async with Client(server) as client:
tracer = otel_trace.get_tracer("client-code")
with tracer.start_as_current_span("client-root") as root:
captured["client"] = root.get_span_context().trace_id
await client.call_tool("work", {})
names = [s.name for s in trace_exporter.get_finished_spans()]
assert "user-span" in names and "client-root" in names
# FastMCP emitted none of its own spans — including the per-request
# SERVER span opened at the middleware seam, which is the whole point.
assert fastmcp_spans(trace_exporter) == []
assert [
s for s in trace_exporter.get_finished_spans() if s.kind == SpanKind.SERVER
] == []
# ...yet the user's span inherited the incoming distributed trace.
assert captured["client"] == captured["downstream"]
async def test_propagation_only_without_incoming_trace(
self, mode, trace_exporter: InMemorySpanExporter
):
"""With no surrounding client span there is no incoming trace. The call
must still succeed and emit no FastMCP spans."""
mode("propagation_only")
captured: dict[str, int] = {}
server = FastMCP("interop-server")
@server.tool
async def work(ctx: Context) -> str:
tracer = otel_trace.get_tracer("user-code")
with tracer.start_as_current_span("user-span") as span:
captured["downstream"] = span.get_span_context().trace_id
return "done"
async with Client(server) as client:
result = await client.call_tool("work", {})
assert result.data == "done"
assert fastmcp_spans(trace_exporter) == []
# A self-rooted trace was created (no incoming parent to inherit).
assert "downstream" in captured
async def test_suppress_block_silences_a_single_call(
self, trace_exporter: InMemorySpanExporter
):
"""The scoped form suppresses one call and leaves the next instrumented."""
server = FastMCP("interop-server")
@server.tool
async def work() -> str:
return "done"
async with Client(server) as client:
# Drop the spans the connection handshake already emitted.
trace_exporter.clear()
with suppress_fastmcp_telemetry():
await client.call_tool("work", {})
assert fastmcp_spans(trace_exporter) == []
await client.call_tool("work", {})
assert fastmcp_spans(trace_exporter) != []