Add telemetry off-switch and mcp.protocol.version span attribute (#4481)

* Turn OpenTelemetry instrumentation on by default with explicit off-switch

Add FASTMCP_ENABLE_TELEMETRY setting (default true) and mcp.protocol.version
span attribute for SDK parity.

* Make disabled telemetry a transparent pass-through, not a NoOpTracer

The stock NoOpTracer.start_as_current_span attaches a NonRecordingSpan, hijacking the current OTel context from any enclosing application span. When telemetry is disabled, get_tracer() now returns a non-attaching pass-through tracer so trace.get_current_span() inside handlers still resolves to the caller's span.
This commit is contained in:
Jeremiah Lowin 2026-07-17 17:02:48 -04:00 committed by GitHub
commit a04f6fd911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 301 additions and 4 deletions

View file

@ -152,6 +152,12 @@ 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
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.
*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`.
### Spec-correct error codes via a central translator — Breaking (wire error code)
Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is.

View file

@ -72,6 +72,12 @@ These control how the server listens when running with an HTTP transport.
| `FASTMCP_SHOW_SERVER_BANNER` | `bool` | `true` | Show the server banner on startup. Also controllable via `--no-banner` or `server.run(show_banner=False)`. |
| `FASTMCP_CHECK_FOR_UPDATES` | `Literal["stable", "prerelease", "off"]` | `stable` | Update checking on CLI startup. `stable` checks stable releases only, `prerelease` includes pre-releases, `off` disables checking. |
## Telemetry
| 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. |
## Tasks (Docket)
These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix.

View file

@ -12,11 +12,17 @@ FastMCP includes native OpenTelemetry instrumentation for observability. Traces
FastMCP uses the OpenTelemetry API for instrumentation. This means:
- **Zero configuration required** - Instrumentation is always active
- **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:
@ -271,6 +277,7 @@ FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentele
| Attribute | Description |
|-----------|-------------|
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) |
| `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) |

View file

