1use mz_ore::metric;
19use mz_ore::metrics::MetricsRegistry;
20use mz_ore::stats::histogram_seconds_buckets;
21use prometheus::{HistogramTimer, HistogramVec, IntCounterVec};
22
23#[derive(Debug, Clone, Copy)]
28pub enum McpCallStatus {
29 Ok,
31 Cancelled,
35 Timeout,
37 EndpointDisabled,
39 Error(&'static str),
42}
43
44impl McpCallStatus {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Ok => "ok",
48 Self::Cancelled => "cancelled",
49 Self::Timeout => "timeout",
50 Self::EndpointDisabled => "endpoint_disabled",
51 Self::Error(e) => e,
52 }
53 }
54}
55
56#[derive(Debug, Clone)]
61pub struct McpMetrics {
62 pub requests: IntCounterVec,
64 pub tool_calls: IntCounterVec,
66 pub tool_call_duration: HistogramVec,
68}
69
70pub struct ToolCallGuard<'a> {
77 metrics: &'a McpMetrics,
78 endpoint_label: &'static str,
79 tool_label: String,
80 status: McpCallStatus,
81 _timer: HistogramTimer,
85}
86
87impl<'a> ToolCallGuard<'a> {
88 pub fn new(metrics: &'a McpMetrics, endpoint_label: &'static str, tool_label: String) -> Self {
91 let timer = metrics
92 .tool_call_duration
93 .with_label_values(&[endpoint_label, &tool_label])
94 .start_timer();
95 Self {
96 metrics,
97 endpoint_label,
98 tool_label,
99 status: McpCallStatus::Cancelled,
100 _timer: timer,
101 }
102 }
103
104 pub fn set_status(&mut self, status: McpCallStatus) {
107 self.status = status;
108 }
109}
110
111impl Drop for ToolCallGuard<'_> {
112 fn drop(&mut self) {
113 self.metrics
114 .tool_calls
115 .with_label_values(&[self.endpoint_label, &self.tool_label, self.status.as_str()])
116 .inc();
117 }
118}
119
120impl McpMetrics {
121 pub fn register_into(registry: &MetricsRegistry) -> Self {
122 Self {
123 requests: registry.register(metric!(
124 name: "mz_mcp_requests_total",
125 help: "Total number of MCP requests received.",
126 var_labels: ["endpoint_type", "method", "status"],
127 )),
128 tool_calls: registry.register(metric!(
129 name: "mz_mcp_tool_calls_total",
130 help: "Total number of MCP tools/call invocations.",
131 var_labels: ["endpoint_type", "tool_name", "status"],
132 )),
133 tool_call_duration: registry.register(metric!(
134 name: "mz_mcp_tool_call_duration_seconds",
135 help: "Duration of MCP tools/call invocations in seconds.",
136 var_labels: ["endpoint_type", "tool_name"],
137 buckets: histogram_seconds_buckets(0.000_128, 8.0),
138 )),
139 }
140 }
141
142 pub fn record_request(&self, endpoint_label: &str, method_label: &str, status: McpCallStatus) {
146 self.requests
147 .with_label_values(&[endpoint_label, method_label, status.as_str()])
148 .inc();
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{McpCallStatus, McpMetrics};
155 use mz_ore::metrics::MetricsRegistry;
156
157 #[mz_ore::test]
160 fn test_call_status_labels() {
161 assert_eq!(McpCallStatus::Ok.as_str(), "ok");
162 assert_eq!(McpCallStatus::Cancelled.as_str(), "cancelled");
163 assert_eq!(McpCallStatus::Timeout.as_str(), "timeout");
164 assert_eq!(
165 McpCallStatus::EndpointDisabled.as_str(),
166 "endpoint_disabled"
167 );
168 assert_eq!(
169 McpCallStatus::Error("ToolNotFound").as_str(),
170 "ToolNotFound"
171 );
172 }
173
174 #[mz_ore::test]
179 fn test_register_into() {
180 let registry = MetricsRegistry::new();
181 let metrics = McpMetrics::register_into(®istry);
182
183 metrics
184 .requests
185 .with_label_values(&["agent", "initialize", "ok"])
186 .inc_by(0);
187 metrics
188 .tool_calls
189 .with_label_values(&["agent", "read_data_product", "ok"])
190 .inc_by(0);
191 metrics
192 .tool_call_duration
193 .with_label_values(&["agent", "read_data_product"])
194 .observe(0.0);
195
196 let names: Vec<String> = registry
197 .gather()
198 .iter()
199 .map(|m| m.name().to_string())
200 .collect();
201
202 assert!(
203 names.iter().any(|n| n == "mz_mcp_requests_total"),
204 "mz_mcp_requests_total should be registered, got: {names:?}",
205 );
206 assert!(
207 names.iter().any(|n| n == "mz_mcp_tool_calls_total"),
208 "mz_mcp_tool_calls_total should be registered, got: {names:?}",
209 );
210 assert!(
211 names
212 .iter()
213 .any(|n| n == "mz_mcp_tool_call_duration_seconds"),
214 "mz_mcp_tool_call_duration_seconds should be registered, got: {names:?}",
215 );
216 }
217
218 #[mz_ore::test]
221 fn test_record_metrics() {
222 let registry = MetricsRegistry::new();
223 let metrics = McpMetrics::register_into(®istry);
224
225 metrics
226 .requests
227 .with_label_values(&["agent", "tools/call", "ok"])
228 .inc();
229 metrics
230 .requests
231 .with_label_values(&["agent", "tools/call", "ok"])
232 .inc();
233 metrics
234 .requests
235 .with_label_values(&["developer", "initialize", "ok"])
236 .inc();
237
238 metrics
239 .tool_calls
240 .with_label_values(&["agent", "read_data_product", "ok"])
241 .inc();
242 metrics
243 .tool_calls
244 .with_label_values(&["agent", "read_data_product", "DataProductNotFound"])
245 .inc();
246
247 metrics
248 .tool_call_duration
249 .with_label_values(&["agent", "read_data_product"])
250 .observe(0.123);
251
252 let gathered = registry.gather();
253
254 let requests = gathered
257 .iter()
258 .find(|m| m.name() == "mz_mcp_requests_total")
259 .expect("requests metric present");
260 assert_eq!(requests.get_metric().len(), 2);
261
262 let tool_calls = gathered
264 .iter()
265 .find(|m| m.name() == "mz_mcp_tool_calls_total")
266 .expect("tool_calls metric present");
267 assert_eq!(tool_calls.get_metric().len(), 2);
268
269 let duration = gathered
271 .iter()
272 .find(|m| m.name() == "mz_mcp_tool_call_duration_seconds")
273 .expect("tool_call_duration metric present");
274 assert_eq!(
275 duration.get_metric()[0].get_histogram().get_sample_count(),
276 1
277 );
278 }
279}