Skip to content

Commit 2fe7c2b

Browse files
committed
feat: record FastMCP HTTP request and tool call metrics
FastMcpPrometheusInstrument runs prometheus-fastapi-instrumentator on every ASGI application http_app() builds, labelled by route and excluding the metrics route, and a FastMCP middleware counts and times tool calls in fastmcp_tool_calls_total and fastmcp_tool_call_duration_seconds. The fastmcp-metrics extra now requires prometheus-fastapi-instrumentator.
1 parent c488d6b commit 2fe7c2b

5 files changed

Lines changed: 261 additions & 5 deletions

File tree

‎docs/integrations/fastmcp.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ nanoseconds. A message that raises is logged at exception level and the exceptio
6464

6565
Set `health_checks_enabled=False` to omit the health route.
6666

67+
## Metrics
68+
69+
With the `fastmcp-metrics` extra installed, the registry is served at `prometheus_metrics_path`,
70+
every application `application.http_app()` builds counts its requests by route, and every tool call
71+
is counted and timed. See [the configuration reference](../introduction/configuration.md#prometheus-fastmcp)
72+
for the metric names and parameters.
73+
6774
## Tracing
6875

6976
With `opentelemetry_endpoint` set (and the `fastmcp-otl` extra installed), every ASGI application

‎docs/introduction/configuration.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,24 @@ Additional parameters for FastAPI integration:
9595
- `prometheus_instrument_params` - passed to `method Instrumentator(...).instrument`
9696
- `prometheus_expose_params` - passed to `method Instrumentator(...).expose`.
9797

98+
### Prometheus FastMCP
99+
100+
Prometheus's integration for FastMCP uses the `prometheus_client` and `prometheus_fastapi_instrumentator`
101+
packages, and serves `prometheus_client.REGISTRY` at `prometheus_metrics_path`.
102+
103+
Every ASGI application `application.http_app()` builds counts its requests in `http_requests_total`,
104+
`http_request_duration_seconds` and the instrumentator's other default metrics, labelled by route
105+
(`handler="/mcp"`); requests to unknown paths are grouped into `handler="none"`, and the metrics
106+
route itself is not counted. Tool calls are counted in `fastmcp_tool_calls_total{tool, status}`, with
107+
`status` either `success` or `error`, and timed in `fastmcp_tool_call_duration_seconds{tool}`.
108+
109+
Additional parameters for FastMCP integration:
110+
111+
- `prometheus_instrumentator_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator`;
112+
`excluded_handlers` defaults to the metrics route.
113+
- `prometheus_instrument_params` - passed to `method Instrumentator(...).instrument`.
114+
- `fastmcp_prometheus_tool_metrics_enabled` - count and time tool calls (default: `True`).
115+
98116

99117
## Opentelemetry
100118

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import dataclasses
33
import functools
4+
import inspect
45
import re
56
import time
67
import typing
@@ -18,13 +19,13 @@
1819

1920
if import_checker.is_fastmcp_installed:
2021
from fastmcp import FastMCP
22+
from fastmcp.server.http import StarletteWithLifespan
2123
from fastmcp.server.middleware import Middleware, MiddlewareContext
2224
from fastmcp.server.providers import Provider
2325
from starlette.requests import Request
2426
from starlette.responses import JSONResponse, Response
2527

2628
if import_checker.is_fastmcp_opentelemetry_installed:
27-
from fastmcp.server.http import StarletteWithLifespan
2829
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
2930
from opentelemetry.metrics import get_meter_provider
3031
from opentelemetry.trace import get_tracer_provider
@@ -40,10 +41,19 @@
4041
if import_checker.is_prometheus_client_installed:
4142
import prometheus_client
4243

44+
if import_checker.is_prometheus_fastapi_instrumentator_installed:
45+
from prometheus_fastapi_instrumentator import Instrumentator
46+
from prometheus_fastapi_instrumentator import metrics as instrumentator_metrics
47+
48+
_DEFAULT_METRICS_PARAMETERS: typing.Final = frozenset(inspect.signature(instrumentator_metrics.default).parameters)
49+
4350

4451
# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path.
4552
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
4653

54+
_TOOL_CALL_STATUS_SUCCESS: typing.Final = "success"
55+
_TOOL_CALL_STATUS_ERROR: typing.Final = "error"
56+
4757
# Set by StarletteInstrumentor too, so an application is never traced twice
4858
_OPENTELEMETRY_INSTRUMENTED_MARKER: typing.Final = "_is_instrumented_by_opentelemetry"
4959

@@ -78,6 +88,24 @@ def restore() -> None:
7888
return restore
7989

8090

91+
def _build_tool_call_metrics() -> tuple["prometheus_client.Counter", "prometheus_client.Histogram"]:
92+
# The global registry rejects a second collector of the same name, so a later bootstrap reuses the first
93+
registered_collectors: typing.Final = prometheus_client.REGISTRY._names_to_collectors # noqa: SLF001
94+
if "fastmcp_tool_calls_total" in registered_collectors:
95+
return (
96+
typing.cast("prometheus_client.Counter", registered_collectors["fastmcp_tool_calls_total"]),
97+
typing.cast("prometheus_client.Histogram", registered_collectors["fastmcp_tool_call_duration_seconds"]),
98+
)
99+
return (
100+
prometheus_client.Counter(
101+
"fastmcp_tool_calls_total", "Number of MCP tool calls by tool and outcome.", ["tool", "status"]
102+
),
103+
prometheus_client.Histogram(
104+
"fastmcp_tool_call_duration_seconds", "Duration of MCP tool calls by tool.", ["tool"]
105+
),
106+
)
107+
108+
81109
def build_fastmcp_route_details_from_scope(
82110
scope: "Scope",
83111
routes: "typing.Iterable[BaseRoute]",
@@ -106,6 +134,32 @@ async def lifespan(self) -> AsyncGenerator[None]:
106134
finally:
107135
self._teardown()
108136

137+
class FastMcpPrometheusMiddleware(Middleware):
138+
def __init__(
139+
self,
140+
tool_calls_total: "prometheus_client.Counter",
141+
tool_call_duration_seconds: "prometheus_client.Histogram",
142+
) -> None:
143+
self.tool_calls_total = tool_calls_total
144+
self.tool_call_duration_seconds = tool_call_duration_seconds
145+
146+
async def on_call_tool(
147+
self,
148+
context: "MiddlewareContext[typing.Any]",
149+
call_next: "typing.Callable[[MiddlewareContext[typing.Any]], typing.Awaitable[typing.Any]]",
150+
) -> typing.Any: # noqa: ANN401
151+
tool_name: typing.Final = context.message.name
152+
start_time: typing.Final = time.perf_counter()
153+
try:
154+
result = await call_next(context)
155+
except Exception:
156+
self.tool_calls_total.labels(tool=tool_name, status=_TOOL_CALL_STATUS_ERROR).inc()
157+
raise
158+
finally:
159+
self.tool_call_duration_seconds.labels(tool=tool_name).observe(time.perf_counter() - start_time)
160+
self.tool_calls_total.labels(tool=tool_name, status=_TOOL_CALL_STATUS_SUCCESS).inc()
161+
return result
162+
109163
class FastMcpLoggingMiddleware(Middleware):
110164
async def on_message(
111165
self,
@@ -142,6 +196,9 @@ class FastMcpConfig(
142196
):
143197
application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp)
144198
fastmcp_logging_middleware_enabled: bool = False
199+
fastmcp_prometheus_tool_metrics_enabled: bool = True
200+
prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
201+
prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
145202

146203

147204
@dataclasses.dataclass(kw_only=True)
@@ -221,14 +278,46 @@ def teardown(self) -> None:
221278
@dataclasses.dataclass(kw_only=True)
222279
class FastMcpPrometheusInstrument(PrometheusInstrument):
223280
bootstrap_config: FastMcpConfig
224-
missing_dependency_message = "prometheus_client is not installed"
281+
missing_dependency_message = "prometheus_client or prometheus_fastapi_instrumentator is not installed"
282+
_restore_http_app: typing.Callable[[], None] | None = dataclasses.field(
283+
default=None, init=False, repr=False, compare=False
284+
)
285+
_default_metrics: typing.Callable[..., typing.Any] | None = dataclasses.field(
286+
default=None, init=False, repr=False, compare=False
287+
)
225288

226289
@staticmethod
227290
def dependencies_installed() -> bool:
228-
return import_checker.is_prometheus_client_installed
291+
return (
292+
import_checker.is_prometheus_client_installed
293+
and import_checker.is_prometheus_fastapi_instrumentator_installed
294+
)
295+
296+
def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "StarletteWithLifespan":
297+
config: typing.Final = self.bootstrap_config
298+
instrumentator_params: typing.Final[dict[str, typing.Any]] = {
299+
"excluded_handlers": [f"^{re.escape(config.prometheus_metrics_path)}$"],
300+
**config.prometheus_instrumentator_params,
301+
}
302+
# A second http_app() would register the default metrics again, which the instrumentator
303+
# treats as a duplicate and silently skips, so every application shares the first set.
304+
if self._default_metrics is None:
305+
default_metrics_params: typing.Final[dict[str, typing.Any]] = {
306+
name: value
307+
for name, value in {**instrumentator_params, **config.prometheus_instrument_params}.items()
308+
if name in _DEFAULT_METRICS_PARAMETERS
309+
}
310+
self._default_metrics = instrumentator_metrics.default(**default_metrics_params)
311+
Instrumentator(**instrumentator_params).add(self._default_metrics).instrument(
312+
http_application, **config.prometheus_instrument_params
313+
)
314+
return http_application
229315

230316
def bootstrap(self) -> None:
231317
config = self.bootstrap_config
318+
self._restore_http_app = _postprocess_http_apps(config.application, self._instrument_http_app)
319+
if config.fastmcp_prometheus_tool_metrics_enabled:
320+
config.application.add_middleware(FastMcpPrometheusMiddleware(*_build_tool_call_metrics()))
232321

233322
@config.application.custom_route(
234323
config.prometheus_metrics_path,
@@ -242,6 +331,11 @@ async def metrics_handler(_: "Request") -> "Response":
242331
headers={"content-type": prometheus_client.CONTENT_TYPE_LATEST},
243332
)
244333

334+
def teardown(self) -> None:
335+
if self._restore_http_app is not None:
336+
self._restore_http_app()
337+
self._restore_http_app = None
338+
245339

246340
@dataclasses.dataclass(kw_only=True)
247341
class FastMcpLoggingInstrument(LoggingInstrument):

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ fastmcp-otl = [
203203
fastmcp-metrics = [
204204
"lite-bootstrap[fastmcp]",
205205
"prometheus-client>=0.20",
206+
# >=8.0.1 to share the fastapi-metrics floor; it requires starlette>=1.0, which fastmcp 3.0 already pulls.
207+
"prometheus-fastapi-instrumentator>=8.0.1",
206208
]
207209
fastmcp-all = [
208210
"lite-bootstrap[fastmcp,fastmcp-otl,fastmcp-metrics,sentry,logging,pyroscope]",

‎tests/test_fastmcp_bootstrap.py‎

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import prometheus_client
99
import pytest
10-
from fastmcp import FastMCP
10+
from fastmcp import Client, FastMCP
11+
from fastmcp.exceptions import ToolError
1112
from fastmcp.server.http import StarletteWithLifespan
1213
from fastmcp.server.middleware import MiddlewareContext
1314
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
@@ -25,6 +26,7 @@
2526
from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import (
2627
FastMcpLoggingMiddleware,
2728
FastMcpOpenTelemetryInstrument,
29+
FastMcpPrometheusMiddleware,
2830
_postprocess_http_apps,
2931
)
3032
from lite_bootstrap.exceptions import ConfigurationError
@@ -446,7 +448,6 @@ def test_fastmcp_otel_leaves_http_app_alone_when_not_configured() -> None:
446448
bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config())
447449
application = bootstrapper.bootstrap()
448450
try:
449-
assert "http_app" not in vars(application)
450451
assert _count_opentelemetry_middlewares(application.http_app()) == 0
451452
finally:
452453
bootstrapper.teardown()
@@ -462,6 +463,140 @@ def test_fastmcp_otel_is_skipped_without_asgi_instrumentation() -> None:
462463
bootstrap_config=_make_test_config(opentelemetry_log_traces=True)
463464
)
464465
application = bootstrapper.bootstrap()
466+
try:
467+
assert _count_opentelemetry_middlewares(application.http_app()) == 0
468+
finally:
469+
bootstrapper.teardown()
470+
471+
472+
@pytest.fixture(autouse=True)
473+
def _unregister_request_metrics() -> None:
474+
"""Give each test unregistered request metrics: a second registration is skipped, not reused."""
475+
registered_collectors = dict(prometheus_client.REGISTRY._collector_to_names) # noqa: SLF001
476+
for collector, names in registered_collectors.items():
477+
if any(name.startswith(("http_request", "http_response")) for name in names):
478+
prometheus_client.REGISTRY.unregister(collector)
479+
480+
481+
def _sample_value(name: str, labels: dict[str, str]) -> float:
482+
return prometheus_client.REGISTRY.get_sample_value(name, labels) or 0.0
483+
484+
485+
def _http_requests_total(handler: str, method: str = "GET", status_group: str = "2xx") -> float:
486+
return _sample_value("http_requests_total", {"handler": handler, "method": method, "status": status_group})
487+
488+
489+
def test_fastmcp_prometheus_counts_http_requests_by_route_template() -> None:
490+
health_checks_path = f"/health-{uuid.uuid4().hex}/"
491+
config = _make_test_config(health_checks_path=health_checks_path)
492+
bootstrapper = FastMcpBootstrapper(bootstrap_config=config)
493+
application = bootstrapper.bootstrap()
494+
try:
495+
with TestClient(application.http_app()) as client:
496+
client.get(health_checks_path)
497+
client.get(health_checks_path)
498+
client.get("/missing")
499+
client.get(config.prometheus_metrics_path)
500+
finally:
501+
bootstrapper.teardown()
502+
503+
expected_health_requests = 2
504+
assert _http_requests_total(health_checks_path) == expected_health_requests
505+
assert _http_requests_total("none", status_group="4xx") == 1
506+
assert _http_requests_total(config.prometheus_metrics_path) == 0
507+
508+
509+
def test_fastmcp_prometheus_counts_requests_of_every_http_app() -> None:
510+
health_checks_path = f"/health-{uuid.uuid4().hex}/"
511+
bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config(health_checks_path=health_checks_path))
512+
application = bootstrapper.bootstrap()
513+
try:
514+
for http_application in (application.http_app(), application.http_app(path="/other")):
515+
with TestClient(http_application) as client:
516+
client.get(health_checks_path)
517+
finally:
518+
bootstrapper.teardown()
519+
520+
expected_requests = 2
521+
assert _http_requests_total(health_checks_path) == expected_requests
522+
523+
524+
def test_fastmcp_prometheus_instrumentator_params_are_passed() -> None:
525+
health_checks_path = f"/health-{uuid.uuid4().hex}/"
526+
config = _make_test_config(
527+
health_checks_path=health_checks_path,
528+
prometheus_instrumentator_params={"excluded_handlers": [health_checks_path]},
529+
)
530+
bootstrapper = FastMcpBootstrapper(bootstrap_config=config)
531+
application = bootstrapper.bootstrap()
532+
try:
533+
with TestClient(application.http_app()) as client:
534+
client.get(health_checks_path)
535+
finally:
536+
bootstrapper.teardown()
537+
538+
assert _http_requests_total(health_checks_path) == 0
539+
540+
541+
def test_fastmcp_prometheus_teardown_restores_http_app() -> None:
542+
bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config())
543+
application = bootstrapper.bootstrap()
544+
assert "http_app" in vars(application)
545+
546+
bootstrapper.teardown()
547+
548+
assert "http_app" not in vars(application)
549+
550+
551+
async def test_fastmcp_prometheus_counts_tool_calls() -> None:
552+
bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config())
553+
application = bootstrapper.bootstrap()
554+
echo_name = f"echo_{uuid.uuid4().hex}"
555+
failing_name = f"failing_{uuid.uuid4().hex}"
556+
557+
def echo(text: str) -> str:
558+
return text
559+
560+
def failing() -> str:
561+
message = "boom"
562+
raise ValueError(message)
563+
564+
application.tool(echo, name=echo_name)
565+
application.tool(failing, name=failing_name)
566+
try:
567+
async with Client(application) as client:
568+
await client.call_tool(echo_name, {"text": "hi"})
569+
await client.call_tool(echo_name, {"text": "hi"})
570+
with pytest.raises(ToolError):
571+
await client.call_tool(failing_name, {})
572+
finally:
573+
bootstrapper.teardown()
574+
575+
expected_successes = 2
576+
assert _sample_value("fastmcp_tool_calls_total", {"tool": echo_name, "status": "success"}) == expected_successes
577+
assert _sample_value("fastmcp_tool_calls_total", {"tool": failing_name, "status": "error"}) == 1
578+
assert _sample_value("fastmcp_tool_call_duration_seconds_count", {"tool": echo_name}) == expected_successes
579+
580+
581+
def test_fastmcp_prometheus_tool_metrics_can_be_disabled() -> None:
582+
bootstrapper = FastMcpBootstrapper(
583+
bootstrap_config=_make_test_config(fastmcp_prometheus_tool_metrics_enabled=False)
584+
)
585+
application = bootstrapper.bootstrap()
586+
try:
587+
assert not [one for one in application.middleware if isinstance(one, FastMcpPrometheusMiddleware)]
588+
finally:
589+
bootstrapper.teardown()
590+
591+
592+
def test_fastmcp_prometheus_is_skipped_without_instrumentator() -> None:
593+
with emulate_package_missing_with_module_reload(
594+
"prometheus_fastapi_instrumentator",
595+
["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"],
596+
):
597+
with pytest.warns(UserWarning, match="prometheus_fastapi_instrumentator"):
598+
bootstrapper = fastmcp_bootstrapper.FastMcpBootstrapper(bootstrap_config=_make_test_config())
599+
application = bootstrapper.bootstrap()
465600
try:
466601
assert "http_app" not in vars(application)
467602
finally:

0 commit comments

Comments
 (0)