diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index 0b9e56e8b..33fe92a98 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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 ` 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. diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index a50cca6e1..b672af316 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -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. diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx index aed7308db..7553868f5 100644 --- a/docs/servers/telemetry.mdx +++ b/docs/servers/telemetry.mdx @@ -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) | diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index eeaa0aeb7..e9f6d4344 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -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", diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index dccfcf5f1..a2a37d24a 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -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( diff --git a/fastmcp_slim/fastmcp/telemetry.py b/fastmcp_slim/fastmcp/telemetry.py index 0965b8b71..c1bb66dfd 100644 --- a/fastmcp_slim/fastmcp/telemetry.py +++ b/fastmcp_slim/fastmcp/telemetry.py @@ -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) diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index c04f49fd6..3c6594aa5 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -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