1pub mod json;
19
20use crate::config::{Config, CustomTransform, MergedRules};
21use regex::Regex;
22use serde_json::Value;
23use std::collections::HashMap;
24use std::sync::Arc;
25
26pub struct FilterEngine {
42 config: Arc<Config>,
43 compiled_transforms: HashMap<String, Vec<(Regex, String)>>,
46}
47
48impl FilterEngine {
49 pub fn new(config: Arc<Config>) -> Self {
53 let mut compiled_transforms = HashMap::new();
54
55 for tool_name in config.filters.tools.keys() {
57 let rules = config.get_tool_rules(tool_name);
58 if !rules.custom_transforms.is_empty() {
59 compiled_transforms.insert(
60 tool_name.clone(),
61 compile_transforms(&rules.custom_transforms),
62 );
63 }
64 }
65
66 if !config.filters.default.custom_transforms.is_empty() {
68 compiled_transforms.insert(
69 String::new(),
70 compile_transforms(&config.filters.default.custom_transforms),
71 );
72 }
73
74 Self {
75 config,
76 compiled_transforms,
77 }
78 }
79
80 pub fn config(&self) -> &Config {
82 &self.config
83 }
84
85 const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
87
88 pub fn filter(&self, tool_name: &str, raw: &str) -> String {
94 let rules = self.config.get_tool_rules(tool_name);
95
96 if raw.len() > Self::MAX_RESPONSE_BYTES {
98 tracing::warn!(
99 tool = tool_name,
100 size = raw.len(),
101 "Response exceeds {} bytes, applying plain-text truncation only",
102 Self::MAX_RESPONSE_BYTES,
103 );
104 return self.filter_plain_text(raw, &rules);
105 }
106
107 let parsed = serde_json::from_str::<Value>(raw);
108 let mut value = match parsed {
109 Ok(v) => v,
110 Err(_) => {
111 return self.filter_plain_text(raw, &rules);
113 }
114 };
115
116 self.apply_pipeline(tool_name, &mut value, &rules);
117 serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string())
118 }
119
120 fn apply_pipeline(&self, tool_name: &str, value: &mut Value, rules: &MergedRules) {
121 if !rules.keep_fields.is_empty() {
123 json::keep_fields(value, &rules.keep_fields);
124 }
125
126 if !rules.strip_fields.is_empty() {
128 json::strip_fields(value, &rules.strip_fields);
129 }
130
131 if rules.condense_users {
133 json::condense_user_objects(value);
134 }
135
136 if rules.strip_nulls {
138 json::strip_null_fields(value);
139 }
140
141 if rules.flatten {
143 json::flatten_single_key_objects(value);
144 }
145
146 json::truncate_strings(value, rules.truncate_strings_at);
148
149 json::collapse_arrays(value, rules.max_array_items);
151
152 if !rules.custom_transforms.is_empty() {
154 if let Some(compiled) = self.compiled_transforms.get(tool_name) {
155 json::apply_custom_transforms(value, compiled);
156 } else if let Some(compiled) = self.compiled_transforms.get("") {
157 json::apply_custom_transforms(value, compiled);
159 }
160 }
161 }
162
163 fn filter_plain_text(&self, text: &str, rules: &MergedRules) -> String {
164 let limit = rules.truncate_strings_at.min(Self::MAX_RESPONSE_BYTES);
166 if limit < text.len() {
167 let mut end = limit;
168 while end > 0 && !text.is_char_boundary(end) {
169 end -= 1;
170 }
171 let mut truncated = text[..end].to_string();
172 truncated.push_str("...[truncated]");
173 truncated
174 } else {
175 text.to_string()
176 }
177 }
178}
179
180fn compile_transforms(transforms: &[CustomTransform]) -> Vec<(Regex, String)> {
184 transforms
185 .iter()
186 .filter_map(|t| {
187 Regex::new(&t.pattern)
188 .ok()
189 .map(|re| (re, t.replacement.clone()))
190 })
191 .collect()
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::config::Config;
198 use serde_json::json;
199
200 fn test_config() -> Config {
201 Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap()
202 }
203
204 #[test]
205 fn test_filter_list_merge_requests() {
206 let config = Arc::new(test_config());
207 let engine = FilterEngine::new(config);
208
209 let input = json!([{
210 "iid": 42,
211 "title": "Fix login",
212 "state": "opened",
213 "author": {"id": 1, "name": "John", "username": "john", "avatar_url": "http://..."},
214 "source_branch": "fix-login",
215 "target_branch": "main",
216 "web_url": "https://gitlab.com/mr/42",
217 "description": "A very long description that should not appear",
218 "created_at": "2024-01-01",
219 "updated_at": "2024-01-02",
220 "_links": {"self": "..."},
221 "task_completion_status": {"count": 0},
222 "time_stats": {},
223 "extra_field": true
224 }]);
225
226 let result = engine.filter("list_merge_requests", &input.to_string());
227 let parsed: Value = serde_json::from_str(&result).unwrap();
228
229 assert!(parsed[0].get("iid").is_some());
231 assert!(parsed[0].get("title").is_some());
232 assert!(parsed[0].get("state").is_some());
233 assert_eq!(parsed[0]["author"], json!({"id": 1, "username": "john"}));
235 assert!(parsed[0].get("description").is_none());
237 assert!(parsed[0].get("_links").is_none());
238 assert!(parsed[0].get("extra_field").is_none());
239 }
240
241 #[test]
242 fn test_filter_plain_text_truncation() {
243 let config = Arc::new(test_config());
244 let engine = FilterEngine::new(config);
245
246 let long_text = "x".repeat(10000);
247 let result = engine.filter("get_job_log", &long_text);
248 assert!(result.len() < 10000);
249 assert!(result.ends_with("...[truncated]"));
250 }
251}