11import contextlib
22import dataclasses
33import functools
4+ import inspect
45import re
56import time
67import typing
1819
1920if 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
2628if 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
4041if 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+
81109def 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 )
222279class 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 )
247341class FastMcpLoggingInstrument (LoggingInstrument ):
0 commit comments