@ -58,6 +58,21 @@ def get_session_span_attributes() -> dict[str, str]:
return attrs
def get_protocol_span_attributes() -> dict[str, str]:
"""Get the negotiated MCP protocol version for the current request.
Mirrors the `mcp.protocol.version` attribute the SDK's own
`OpenTelemetryMiddleware` sets FastMCP drops that middleware to avoid a
duplicate SERVER span, so this restores the attribute on FastMCP's span.
"""
from fastmcp.server.dependencies import fastmcp_request_ctx
req_ctx = fastmcp_request_ctx.get()
if req_ctx is not None and req_ctx.protocol_version:
return {"mcp.protocol.version": req_ctx.protocol_version}
return {}
def _get_parent_trace_context() -> Context | None:
"""Get parent trace context from request meta for distributed tracing."""
from fastmcp.server.dependencies import fastmcp_request_ctx
@ -84,6 +99,7 @@ def _build_server_span_attrs(
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_protocol_span_attributes(),
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
@ -131,6 +147,7 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
{
"mcp.method.name": method,
"fastmcp.server.name": server_name,
**get_protocol_span_attributes(),
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
@ -244,6 +261,7 @@ __all__ = [
"SEAM_SPAN_MARKER",
"delegate_span",
"get_auth_span_attributes",
"get_protocol_span_attributes",
"get_session_span_attributes",
"record_span_exception",
"seam_span",

View file

@ -210,6 +210,24 @@ 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(

View file

@ -21,13 +21,24 @@ Example usage with SDK:
```
"""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from opentelemetry import context as otel_context
from opentelemetry import propagate, trace
from opentelemetry.context import Context
from opentelemetry.trace import Span, Status, StatusCode, Tracer
from opentelemetry.trace import (
INVALID_SPAN,
NoOpTracer,
Span,
SpanKind,
Status,
StatusCode,
Tracer,
)
from opentelemetry.trace import get_tracer as otel_get_tracer
from opentelemetry.util import types as otel_types
INSTRUMENTATION_NAME = "fastmcp"
@ -35,15 +46,60 @@ TRACE_PARENT_KEY = "traceparent"
TRACE_STATE_KEY = "tracestate"
class _DisabledTracer(NoOpTracer):
"""A tracer that neither records spans nor touches the OTel context.
When telemetry is disabled FastMCP must be fully transparent. The stock
`NoOpTracer.start_as_current_span` still *attaches* a `NonRecordingSpan` to
the current OTel context, so an enclosing application span (from ASGI/HTTP
instrumentation or a user-created span) is hidden while a FastMCP span
helper is active `trace.get_current_span()` inside a handler would then
return that non-recording span instead of the caller's span. This tracer
yields the invalid span *without* entering it as current, leaving the
surrounding trace context untouched.
"""
@contextmanager
def start_as_current_span(
self,
name: str,
context: Context | None = None,
kind: SpanKind = SpanKind.INTERNAL,
attributes: otel_types.Attributes = None,
links: Any = None,
start_time: int | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
end_on_exit: bool = True,
) -> Iterator[Span]:
yield INVALID_SPAN
_DISABLED_TRACER = _DisabledTracer()
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.
Args:
version: Optional version string for the instrumentation
Returns:
A tracer instance. Returns a no-op tracer if no SDK is configured.
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.
"""
import fastmcp
if not fastmcp.settings.enable_telemetry:
return _DISABLED_TRACER
return otel_get_tracer(INSTRUMENTATION_NAME, version)

View file

@ -5,9 +5,11 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import SpanKind, StatusCode
from opentelemetry.trace import Span, SpanKind, StatusCode
import fastmcp
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.auth import AccessToken
@ -575,3 +577,187 @@ class TestFailurePathServerSpan:
tool_call_server_spans[0].attributes is not None
and tool_call_server_spans[0].attributes["mcp.method.name"] == "tools/call"
)
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
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
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
await mcp.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].name == "tools/call greet"
async def test_off_switch_suppresses_spans(
self,
trace_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
):
"""With telemetry disabled, no spans are created even with an SDK
configured (the exporter fixture installs one)."""
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
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) == 0
async def test_off_switch_suppresses_spans_via_client(
self,
trace_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
):
"""The off-switch suppresses every FastMCP span on the full request path
(seam SERVER span and FastMCP CLIENT span alike).
The SDK's own low-level `mcp-python-sdk` CLIENT spans ("MCP send ...")
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)
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
fastmcp_spans = [
s
for s in spans
if s.instrumentation_scope is not None
and s.instrumentation_scope.name == "fastmcp"
]
assert fastmcp_spans == []
# In particular, no FastMCP SERVER span (FastMCP owns all SERVER spans).
assert [s for s in spans if s.kind == SpanKind.SERVER] == []
async def test_off_switch_leaves_enclosing_span_current(
self,
trace_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
):
"""Disabling telemetry must be a transparent pass-through.
The stock OpenTelemetry `NoOpTracer.start_as_current_span` attaches a
`NonRecordingSpan` as the current span, which would hijack the trace
context from an enclosing application span. With FastMCP's off-switch,
`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)
tracer = trace.get_tracer("test-enclosing")
captured: dict[str, Span] = {}
mcp = FastMCP("test-server")
@mcp.tool()
def annotate() -> str:
current = trace.get_current_span()
captured["current"] = current
current.set_attribute("tool.touched", True)
return "ok"
with tracer.start_as_current_span("enclosing-app-span") as enclosing:
await mcp.call_tool("annotate", {})
# The tool ran with the enclosing span still current — FastMCP did
# not attach a replacement (non-recording) span.
assert captured["current"] is enclosing
assert enclosing.is_recording()
spans = trace_exporter.get_finished_spans()
app_spans = [s for s in spans if s.name == "enclosing-app-span"]
assert len(app_spans) == 1
assert app_spans[0].attributes is not None
assert app_spans[0].attributes["tool.touched"] is True
# No FastMCP spans were exported.
fastmcp_spans = [
s
for s in spans
if s.instrumentation_scope is not None
and s.instrumentation_scope.name == "fastmcp"
]
assert fastmcp_spans == []
class TestProtocolVersionAttribute:
"""The SERVER span carries `mcp.protocol.version`, matching the SDK.
FastMCP drops the SDK's `OpenTelemetryMiddleware` to avoid a duplicate SERVER
span, so it re-emits the SDK's `mcp.protocol.version` attribute on its own
span for parity.
"""
async def test_tool_call_span_has_protocol_version(
self, trace_exporter: InMemorySpanExporter
):
mcp = FastMCP("test-server")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
await client.call_tool("greet", {"name": "World"})
spans = trace_exporter.get_finished_spans()
server_span = next(
s
for s in spans
if s.kind == SpanKind.SERVER
and s.attributes is not None
and s.attributes.get("mcp.method.name") == "tools/call"
)
assert server_span.attributes is not None
version = server_span.attributes.get("mcp.protocol.version")
assert isinstance(version, str)
assert version
async def test_seam_span_has_protocol_version(
self, trace_exporter: InMemorySpanExporter
):
"""Seam-only methods (never reaching the high-level path) also carry the
protocol version."""
mcp = FastMCP("test-server")
async with Client(mcp) as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
seam_span = next(
s
for s in spans
if s.kind == SpanKind.SERVER and s.name == "logging/setLevel"
)
assert seam_span.attributes is not None
version = seam_span.attributes.get("mcp.protocol.version")
assert isinstance(version, str)
assert version