Skip to main content

rmcp_server_kit/
metrics.rs

1//! Prometheus metrics for MCP servers.
2//!
3//! Provides a shared [`crate::metrics::McpMetrics`] registry with standard HTTP counters.
4//! The transport layer exposes these via a `/metrics` endpoint on a
5//! dedicated listener when `metrics_enabled` is true.
6//!
7//! # Public surface and the `prometheus` crate
8//!
9//! [`crate::metrics::McpMetrics::registry`] and the `IntCounterVec` / `HistogramVec` fields are
10//! intentionally exposed so downstream crates can register additional custom
11//! collectors against the same registry. This re-exports the [`prometheus`]
12//! crate types as part of `rmcp-server-kit`'s public API; pin the same major version to
13//! avoid type-identity mismatches when registering custom metrics.
14
15use std::sync::Arc;
16
17use prometheus::{
18    Encoder, HistogramVec, IntCounterVec, Registry, TextEncoder, histogram_opts, opts,
19};
20
21use crate::error::McpxError;
22
23/// Collected Prometheus metrics for an MCP server.
24#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct McpMetrics {
27    /// Prometheus registry holding all counters and histograms.
28    pub registry: Registry,
29    /// Total HTTP requests by method, path, and status code.
30    pub http_requests_total: IntCounterVec,
31    /// HTTP request duration in seconds by method and path.
32    pub http_request_duration_seconds: HistogramVec,
33}
34
35impl McpMetrics {
36    /// Create a new metrics registry with default MCP counters.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`McpxError::Metrics`] if counter registration fails (should
41    /// not happen unless duplicate registrations occur).
42    pub fn new() -> Result<Self, McpxError> {
43        let registry = Registry::new();
44
45        let http_requests_total = IntCounterVec::new(
46            opts!("rmcp_server_kit_http_requests_total", "Total HTTP requests"),
47            &["method", "path", "status"],
48        )
49        .map_err(|e| McpxError::Metrics(e.to_string()))?;
50        registry
51            .register(Box::new(http_requests_total.clone()))
52            .map_err(|e| McpxError::Metrics(e.to_string()))?;
53
54        let http_request_duration_seconds = HistogramVec::new(
55            histogram_opts!(
56                "rmcp_server_kit_http_request_duration_seconds",
57                "HTTP request duration in seconds"
58            ),
59            &["method", "path"],
60        )
61        .map_err(|e| McpxError::Metrics(e.to_string()))?;
62        registry
63            .register(Box::new(http_request_duration_seconds.clone()))
64            .map_err(|e| McpxError::Metrics(e.to_string()))?;
65
66        Ok(Self {
67            registry,
68            http_requests_total,
69            http_request_duration_seconds,
70        })
71    }
72
73    /// Encode all collected metrics as Prometheus text format.
74    #[must_use]
75    pub fn encode(&self) -> String {
76        let encoder = TextEncoder::new();
77        let metric_families = self.registry.gather();
78        let mut buf = Vec::new();
79        if let Err(e) = encoder.encode(&metric_families, &mut buf) {
80            tracing::warn!(error = %e, "prometheus encode failed");
81            return String::new();
82        }
83        // TextEncoder always produces valid UTF-8; fall back to empty on
84        // the near-impossible chance it doesn't.
85        String::from_utf8(buf).unwrap_or_default()
86    }
87}
88
89/// Spawn a dedicated HTTP listener that serves Prometheus metrics on `/metrics`.
90///
91/// # Errors
92///
93/// Returns [`McpxError::Startup`] if the TCP listener cannot bind or the
94/// underlying axum server fails.
95pub async fn serve_metrics(bind: String, metrics: Arc<McpMetrics>) -> Result<(), McpxError> {
96    let app = axum::Router::new().route(
97        "/metrics",
98        axum::routing::get(move || {
99            let m = Arc::clone(&metrics);
100            async move { m.encode() }
101        }),
102    );
103
104    let listener = tokio::net::TcpListener::bind(&bind)
105        .await
106        .map_err(|e| McpxError::Startup(format!("metrics bind {bind}: {e}")))?;
107    tracing::info!("metrics endpoint listening on http://{bind}/metrics");
108    axum::serve(listener, app)
109        .await
110        .map_err(|e| McpxError::Startup(format!("metrics serve: {e}")))?;
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    #![allow(
117        clippy::unwrap_used,
118        clippy::expect_used,
119        clippy::panic,
120        clippy::indexing_slicing,
121        clippy::unwrap_in_result,
122        clippy::print_stdout,
123        clippy::print_stderr
124    )]
125    use super::*;
126
127    #[test]
128    fn new_creates_registry_with_counters() {
129        let m = McpMetrics::new().unwrap();
130        // Incrementing a counter should make it appear in gather output.
131        m.http_requests_total
132            .with_label_values(&["GET", "/test", "200"])
133            .inc();
134        m.http_request_duration_seconds
135            .with_label_values(&["GET", "/test"])
136            .observe(0.1);
137        assert_eq!(m.registry.gather().len(), 2);
138    }
139
140    #[test]
141    fn encode_empty_registry() {
142        let m = McpMetrics::new().unwrap();
143        let output = m.encode();
144        // Empty counters/histograms produce no samples but the output is valid.
145        assert!(output.is_empty() || output.contains("rmcp_server_kit_"));
146    }
147
148    #[test]
149    fn counter_increment_shows_in_encode() {
150        let m = McpMetrics::new().unwrap();
151        m.http_requests_total
152            .with_label_values(&["GET", "/healthz", "200"])
153            .inc();
154        let output = m.encode();
155        assert!(output.contains("rmcp_server_kit_http_requests_total"));
156        assert!(output.contains("method=\"GET\""));
157        assert!(output.contains("path=\"/healthz\""));
158        assert!(output.contains("status=\"200\""));
159        assert!(output.contains(" 1")); // count = 1
160    }
161
162    #[test]
163    fn histogram_observe_shows_in_encode() {
164        let m = McpMetrics::new().unwrap();
165        m.http_request_duration_seconds
166            .with_label_values(&["POST", "/mcp"])
167            .observe(0.042);
168        let output = m.encode();
169        assert!(output.contains("rmcp_server_kit_http_request_duration_seconds"));
170        assert!(output.contains("method=\"POST\""));
171        assert!(output.contains("path=\"/mcp\""));
172    }
173
174    #[test]
175    fn multiple_increments_accumulate() {
176        let m = McpMetrics::new().unwrap();
177        let counter = m
178            .http_requests_total
179            .with_label_values(&["POST", "/mcp", "200"]);
180        counter.inc();
181        counter.inc();
182        counter.inc();
183        let output = m.encode();
184        assert!(output.contains(" 3")); // count = 3
185    }
186
187    #[test]
188    fn clone_shares_registry() {
189        let m = McpMetrics::new().unwrap();
190        let m2 = m.clone();
191        m.http_requests_total
192            .with_label_values(&["GET", "/test", "200"])
193            .inc();
194        // The clone should see the same counter value.
195        let output = m2.encode();
196        assert!(output.contains(" 1"));
197    }
198}