1use crate::mcp::error::McpError;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::sync::{Arc, Mutex};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum McpDecisionLabel {
14 Allowed,
15 Denied,
16 RateLimited,
17 ApprovalRequired,
18 Sanitized,
19}
20
21impl McpDecisionLabel {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Allowed => "allowed",
25 Self::Denied => "denied",
26 Self::RateLimited => "rate_limited",
27 Self::ApprovalRequired => "approval_required",
28 Self::Sanitized => "sanitized",
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub enum McpThreatLabel {
36 ToolPoisoning,
37 RugPull,
38 CrossServerAttack,
39 DescriptionInjection,
40 SchemaAbuse,
41 HiddenInstruction,
42 PromptInjectionTag,
43 ImperativePhrasing,
44 CredentialLeakage,
45 ExfiltrationUrl,
46}
47
48impl McpThreatLabel {
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::ToolPoisoning => "tool_poisoning",
52 Self::RugPull => "rug_pull",
53 Self::CrossServerAttack => "cross_server_attack",
54 Self::DescriptionInjection => "description_injection",
55 Self::SchemaAbuse => "schema_abuse",
56 Self::HiddenInstruction => "hidden_instruction",
57 Self::PromptInjectionTag => "prompt_injection_tag",
58 Self::ImperativePhrasing => "imperative_phrasing",
59 Self::CredentialLeakage => "credential_leakage",
60 Self::ExfiltrationUrl => "exfiltration_url",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum McpScanLabel {
68 Response,
69 ToolMetadata,
70 Gateway,
71}
72
73impl McpScanLabel {
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Response => "response",
77 Self::ToolMetadata => "tool_metadata",
78 Self::Gateway => "gateway",
79 }
80 }
81}
82
83#[derive(Debug, Default)]
84struct McpMetricsState {
85 decisions: BTreeMap<String, u64>,
86 threats_detected: BTreeMap<String, u64>,
87 rate_limit_hits: BTreeMap<String, u64>,
88 scans: BTreeMap<String, u64>,
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct McpMetricsCollector {
94 state: Arc<Mutex<McpMetricsState>>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct McpMetricsSnapshot {
100 pub mcp_decisions: BTreeMap<String, u64>,
101 pub mcp_threats_detected: BTreeMap<String, u64>,
102 pub mcp_rate_limit_hits: BTreeMap<String, u64>,
103 pub mcp_scans: BTreeMap<String, u64>,
104}
105
106impl McpMetricsCollector {
107 pub fn record_decision(&self, label: McpDecisionLabel) -> Result<(), McpError> {
108 self.increment("metrics", |state| &mut state.decisions, label.as_str())
109 }
110
111 pub fn record_threat(&self, label: McpThreatLabel) -> Result<(), McpError> {
112 self.increment(
113 "metrics",
114 |state| &mut state.threats_detected,
115 label.as_str(),
116 )
117 }
118
119 pub fn record_rate_limit_hit(&self, label: &str) -> Result<(), McpError> {
120 self.increment("metrics", |state| &mut state.rate_limit_hits, label)
121 }
122
123 pub fn record_scan(&self, label: McpScanLabel) -> Result<(), McpError> {
124 self.increment("metrics", |state| &mut state.scans, label.as_str())
125 }
126
127 pub fn snapshot(&self) -> Result<McpMetricsSnapshot, McpError> {
128 let state = self
129 .state
130 .lock()
131 .map_err(|_| McpError::store("metrics", "metrics lock poisoned"))?;
132 Ok(McpMetricsSnapshot {
133 mcp_decisions: state.decisions.clone(),
134 mcp_threats_detected: state.threats_detected.clone(),
135 mcp_rate_limit_hits: state.rate_limit_hits.clone(),
136 mcp_scans: state.scans.clone(),
137 })
138 }
139
140 fn increment<F>(&self, store: &'static str, select: F, key: &str) -> Result<(), McpError>
141 where
142 F: FnOnce(&mut McpMetricsState) -> &mut BTreeMap<String, u64>,
143 {
144 let mut state = self
145 .state
146 .lock()
147 .map_err(|_| McpError::store(store, "metrics lock poisoned"))?;
148 let entry = select(&mut state).entry(key.to_string()).or_insert(0);
149 *entry += 1;
150 Ok(())
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn metrics_snapshot_is_categorical() {
160 let metrics = McpMetricsCollector::default();
161 metrics.record_decision(McpDecisionLabel::Allowed).unwrap();
162 metrics.record_threat(McpThreatLabel::SchemaAbuse).unwrap();
163 let snapshot = metrics.snapshot().unwrap();
164 assert_eq!(snapshot.mcp_decisions["allowed"], 1);
165 assert_eq!(snapshot.mcp_threats_detected["schema_abuse"], 1);
166 }
167}