Skip to content

Record HTTP request and tool call metrics for FastMCP in Prometheus - #155

Open
personage-hub wants to merge 2 commits into
community-of-python:mainfrom
personage-hub:fastmcp-prometheus
Open

personage-hub wants to merge 2 commits into
community-of-python:mainfrom
personage-hub:fastmcp-prometheus

Conversation

@personage-hub

Copy link
Copy Markdown
Contributor

Summary

FastMcpPrometheusInstrument only exposed the registry on the metrics route, so a FastMCP service had nothing in /metrics but process_* and python_*, unlike the Litestar, FastAPI and FastStream bootstrappers, which also record request metrics. Two commits, the second one can be dropped if you prefer to keep the library to HTTP metrics:

  1. HTTP request metrics through prometheus-fastapi-instrumentator, the same library the FastAPI bootstrapper uses; it officially supports plain Starlette apps. Every ASGI application returned by http_app() is instrumented via the add_http_application_postprocessor hook from Add OpenTelemetry instrument for FastMCP #152, so requests are counted by route template (handler="/mcp", handler="/health/"), unknown paths are grouped into handler="none", the metrics route itself is excluded. FastMcpPrometheusConfig gets the FastAPI-style prometheus_instrumentator_params, prometheus_instrument_params and prometheus_custom_labels; prometheus_registry is reused for the request metrics. The fastmcp extra now depends on prometheus-fastapi-instrumentator>=7.1 (it only requires starlette and prometheus-client).
  2. Tool call metrics: FastMcpPrometheusMiddleware (a FastMCP middleware next to FastMcpLoggingMiddleware) records fastmcp_tool_calls_total{tool, status} and fastmcp_tool_call_duration_seconds{tool}. Enabled by default, prometheus_tool_metrics=False turns it off.

No library for MCP-level metrics exists, so the tool call part is hand-written; the HTTP part reuses an existing dependency instead of adding a custom ASGI middleware.

Tests

  • test_fastmcp_prometheus_counts_http_requests_by_route_template: health route counted twice as 2xx, unknown path as none, POST /mcp by the mount path, metrics route not counted, custom label present.
  • test_fastmcp_prometheus_instrumentator_params_are_passed: excluded_handlers and should_group_status_codes overrides.
  • test_fastmcp_prometheus_counts_tool_calls / ..._can_be_disabled with the in-memory fastmcp.Client.
  • just test: 346 passed.

Note: uv run mypy . reports two pre-existing errors in microbootstrap/helpers.py:29 with the locked mypy on Python 3.14; untouched here.

🤖 Generated with Claude Code

Alexander Niyazov and others added 2 commits October 7, 2026 11:13
The FastMCP Prometheus instrument only exposed the registry on the metrics route, so the endpoint
had nothing but process_* and python_* series. Every ASGI application returned by http_app() is now
instrumented with prometheus-fastapi-instrumentator, the same library the FastAPI bootstrapper uses:
requests are counted by route template (handler="/mcp", handler="/health/"), unknown paths are
grouped into handler="none" and the metrics route itself is excluded.

FastMcpPrometheusConfig gets the FastAPI-style prometheus_instrumentator_params,
prometheus_instrument_params and prometheus_custom_labels; prometheus_registry is used for the request
metrics as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tool calls are the unit of work of an MCP server, so FastMcpPrometheusMiddleware records
fastmcp_tool_calls_total{tool, status} and fastmcp_tool_call_duration_seconds{tool}, sharing the
registry and custom labels of the request metrics. Enabled by default, prometheus_tool_metrics=False
turns it off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lesnik512

Copy link
Copy Markdown
Member

instrumentator_metrics.default() doesn't reuse metrics that are already in the registry. It catches the duplicate-registration error and returns None. Because __instrument_http_app calls it for every application http_app() returns, only the first application records http_requests_total and its siblings. A second http_app() call (a second transport, or a test building the app twice) gets an Instrumentator with no instrumentations, and its requests are silently not counted.

Reproduction with the same calls against one registry:

registry = prometheus_client.CollectorRegistry()
for _ in range(2):
    app = Starlette(routes=[Route("/ping", lambda _: PlainTextResponse("ok"))])
    Instrumentator(registry=registry).add(instrumentator_metrics.default(registry=registry)).instrument(app)
    with TestClient(app) as client:
        client.get("/ping")
# http_requests_total{handler="/ping"} stays at 1.0 after the second app's request

A fix that works for us in modern-python/lite-bootstrap#281: build the default instrumentation once per instrument, on the first http_app(), and .add() that same closure to every later Instrumentator. A test calling http_app() twice against prometheus_registry and asserting both requests are counted would catch it.

This branch has not been deployed

No deployments
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.

2 participants