Skip to content

Add OpenTelemetry instrument for FastMCP - #2

Open
personage-hub wants to merge 3 commits into
faststream-http-server-spansfrom
fastmcp-opentelemetry
Open

personage-hub wants to merge 3 commits into
faststream-http-server-spansfrom
fastmcp-opentelemetry

Conversation

@personage-hub

@personage-hub personage-hub commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Motivation

FastMcpBootstrapper already wires logging, Sentry, Pyroscope, health checks and Prometheus, but has no OpenTelemetry:
FastMcpSettings does not include OpentelemetryConfig and no tracing instrument is registered. FastMCP services need the
same SERVER spans that Litestar and FastAPI already produce: http.route and the response status
code are what availability is computed from in APM. Today services work around this by mixing OpentelemetryConfig into
their settings, registering OpentelemetryInstrument by hand and calling StarletteInstrumentor().instrument_app() on the
result of http_app().

Autoloaded StarletteInstrumentor().instrument() does not help: it replaces starlette.applications.Starlette, but FastMCP
subclasses the original class at import time (StarletteWithLifespan), so apps from http_app() stay uninstrumented.

Stacked on community-of-python#151 (Honor opentelemetry_exclude_urls in the Litestar bootstrapper): reuses build_span_name, define_exclude_urls and
CombinedExcludeList moved to instruments/opentelemetry_instrument.py there. Only the last commit belongs to this PR.

Changes

  • FastMcpSettings now include OpentelemetryConfig. No new required settings.
  • KwargsFastMCP.http_app() calls the parent and passes the result through hooks registered with add_http_app_hook().
    The return type stays StarletteWithLifespan. This handles the case where the
    ASGI app is created after bootstrap, and it also covers run(transport="http"), which calls http_app() internally.
  • FastMcpOpentelemetryInstrument adds OpenTelemetryMiddleware to every created app:
    • http.route comes from app.routes (Route/Mount, Match.FULL only). Unknown paths produce a method-only span
      with no http.route, which keeps cardinality low.
    • excluded_urls = CombinedExcludeList(ExcludeList(define_exclude_urls()), get_excluded_urls("STARLETTE")).
    • Apps that already have _is_instrumented_by_opentelemetry are skipped. The flag is set the same way
      StarletteInstrumentor sets it, so requests are never traced twice.
    • Semantic conventions are left to OpenTelemetryMiddleware (OTEL_SEMCONV_STABILITY_OPT_IN).
  • FastMcpBootstrapper.bootstrap() is now typed as returning KwargsFastMCP (a FastMCP subclass), so add_http_app_hook type-checks.
  • Added opentelemetry-instrumentation-starlette to the dev group so the entry-point autoload test can run.
  • README: FastMCP OpenTelemetry section.

Tests

tests/bootstrappers/test_fastmcp.py:

  • settings fields and tracer provider creation; instrument inactive without the relevant settings
  • GET /health/ span name, http.route and status attribute for unset / http / http/dup semconv
  • /metrics excluded by default; opentelemetry_exclude_urls; OTEL_PYTHON_STARLETTE_EXCLUDED_URLS; opentelemetry_generate_health_check_spans=False
  • unknown path: GET span without http.route, status 404
  • POST /mcp has http.route == "/mcp"
  • autoloaded starlette entry point: one middleware, flag set; already-instrumented app is skipped
  • opentelemetry_instrumentors (httpx) applied and torn down
  • two http_app() calls produce two independently instrumented apps

just lint-ci and just test pass (238 passed).

🤖 Generated with Claude Code

@personage-hub personage-hub left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the branch locally on top of community-of-python#151: just lint clean, pytest 253 passed (26 in test_fastmcp.py). Also checked against FastMCP 3.4 from a real service: http_app() routes are plain Routes (/.well-known/oauth-protected-resource/mcp, /mcp, /health/, /metrics), the matcher resolves them exactly, /mcp/ with a trailing slash and scan paths get no route, and add_middleware after http_app() works.

The design is the right one: hooking http_app() is the only place where the ASGI app exists, the _is_instrumented_by_opentelemetry flag keeps interop with StarletteInstrumentor, exclusions combine settings and env like Litestar does after community-of-python#151, and the starlette instrumentation stays a dev-only dependency. Tests cover every case from the task, including the entry-point autoload and double http_app() calls.

Two small notes inline, neither blocks the merge. From the service side this removes the OpentelemetryConfig mixin, the manual use_instrument() and the instrument_app() call, which is exactly what we wanted.

) -> tuple[str, dict[str, str]]:
method: typing.Final = str(scope.get("method", "HTTP")).strip()
for route in routes:
if isinstance(route, (Route, Mount)) and route.matches(scope)[0] == Match.FULL:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mount matches by prefix, so a Mount("", ...) or Mount("/", ...) (static files, a sub-app mounted at the root) would turn every unknown path into http.route="/". Cardinality stays low, so this is not a problem for the SLI, but the route then stops meaning "matched route". Worth either skipping mounts whose path is empty or /, or at least a line in the README. Not blocking.



if typing.TYPE_CHECKING:
from fastmcp.server.http import StarletteWithLifespan

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StarletteWithLifespan is imported from fastmcp.server.http, which is not a public import path, and the extra still allows fastmcp>=2,<4. It is behind TYPE_CHECKING, so runtime is safe, but mypy users on a fastmcp version where the symbol moved would get an error from the installed package. If 2.x is still meant to be supported, a quick check that the name exists there (or typing the hook with Starlette) would settle it.

@personage-hub
personage-hub force-pushed the faststream-http-server-spans branch from 8493dc5 to b0eb8b7 Compare October 5, 2026 08:57
@personage-hub
personage-hub force-pushed the fastmcp-opentelemetry branch from 4811965 to b0f6bd8 Compare October 5, 2026 08:59
@personage-hub
personage-hub force-pushed the faststream-http-server-spans branch from b0eb8b7 to 5f8e27e Compare October 5, 2026 10:10
@personage-hub
personage-hub force-pushed the fastmcp-opentelemetry branch from b0f6bd8 to 43ccc29 Compare October 5, 2026 10:10
@personage-hub
personage-hub force-pushed the faststream-http-server-spans branch from 5f8e27e to 7564f48 Compare October 6, 2026 08:35
personage-hub and others added 3 commits October 6, 2026 11:41
…ity-of-python#151)

LitestarOpenTelemetryInstrumentationMiddleware read exclusions only from
OTEL_PYTHON_LITESTAR_EXCLUDED_URLS, so the opentelemetry_exclude_urls setting
was ignored for Litestar. Combine define_exclude_urls() with the env-based
list (CombinedExcludeList) and build it once instead of per request.

define_exclude_urls moves to BaseOpentelemetryInstrument and build_span_name
to instruments/opentelemetry_instrument.py so other bootstrappers can reuse
them without importing the Litestar module.

Co-authored-by: Alexander Niyazov <aleksandr.niiazov@raiffeisen.ru>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
FastMcpSettings now include OpentelemetryConfig, and FastMcpOpentelemetryInstrument
wraps every ASGI application returned by http_app() in OpenTelemetryMiddleware.
Server spans get http.route from Starlette route matching, exclusions combine
opentelemetry_exclude_urls with OTEL_PYTHON_STARLETTE_EXCLUDED_URLS, and
applications already instrumented by StarletteInstrumentor are skipped.

KwargsFastMCP gets add_http_app_hook, since the ASGI application is created
by the user after bootstrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ethod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@personage-hub
personage-hub force-pushed the fastmcp-opentelemetry branch from f3abe5c to bc2cef1 Compare October 6, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant