use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
static DEFAULT_FILTERS: &str = include_str!("../config/default.toml");
static PRESETS: &[(&str, &[&str], &str)] = &[
(
"gitlab",
&["gitlab-mcp", "gitlab"],
include_str!("../config/presets/gitlab.toml"),
),
(
"grafana",
&["mcp-grafana", "grafana"],
include_str!("../config/presets/grafana.toml"),
),
];
#[derive(Debug, Clone)]
pub struct Config {
pub upstream: UpstreamConfig,
pub filters: FilterConfig,
pub tracking: TrackingConfig,
pub preset: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpstreamConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FilterConfig {
#[serde(default)]
pub default: ToolFilterRules,
#[serde(default, alias = "tools")]
pub tools: HashMap<String, ToolFilterRules>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ToolFilterRules {
#[serde(default)]
pub keep_fields: Vec<String>,
#[serde(default)]
pub strip_fields: Vec<String>,
#[serde(default)]
pub condense_users: Option<bool>,
#[serde(default)]
pub truncate_strings_at: Option<usize>,
#[serde(default)]
pub max_array_items: Option<usize>,
#[serde(default)]
pub strip_nulls: Option<bool>,
#[serde(default)]
pub flatten: Option<bool>,
#[serde(default)]
pub custom_transforms: Vec<CustomTransform>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CustomTransform {
pub pattern: String,
pub replacement: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TrackingConfig {
#[serde(default = "default_tracking_enabled")]
pub enabled: bool,
#[serde(default = "default_db_path")]
pub db_path: String,
}
impl Default for TrackingConfig {
fn default() -> Self {
Self {
enabled: default_tracking_enabled(),
db_path: default_db_path(),
}
}
}
fn default_tracking_enabled() -> bool {
true
}
fn default_db_path() -> String {
"~/.local/share/mcp-rtk/metrics.db".to_string()
}
#[derive(Debug, Clone, Deserialize)]
struct PresetConfig {
#[serde(default)]
tools: HashMap<String, ToolFilterRules>,
}
#[derive(Debug, Clone, Deserialize)]
struct UserConfig {
#[serde(default)]
pub upstream: Option<UpstreamConfig>,
#[serde(default)]
filters: Option<FilterConfig>,
#[serde(default)]
tracking: Option<TrackingConfig>,
#[serde(default)]
preset: Option<String>,
}
impl Config {
pub fn from_upstream(upstream_args: &[&str], config_path: Option<&Path>) -> Result<Self> {
let defaults = Self::load_defaults()?;
let mut upstream = if let Some((cmd, args)) = upstream_args.split_first() {
UpstreamConfig {
command: cmd.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
env: HashMap::new(),
}
} else {
anyhow::bail!("No upstream command provided. Usage: mcp-rtk -- <command> [args...]");
};
let user_config = if let Some(path) = config_path {
let content = std::fs::read_to_string(path).context("Failed to read config file")?;
Some(toml::from_str::<UserConfig>(&content).context("Failed to parse config file")?)
} else {
None
};
let preset_name = user_config
.as_ref()
.and_then(|u| u.preset.clone())
.or_else(|| Self::detect_preset(upstream_args));
let mut filters = defaults;
if let Some(ref name) = preset_name {
if let Some(preset) = Self::load_preset(name) {
for (k, v) in preset.tools {
filters.tools.insert(k, v);
}
}
}
let mut tracking = TrackingConfig::default();
if let Some(user) = user_config {
if let Some(user_upstream) = user.upstream {
for (k, v) in user_upstream.env {
upstream.env.insert(k, v);
}
}
if let Some(user_filters) = user.filters {
filters.default = merge_tool_rules(&filters.default, &user_filters.default);
for (k, v) in user_filters.tools {
filters.tools.insert(k, v);
}
}
if let Some(t) = user.tracking {
tracking = t;
}
}
let upstream = Self::resolve_env(upstream);
Ok(Config {
upstream,
filters,
tracking,
preset: preset_name,
})
}
pub fn load_for_gain(config_path: Option<&Path>) -> Result<Self> {
let defaults = Self::load_defaults()?;
let mut tracking = TrackingConfig::default();
if let Some(path) = config_path {
let content = std::fs::read_to_string(path).context("Failed to read config file")?;
let user: UserConfig =
toml::from_str(&content).context("Failed to parse config file")?;
if let Some(t) = user.tracking {
tracking = t;
}
}
Ok(Config {
upstream: UpstreamConfig {
command: String::new(),
args: vec![],
env: HashMap::new(),
},
filters: defaults,
tracking,
preset: None,
})
}
fn load_defaults() -> Result<FilterConfig> {
toml::from_str(DEFAULT_FILTERS).context("Failed to parse built-in defaults")
}
fn detect_preset(args: &[&str]) -> Option<String> {
let joined = args.join(" ").to_lowercase();
for (name, keywords, _) in PRESETS {
for keyword in *keywords {
if joined.contains(keyword) {
return Some(name.to_string());
}
}
}
None
}
pub fn load_preset_by_name(name: &str) -> Option<HashMap<String, ToolFilterRules>> {
Self::load_preset(name).map(|p| p.tools)
}
fn load_preset(name: &str) -> Option<PresetConfig> {
for (preset_name, _, toml_content) in PRESETS {
if *preset_name == name {
return toml::from_str(toml_content).ok();
}
}
None
}
fn resolve_env(mut upstream: UpstreamConfig) -> UpstreamConfig {
let resolved: HashMap<String, String> = upstream
.env
.iter()
.map(|(k, v)| {
let resolved = if let Some(var_name) = v.strip_prefix('$') {
std::env::var(var_name).unwrap_or_default()
} else {
v.clone()
};
(k.clone(), resolved)
})
.collect();
upstream.env = resolved;
upstream
}
pub fn get_tool_rules(&self, tool_name: &str) -> MergedRules {
let defaults = &self.filters.default;
let tool_specific = self.filters.tools.get(tool_name);
MergedRules::merge(defaults, tool_specific)
}
pub fn available_presets() -> Vec<&'static str> {
PRESETS.iter().map(|(name, _, _)| *name).collect()
}
}
fn merge_tool_rules(base: &ToolFilterRules, user: &ToolFilterRules) -> ToolFilterRules {
ToolFilterRules {
keep_fields: if user.keep_fields.is_empty() {
base.keep_fields.clone()
} else {
user.keep_fields.clone()
},
strip_fields: {
let mut fields = base.strip_fields.clone();
fields.extend(user.strip_fields.clone());
fields
},
condense_users: user.condense_users.or(base.condense_users),
truncate_strings_at: user.truncate_strings_at.or(base.truncate_strings_at),
max_array_items: user.max_array_items.or(base.max_array_items),
strip_nulls: user.strip_nulls.or(base.strip_nulls),
flatten: user.flatten.or(base.flatten),
custom_transforms: {
let mut t = base.custom_transforms.clone();
t.extend(user.custom_transforms.clone());
t
},
}
}
#[derive(Debug, Clone)]
pub struct MergedRules {
pub keep_fields: Vec<String>,
pub strip_fields: Vec<String>,
pub condense_users: bool,
pub truncate_strings_at: usize,
pub max_array_items: usize,
pub strip_nulls: bool,
pub flatten: bool,
pub custom_transforms: Vec<CustomTransform>,
}
impl MergedRules {
fn merge(defaults: &ToolFilterRules, specific: Option<&ToolFilterRules>) -> Self {
let s = specific.cloned().unwrap_or_default();
Self {
keep_fields: if s.keep_fields.is_empty() {
defaults.keep_fields.clone()
} else {
s.keep_fields
},
strip_fields: {
let mut fields = defaults.strip_fields.clone();
fields.extend(s.strip_fields);
fields
},
condense_users: s
.condense_users
.or(defaults.condense_users)
.unwrap_or(false),
truncate_strings_at: s
.truncate_strings_at
.or(defaults.truncate_strings_at)
.unwrap_or(usize::MAX),
max_array_items: s
.max_array_items
.or(defaults.max_array_items)
.unwrap_or(usize::MAX),
strip_nulls: s.strip_nulls.or(defaults.strip_nulls).unwrap_or(false),
flatten: s.flatten.or(defaults.flatten).unwrap_or(false),
custom_transforms: {
let mut t = defaults.custom_transforms.clone();
t.extend(s.custom_transforms);
t
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_defaults() {
let filters = Config::load_defaults().unwrap();
assert!(filters.default.strip_nulls.unwrap_or(false));
assert!(filters.default.condense_users.unwrap_or(false));
assert!(filters.default.flatten.unwrap_or(false));
}
#[test]
fn detect_gitlab_preset() {
assert_eq!(
Config::detect_preset(&["npx", "@nicepkg/gitlab-mcp"]),
Some("gitlab".to_string())
);
assert_eq!(
Config::detect_preset(&["node", "/path/to/gitlab-mcp/build/index.js"]),
Some("gitlab".to_string())
);
}
#[test]
fn detect_no_preset() {
assert_eq!(
Config::detect_preset(&["node", "/path/to/custom-server.js"]),
None
);
}
#[test]
fn from_upstream_with_gitlab_preset() {
let config = Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap();
assert_eq!(config.preset, Some("gitlab".to_string()));
assert_eq!(config.upstream.command, "npx");
assert_eq!(config.upstream.args, vec!["@nicepkg/gitlab-mcp"]);
let rules = config.get_tool_rules("list_merge_requests");
assert!(!rules.keep_fields.is_empty());
assert!(rules.condense_users);
}
#[test]
fn from_upstream_without_preset() {
let config = Config::from_upstream(&["node", "my-custom-server.js"], None).unwrap();
assert_eq!(config.preset, None);
let rules = config.get_tool_rules("any_tool");
assert!(rules.strip_nulls);
assert!(rules.condense_users);
assert!(rules.keep_fields.is_empty());
}
#[test]
fn from_upstream_no_args_fails() {
let result = Config::from_upstream(&[], None);
assert!(result.is_err());
}
#[test]
fn available_presets_includes_gitlab() {
let presets = Config::available_presets();
assert!(presets.contains(&"gitlab"));
}
#[test]
fn get_tool_rules_merges_preset_and_defaults() {
let config = Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap();
let rules = config.get_tool_rules("list_merge_requests");
assert!(!rules.keep_fields.is_empty());
assert!(rules.strip_nulls);
assert!(rules.strip_fields.contains(&"avatar_url".to_string()));
}
}