pub mod json;
use crate::config::{Config, CustomTransform, MergedRules};
use regex::Regex;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
pub struct FilterEngine {
config: Arc<Config>,
compiled_transforms: HashMap<String, Vec<(Regex, String)>>,
}
impl FilterEngine {
pub fn new(config: Arc<Config>) -> Self {
let mut compiled_transforms = HashMap::new();
for tool_name in config.filters.tools.keys() {
let rules = config.get_tool_rules(tool_name);
if !rules.custom_transforms.is_empty() {
compiled_transforms.insert(
tool_name.clone(),
compile_transforms(&rules.custom_transforms),
);
}
}
if !config.filters.default.custom_transforms.is_empty() {
compiled_transforms.insert(
String::new(),
compile_transforms(&config.filters.default.custom_transforms),
);
}
Self {
config,
compiled_transforms,
}
}
pub fn config(&self) -> &Config {
&self.config
}
const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
pub fn filter(&self, tool_name: &str, raw: &str) -> String {
let rules = self.config.get_tool_rules(tool_name);
if raw.len() > Self::MAX_RESPONSE_BYTES {
tracing::warn!(
tool = tool_name,
size = raw.len(),
"Response exceeds {} bytes, applying plain-text truncation only",
Self::MAX_RESPONSE_BYTES,
);
return self.filter_plain_text(raw, &rules);
}
let parsed = serde_json::from_str::<Value>(raw);
let mut value = match parsed {
Ok(v) => v,
Err(_) => {
return self.filter_plain_text(raw, &rules);
}
};
self.apply_pipeline(tool_name, &mut value, &rules);
serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string())
}
fn apply_pipeline(&self, tool_name: &str, value: &mut Value, rules: &MergedRules) {
if !rules.keep_fields.is_empty() {
json::keep_fields(value, &rules.keep_fields);
}
if !rules.strip_fields.is_empty() {
json::strip_fields(value, &rules.strip_fields);
}
if rules.condense_users {
json::condense_user_objects(value);
}
if rules.strip_nulls {
json::strip_null_fields(value);
}
if rules.flatten {
json::flatten_single_key_objects(value);
}
json::truncate_strings(value, rules.truncate_strings_at);
json::collapse_arrays(value, rules.max_array_items);
if !rules.custom_transforms.is_empty() {
if let Some(compiled) = self.compiled_transforms.get(tool_name) {
json::apply_custom_transforms(value, compiled);
} else if let Some(compiled) = self.compiled_transforms.get("") {
json::apply_custom_transforms(value, compiled);
}
}
}
fn filter_plain_text(&self, text: &str, rules: &MergedRules) -> String {
let limit = rules.truncate_strings_at.min(Self::MAX_RESPONSE_BYTES);
if limit < text.len() {
let mut end = limit;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
let mut truncated = text[..end].to_string();
truncated.push_str("...[truncated]");
truncated
} else {
text.to_string()
}
}
}
fn compile_transforms(transforms: &[CustomTransform]) -> Vec<(Regex, String)> {
transforms
.iter()
.filter_map(|t| {
Regex::new(&t.pattern)
.ok()
.map(|re| (re, t.replacement.clone()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use serde_json::json;
fn test_config() -> Config {
Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap()
}
#[test]
fn test_filter_list_merge_requests() {
let config = Arc::new(test_config());
let engine = FilterEngine::new(config);
let input = json!([{
"iid": 42,
"title": "Fix login",
"state": "opened",
"author": {"id": 1, "name": "John", "username": "john", "avatar_url": "http://..."},
"source_branch": "fix-login",
"target_branch": "main",
"web_url": "https://gitlab.com/mr/42",
"description": "A very long description that should not appear",
"created_at": "2024-01-01",
"updated_at": "2024-01-02",
"_links": {"self": "..."},
"task_completion_status": {"count": 0},
"time_stats": {},
"extra_field": true
}]);
let result = engine.filter("list_merge_requests", &input.to_string());
let parsed: Value = serde_json::from_str(&result).unwrap();
assert!(parsed[0].get("iid").is_some());
assert!(parsed[0].get("title").is_some());
assert!(parsed[0].get("state").is_some());
assert_eq!(parsed[0]["author"], json!({"id": 1, "username": "john"}));
assert!(parsed[0].get("description").is_none());
assert!(parsed[0].get("_links").is_none());
assert!(parsed[0].get("extra_field").is_none());
}
#[test]
fn test_filter_plain_text_truncation() {
let config = Arc::new(test_config());
let engine = FilterEngine::new(config);
let long_text = "x".repeat(10000);
let result = engine.filter("get_job_log", &long_text);
assert!(result.len() < 10000);
assert!(result.ends_with("...[truncated]"));
}
}