Skip to main content

mcp_rtk/
config.rs

1//! Configuration loading with preset auto-detection.
2//!
3//! mcp-rtk uses a layered configuration approach:
4//!
5//! 1. **Generic defaults** (`config/default.toml`) — sensible rules for any MCP.
6//! 2. **Presets** (`config/presets/*.toml`) — community-contributed, tool-specific
7//!    filter rules for known MCP servers. Auto-detected from the upstream command.
8//! 3. **User config** (optional `--config`) — power-user overrides.
9//!
10//! All layers are merged: presets add tool rules on top of defaults, and user
11//! config overrides everything.
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// Generic default filter rules (no tool-specific entries).
19static DEFAULT_FILTERS: &str = include_str!("../config/default.toml");
20
21/// Known presets, embedded at compile time.
22static PRESETS: &[(&str, &[&str], &str)] = &[
23    (
24        "gitlab",
25        &["gitlab-mcp", "gitlab"],
26        include_str!("../config/presets/gitlab.toml"),
27    ),
28    (
29        "grafana",
30        &["mcp-grafana", "grafana"],
31        include_str!("../config/presets/grafana.toml"),
32    ),
33    // To add a new preset:
34    // ("github", &["github-mcp", "github"], include_str!("../config/presets/github.toml")),
35];
36
37/// Top-level configuration for mcp-rtk.
38///
39/// # Examples
40///
41/// ```no_run
42/// # use mcp_rtk::config::Config;
43/// # fn example() -> anyhow::Result<()> {
44/// let config = Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None)?;
45/// let rules = config.get_tool_rules("list_merge_requests");
46/// assert!(!rules.keep_fields.is_empty());
47/// # Ok(())
48/// # }
49/// ```
50#[derive(Debug, Clone)]
51pub struct Config {
52    /// Upstream MCP server command and environment.
53    pub upstream: UpstreamConfig,
54    /// Filter rules (default + preset + user overrides).
55    pub filters: FilterConfig,
56    /// Token-savings tracking configuration.
57    pub tracking: TrackingConfig,
58    /// Name of the detected/selected preset, if any.
59    pub preset: Option<String>,
60}
61
62/// How to spawn the upstream MCP server.
63#[derive(Debug, Clone, Deserialize)]
64pub struct UpstreamConfig {
65    /// The executable to run (e.g. `"node"`).
66    pub command: String,
67    /// Arguments passed to the command.
68    #[serde(default)]
69    pub args: Vec<String>,
70    /// Extra environment variables. Values starting with `$` are resolved from
71    /// the current process environment.
72    #[serde(default)]
73    pub env: HashMap<String, String>,
74}
75
76/// Container for the default filter rules and per-tool overrides.
77#[derive(Debug, Clone, Deserialize, Default)]
78pub struct FilterConfig {
79    /// Rules applied to every tool unless overridden.
80    #[serde(default)]
81    pub default: ToolFilterRules,
82    /// Per-tool overrides, keyed by MCP tool name.
83    #[serde(default, alias = "tools")]
84    pub tools: HashMap<String, ToolFilterRules>,
85}
86
87/// Declarative filter rules for a single tool (or the default).
88///
89/// All fields are optional so that tool-specific sections only need to
90/// specify what they override.
91#[derive(Debug, Clone, Deserialize, Default)]
92pub struct ToolFilterRules {
93    /// Whitelist of JSON field names to keep (applied first).
94    #[serde(default)]
95    pub keep_fields: Vec<String>,
96    /// Blacklist of JSON field names to strip recursively.
97    #[serde(default)]
98    pub strip_fields: Vec<String>,
99    /// Replace user objects (`{id, name, username, …}`) with just `"username"`.
100    #[serde(default)]
101    pub condense_users: Option<bool>,
102    /// Maximum character length for any string value.
103    #[serde(default)]
104    pub truncate_strings_at: Option<usize>,
105    /// Maximum number of items in any JSON array.
106    #[serde(default)]
107    pub max_array_items: Option<usize>,
108    /// Remove all `null` and empty-string fields.
109    #[serde(default)]
110    pub strip_nulls: Option<bool>,
111    /// Unwrap single-key wrapper objects (`{"data": [...]}` → `[...]`).
112    #[serde(default)]
113    pub flatten: Option<bool>,
114    /// Regex-based string replacements applied last.
115    #[serde(default)]
116    pub custom_transforms: Vec<CustomTransform>,
117}
118
119/// A single regex-based string replacement.
120#[derive(Debug, Clone, Deserialize)]
121pub struct CustomTransform {
122    /// The regex pattern to match.
123    pub pattern: String,
124    /// The replacement string (supports `$1`-style capture groups).
125    pub replacement: String,
126}
127
128/// SQLite tracking configuration.
129#[derive(Debug, Clone, Deserialize)]
130pub struct TrackingConfig {
131    /// Whether to record per-call metrics.
132    #[serde(default = "default_tracking_enabled")]
133    pub enabled: bool,
134    /// Path to the SQLite database. Supports `~/` expansion.
135    #[serde(default = "default_db_path")]
136    pub db_path: String,
137}
138
139impl Default for TrackingConfig {
140    fn default() -> Self {
141        Self {
142            enabled: default_tracking_enabled(),
143            db_path: default_db_path(),
144        }
145    }
146}
147
148fn default_tracking_enabled() -> bool {
149    true
150}
151
152fn default_db_path() -> String {
153    "~/.local/share/mcp-rtk/metrics.db".to_string()
154}
155
156/// Preset filter rules (no upstream section — just `[tools.*]`).
157///
158/// External presets can include an optional `[meta]` section with detection
159/// keywords for auto-discovery:
160///
161/// ```toml
162/// [meta]
163/// keywords = ["github-mcp", "github"]
164///
165/// [tools.list_repos]
166/// keep_fields = ["id", "name", "full_name"]
167/// ```
168#[derive(Debug, Clone, Deserialize)]
169pub struct PresetConfig {
170    /// Optional metadata for auto-detection (used by external presets).
171    #[serde(default)]
172    pub meta: Option<PresetMeta>,
173    #[serde(default)]
174    pub tools: HashMap<String, ToolFilterRules>,
175}
176
177/// Metadata for an external preset, enabling auto-detection from the upstream
178/// command.
179#[derive(Debug, Clone, Deserialize, Default)]
180pub struct PresetMeta {
181    /// Keywords that trigger this preset when found in the upstream command.
182    #[serde(default)]
183    pub keywords: Vec<String>,
184}
185
186/// An external preset loaded from the filesystem at runtime.
187#[derive(Debug, Clone)]
188pub struct ExternalPreset {
189    /// Preset name (derived from the filename without `.toml`).
190    pub name: String,
191    /// Keywords for auto-detection (from `[meta]`).
192    pub keywords: Vec<String>,
193    /// The parsed preset configuration.
194    pub config: PresetConfig,
195    /// Path to the source TOML file.
196    pub path: PathBuf,
197}
198
199/// Return the directory for external (user/community) presets.
200///
201/// Defaults to `~/.local/share/mcp-rtk/presets/`. The directory is created
202/// if it does not exist.
203pub fn external_presets_dir() -> Result<PathBuf> {
204    let home = std::env::var("HOME").context("HOME not set")?;
205    let dir = PathBuf::from(home)
206        .join(".local")
207        .join("share")
208        .join("mcp-rtk")
209        .join("presets");
210    std::fs::create_dir_all(&dir)
211        .context(format!("Failed to create presets dir: {}", dir.display()))?;
212    Ok(dir)
213}
214
215/// User-supplied configuration file. All sections are optional.
216#[derive(Debug, Clone, Deserialize)]
217pub(crate) struct UserConfig {
218    /// Optional upstream override (env vars from config are merged).
219    #[serde(default)]
220    pub upstream: Option<UpstreamConfig>,
221    #[serde(default)]
222    pub(crate) filters: Option<FilterConfig>,
223    #[serde(default)]
224    tracking: Option<TrackingConfig>,
225    /// Explicitly select a preset (overrides auto-detection).
226    #[serde(default)]
227    preset: Option<String>,
228}
229
230/// Simple glob matching: `*` matches any sequence of characters, `?` matches
231/// exactly one character. No other special syntax is supported.
232fn glob_match(pattern: &str, text: &str) -> bool {
233    let p: Vec<char> = pattern.chars().collect();
234    let t: Vec<char> = text.chars().collect();
235    let mut pi = 0;
236    let mut ti = 0;
237    let mut star_pi = usize::MAX;
238    let mut star_ti = 0;
239
240    while ti < t.len() {
241        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
242            pi += 1;
243            ti += 1;
244        } else if pi < p.len() && p[pi] == '*' {
245            star_pi = pi;
246            star_ti = ti;
247            pi += 1;
248        } else if star_pi != usize::MAX {
249            pi = star_pi + 1;
250            star_ti += 1;
251            ti = star_ti;
252        } else {
253            return false;
254        }
255    }
256
257    while pi < p.len() && p[pi] == '*' {
258        pi += 1;
259    }
260
261    pi == p.len()
262}
263
264impl Config {
265    /// Build configuration from upstream command args with optional user config.
266    ///
267    /// This is the primary entry point. The upstream command is taken from
268    /// `upstream_args` (e.g. `["npx", "@nicepkg/gitlab-mcp"]`). A preset is
269    /// auto-detected from the command (including external presets from
270    /// `~/.local/share/mcp-rtk/presets/`), and an optional user config file
271    /// provides overrides.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the user config file cannot be read or parsed.
276    pub fn from_upstream(upstream_args: &[&str], config_path: Option<&Path>) -> Result<Self> {
277        let defaults = Self::load_defaults()?;
278        let externals = Self::load_external_presets();
279
280        // Build upstream from args
281        let mut upstream = if let Some((cmd, args)) = upstream_args.split_first() {
282            UpstreamConfig {
283                command: cmd.to_string(),
284                args: args.iter().map(|s| s.to_string()).collect(),
285                env: HashMap::new(),
286            }
287        } else {
288            anyhow::bail!("No upstream command provided. Usage: mcp-rtk -- <command> [args...]");
289        };
290
291        // Load user config if provided
292        let user_config = if let Some(path) = config_path {
293            let content = std::fs::read_to_string(path).context("Failed to read config file")?;
294            Some(toml::from_str::<UserConfig>(&content).context("Failed to parse config file")?)
295        } else {
296            None
297        };
298
299        // Determine preset: user explicit > auto-detect (embedded + external)
300        let preset_name = user_config
301            .as_ref()
302            .and_then(|u| u.preset.clone())
303            .or_else(|| Self::detect_preset_all(upstream_args, &externals));
304
305        // Layer: defaults → preset → user config
306        let mut filters = defaults;
307        if let Some(ref name) = preset_name {
308            if let Some(preset) = Self::load_preset_all(name, &externals) {
309                for (k, v) in preset.tools {
310                    filters.tools.insert(k, v);
311                }
312            }
313        }
314
315        let mut tracking = TrackingConfig::default();
316
317        // Apply user overrides
318        if let Some(user) = user_config {
319            // Merge env vars from user config upstream (if any)
320            if let Some(user_upstream) = user.upstream {
321                for (k, v) in user_upstream.env {
322                    upstream.env.insert(k, v);
323                }
324            }
325            if let Some(user_filters) = user.filters {
326                // User default rules merge on top
327                filters.default = merge_tool_rules(&filters.default, &user_filters.default);
328                // User tool rules override
329                for (k, v) in user_filters.tools {
330                    filters.tools.insert(k, v);
331                }
332            }
333            if let Some(t) = user.tracking {
334                tracking = t;
335            }
336        }
337
338        // Resolve upstream env: inherit from parent process env
339        let upstream = Self::resolve_env(upstream);
340
341        Ok(Config {
342            upstream,
343            filters,
344            tracking,
345            preset: preset_name,
346        })
347    }
348
349    /// Build a complete config with optional preset override.
350    ///
351    /// Convenience wrapper around [`from_upstream`](Self::from_upstream) that
352    /// also applies a `--preset` override. Used by both initial startup and
353    /// hot reload to avoid duplicating the override logic.
354    pub fn build(
355        upstream_args: &[&str],
356        config_path: Option<&Path>,
357        preset_override: Option<&str>,
358    ) -> Result<Self> {
359        let mut config = Self::from_upstream(upstream_args, config_path)?;
360
361        if let Some(preset_name) = preset_override {
362            if let Some(preset_rules) = Self::load_preset_by_name(preset_name) {
363                for (k, v) in preset_rules {
364                    config.filters.tools.insert(k, v);
365                }
366                config.preset = Some(preset_name.to_string());
367            } else {
368                anyhow::bail!(
369                    "Unknown preset: {preset_name}\nAvailable: {}",
370                    Self::available_presets().join(", ")
371                );
372            }
373        }
374
375        Ok(config)
376    }
377
378    /// Load configuration for the `gain` subcommand (no upstream needed).
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if the user config file cannot be read or parsed.
383    pub fn load_for_gain(config_path: Option<&Path>) -> Result<Self> {
384        let defaults = Self::load_defaults()?;
385        let mut tracking = TrackingConfig::default();
386
387        if let Some(path) = config_path {
388            let content = std::fs::read_to_string(path).context("Failed to read config file")?;
389            let user: UserConfig =
390                toml::from_str(&content).context("Failed to parse config file")?;
391            if let Some(t) = user.tracking {
392                tracking = t;
393            }
394        }
395
396        Ok(Config {
397            upstream: UpstreamConfig {
398                command: String::new(),
399                args: vec![],
400                env: HashMap::new(),
401            },
402            filters: defaults,
403            tracking,
404            preset: None,
405        })
406    }
407
408    /// Load the generic default filter rules.
409    fn load_defaults() -> Result<FilterConfig> {
410        toml::from_str(DEFAULT_FILTERS).context("Failed to parse built-in defaults")
411    }
412
413    /// Auto-detect a preset name from the upstream command args (embedded only).
414    fn detect_preset(args: &[&str]) -> Option<String> {
415        let joined = args.join(" ").to_lowercase();
416        for (name, keywords, _) in PRESETS {
417            for keyword in *keywords {
418                if joined.contains(keyword) {
419                    return Some(name.to_string());
420                }
421            }
422        }
423        None
424    }
425
426    /// Auto-detect a preset name from upstream args, checking both embedded
427    /// and external presets. Embedded presets take priority.
428    fn detect_preset_all(args: &[&str], externals: &[ExternalPreset]) -> Option<String> {
429        if let Some(name) = Self::detect_preset(args) {
430            return Some(name);
431        }
432        let joined = args.join(" ").to_lowercase();
433        for ext in externals {
434            for keyword in &ext.keywords {
435                if joined.contains(&keyword.to_lowercase()) {
436                    return Some(ext.name.clone());
437                }
438            }
439        }
440        None
441    }
442
443    /// Load a preset's tool rules by name (embedded + external).
444    ///
445    /// Returns the tool-specific filter rules from the preset, or `None` if
446    /// the preset name is unknown.
447    pub fn load_preset_by_name(name: &str) -> Option<HashMap<String, ToolFilterRules>> {
448        let externals = Self::load_external_presets();
449        Self::load_preset_all(name, &externals).map(|p| p.tools)
450    }
451
452    /// Load a preset by name from the embedded presets.
453    fn load_preset(name: &str) -> Option<PresetConfig> {
454        for (preset_name, _, toml_content) in PRESETS {
455            if *preset_name == name {
456                return toml::from_str(toml_content).ok();
457            }
458        }
459        None
460    }
461
462    /// Load a preset by name, checking both embedded and external presets.
463    /// Embedded presets take priority.
464    fn load_preset_all(name: &str, externals: &[ExternalPreset]) -> Option<PresetConfig> {
465        if let Some(preset) = Self::load_preset(name) {
466            return Some(preset);
467        }
468        externals
469            .iter()
470            .find(|e| e.name == name)
471            .map(|e| e.config.clone())
472    }
473
474    /// Scan `~/.local/share/mcp-rtk/presets/` for external preset TOML files.
475    ///
476    /// Each `.toml` file is parsed as a [`PresetConfig`]. The preset name is
477    /// derived from the filename (without extension). An optional `[meta]`
478    /// section provides detection keywords for auto-discovery.
479    ///
480    /// Invalid files are silently skipped.
481    pub fn load_external_presets() -> Vec<ExternalPreset> {
482        let dir = match external_presets_dir() {
483            Ok(d) => d,
484            Err(_) => return vec![],
485        };
486
487        let entries = match std::fs::read_dir(&dir) {
488            Ok(e) => e,
489            Err(_) => return vec![],
490        };
491
492        let mut presets = Vec::new();
493        for entry in entries.flatten() {
494            let path = entry.path();
495            if path.extension() != Some(std::ffi::OsStr::new("toml")) {
496                continue;
497            }
498            let content = match std::fs::read_to_string(&path) {
499                Ok(c) => c,
500                Err(_) => continue,
501            };
502            let config = match toml::from_str::<PresetConfig>(&content) {
503                Ok(c) => c,
504                Err(e) => {
505                    tracing::warn!("Skipping invalid preset {}: {e}", path.display());
506                    continue;
507                }
508            };
509            let name = path
510                .file_stem()
511                .and_then(|s| s.to_str())
512                .unwrap_or("unknown")
513                .to_string();
514            let keywords = config
515                .meta
516                .as_ref()
517                .map(|m| m.keywords.clone())
518                .unwrap_or_default();
519            presets.push(ExternalPreset {
520                name,
521                keywords,
522                config,
523                path,
524            });
525        }
526
527        if !presets.is_empty() {
528            let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
529            tracing::debug!(
530                "Loaded {} external preset(s): {}",
531                presets.len(),
532                names.join(", ")
533            );
534        }
535
536        presets
537    }
538
539    /// Resolve env vars: values starting with `$` are read from the process env.
540    ///
541    /// Env vars from the parent process are also inherited automatically by the
542    /// child process, so most env vars don't need to be in the config at all.
543    ///
544    /// # Security
545    ///
546    /// Only config values explicitly prefixed with `$` are resolved. The config
547    /// file itself must be trusted — anyone who can write to it can control which
548    /// env vars are forwarded and which command is spawned.
549    fn resolve_env(mut upstream: UpstreamConfig) -> UpstreamConfig {
550        let resolved: HashMap<String, String> = upstream
551            .env
552            .iter()
553            .map(|(k, v)| {
554                let resolved = if let Some(var_name) = v.strip_prefix('$') {
555                    std::env::var(var_name).unwrap_or_default()
556                } else {
557                    v.clone()
558                };
559                (k.clone(), resolved)
560            })
561            .collect();
562        upstream.env = resolved;
563        upstream
564    }
565
566    /// Return the merged filter rules for a given tool name.
567    ///
568    /// Tool-specific rules override the defaults. Lists (`strip_fields`,
569    /// `custom_transforms`) are concatenated; scalars use the tool value
570    /// if present, otherwise the default.
571    ///
572    /// Lookup order:
573    /// 1. Exact match by tool name (fast path).
574    /// 2. Glob pattern match — keys containing `*` or `?` are tested against
575    ///    the tool name using [`glob_match`].
576    pub fn get_tool_rules(&self, tool_name: &str) -> MergedRules {
577        let defaults = &self.filters.default;
578
579        // Exact match first
580        if let Some(specific) = self.filters.tools.get(tool_name) {
581            return MergedRules::merge(defaults, Some(specific));
582        }
583
584        // Glob pattern match
585        for (pattern, rules) in &self.filters.tools {
586            if (pattern.contains('*') || pattern.contains('?')) && glob_match(pattern, tool_name) {
587                return MergedRules::merge(defaults, Some(rules));
588            }
589        }
590
591        MergedRules::merge(defaults, None)
592    }
593
594    /// List all available preset names (embedded + external).
595    pub fn available_presets() -> Vec<String> {
596        let mut names: Vec<String> = PRESETS
597            .iter()
598            .map(|(name, _, _)| name.to_string())
599            .collect();
600        for ext in Self::load_external_presets() {
601            if !names.iter().any(|n| n == &ext.name) {
602                names.push(ext.name);
603            }
604        }
605        names
606    }
607}
608
609/// Print a table of all available presets (embedded + external).
610pub fn list_presets() {
611    use crate::display::*;
612
613    println!();
614    println!("  {BOLD}{GREEN}MCP-RTK{RESET}{DIM} — Available Presets{RESET}");
615    println!("  {DIM}{}{RESET}", "─".repeat(56));
616
617    // Embedded presets
618    println!();
619    println!("  {DIM}Built-in:{RESET}");
620    for (name, keywords, toml_content) in PRESETS {
621        let tool_count = toml_content.matches("[tools.").count();
622        println!(
623            "  {BOLD}{WHITE}{:<12}{RESET}  {DIM}detected from:{RESET} {YELLOW}{}{RESET}  {DIM}({} tools){RESET}",
624            name,
625            keywords.join(", "),
626            tool_count,
627        );
628    }
629
630    // External presets
631    let externals = Config::load_external_presets();
632    if !externals.is_empty() {
633        println!();
634        println!("  {DIM}External (~/.local/share/mcp-rtk/presets/):{RESET}");
635        for ext in &externals {
636            let tool_count = ext.config.tools.len();
637            let kw = if ext.keywords.is_empty() {
638                "manual only".to_string()
639            } else {
640                ext.keywords.join(", ")
641            };
642            println!(
643                "  {BOLD}{WHITE}{:<12}{RESET}  {DIM}detected from:{RESET} {YELLOW}{}{RESET}  {DIM}({} tools){RESET}",
644                ext.name, kw, tool_count,
645            );
646        }
647    }
648
649    println!();
650    println!("  {DIM}Use `mcp-rtk presets show <name>` to see the full TOML.{RESET}");
651    if externals.is_empty() {
652        println!("  {DIM}Drop .toml presets in ~/.local/share/mcp-rtk/presets/ for auto-discovery.{RESET}");
653    }
654    println!();
655}
656
657/// Print the full TOML content of a named preset (embedded or external).
658pub fn show_preset(name: &str) -> Result<()> {
659    use crate::display::*;
660
661    // Check embedded presets
662    for (preset_name, keywords, toml_content) in PRESETS {
663        if *preset_name == name {
664            println!();
665            println!("  {BOLD}{GREEN}{}{RESET}{DIM} preset{RESET}", name);
666            println!("  {DIM}Auto-detected from: {}{RESET}", keywords.join(", "));
667            println!();
668            print_toml_highlighted(toml_content);
669            println!();
670            return Ok(());
671        }
672    }
673
674    // Check external presets
675    for ext in Config::load_external_presets() {
676        if ext.name == name {
677            let content = std::fs::read_to_string(&ext.path)
678                .context(format!("Failed to read {}", ext.path.display()))?;
679            let kw = if ext.keywords.is_empty() {
680                "none (use --preset to select)".to_string()
681            } else {
682                ext.keywords.join(", ")
683            };
684            println!();
685            println!(
686                "  {BOLD}{GREEN}{}{RESET}{DIM} preset (external){RESET}",
687                name
688            );
689            println!("  {DIM}Auto-detected from: {kw}{RESET}");
690            println!("  {DIM}Path: {}{RESET}", ext.path.display());
691            println!();
692            print_toml_highlighted(&content);
693            println!();
694            return Ok(());
695        }
696    }
697
698    anyhow::bail!(
699        "Unknown preset: {name}\nAvailable: {}",
700        Config::available_presets().join(", ")
701    );
702}
703
704fn print_toml_highlighted(content: &str) {
705    use crate::display::*;
706
707    for line in content.lines() {
708        if line.starts_with('#') {
709            println!("  {DIM}{line}{RESET}");
710        } else if line.starts_with("[tools.") || line.starts_with("[meta]") {
711            println!("  {BOLD}{CYAN}{line}{RESET}");
712        } else if line.is_empty() {
713            println!();
714        } else {
715            println!("  {line}");
716        }
717    }
718}
719
720/// Merge two sets of tool filter rules (user on top of base).
721fn merge_tool_rules(base: &ToolFilterRules, user: &ToolFilterRules) -> ToolFilterRules {
722    ToolFilterRules {
723        keep_fields: if user.keep_fields.is_empty() {
724            base.keep_fields.clone()
725        } else {
726            user.keep_fields.clone()
727        },
728        strip_fields: {
729            let mut fields = base.strip_fields.clone();
730            fields.extend(user.strip_fields.clone());
731            fields
732        },
733        condense_users: user.condense_users.or(base.condense_users),
734        truncate_strings_at: user.truncate_strings_at.or(base.truncate_strings_at),
735        max_array_items: user.max_array_items.or(base.max_array_items),
736        strip_nulls: user.strip_nulls.or(base.strip_nulls),
737        flatten: user.flatten.or(base.flatten),
738        custom_transforms: {
739            let mut t = base.custom_transforms.clone();
740            t.extend(user.custom_transforms.clone());
741            t
742        },
743    }
744}
745
746/// Fully resolved filter rules for a single tool call.
747///
748/// Produced by [`Config::get_tool_rules`] — the result of merging the default
749/// rules with any tool-specific overrides.
750#[derive(Debug, Clone)]
751pub struct MergedRules {
752    /// Whitelist of JSON field names to keep.
753    pub keep_fields: Vec<String>,
754    /// Blacklist of JSON field names to strip recursively.
755    pub strip_fields: Vec<String>,
756    /// Whether to condense user objects to bare usernames.
757    pub condense_users: bool,
758    /// Maximum character length for any string value.
759    pub truncate_strings_at: usize,
760    /// Maximum number of items in any JSON array.
761    pub max_array_items: usize,
762    /// Whether to remove null and empty-string fields.
763    pub strip_nulls: bool,
764    /// Whether to unwrap single-key wrapper objects.
765    pub flatten: bool,
766    /// Compiled regex-based string replacements.
767    pub custom_transforms: Vec<CustomTransform>,
768}
769
770impl MergedRules {
771    fn merge(defaults: &ToolFilterRules, specific: Option<&ToolFilterRules>) -> Self {
772        let s = specific.cloned().unwrap_or_default();
773        Self {
774            keep_fields: if s.keep_fields.is_empty() {
775                defaults.keep_fields.clone()
776            } else {
777                s.keep_fields
778            },
779            strip_fields: {
780                let mut fields = defaults.strip_fields.clone();
781                fields.extend(s.strip_fields);
782                fields
783            },
784            condense_users: s
785                .condense_users
786                .or(defaults.condense_users)
787                .unwrap_or(false),
788            truncate_strings_at: s
789                .truncate_strings_at
790                .or(defaults.truncate_strings_at)
791                .unwrap_or(usize::MAX),
792            max_array_items: s
793                .max_array_items
794                .or(defaults.max_array_items)
795                .unwrap_or(usize::MAX),
796            strip_nulls: s.strip_nulls.or(defaults.strip_nulls).unwrap_or(false),
797            flatten: s.flatten.or(defaults.flatten).unwrap_or(false),
798            custom_transforms: {
799                let mut t = defaults.custom_transforms.clone();
800                t.extend(s.custom_transforms);
801                t
802            },
803        }
804    }
805}
806
807/// Validate a preset or user config TOML file and print a diagnostic report.
808///
809/// Parses the file as either a preset (`[tools.*]` format) or a full user
810/// config (`[filters.*]` format). Reports the tools defined, active rules
811/// per tool, and any warnings (conflicting options, invalid regex, etc.).
812///
813/// # Errors
814///
815/// Returns an error if the file cannot be read or is not valid TOML for
816/// either format.
817pub fn validate_preset_file(path: &Path) -> Result<()> {
818    use crate::display::*;
819
820    let content = std::fs::read_to_string(path)
821        .context(format!("Failed to read file: {}", path.display()))?;
822
823    // Try parsing as a preset (tools.* format)
824    let preset_result = toml::from_str::<PresetConfig>(&content);
825    // Try parsing as a full user config (filters.* format)
826    let user_result = toml::from_str::<UserConfig>(&content);
827
828    let (tools, is_preset) = match (preset_result, user_result) {
829        (Ok(preset), _) => (preset.tools, true),
830        (_, Ok(user)) => {
831            let filters = user.filters.unwrap_or_default();
832            (filters.tools, false)
833        }
834        (Err(e1), Err(_)) => {
835            anyhow::bail!("Failed to parse TOML:\n{e1}");
836        }
837    };
838
839    let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
840
841    println!();
842    println!(
843        "  {BOLD}{GREEN}✓{RESET} {BOLD}{file_name}{RESET} is valid {}",
844        if is_preset { "preset" } else { "config" }
845    );
846    println!();
847
848    // Stats
849    println!("  {DIM}Tools defined:{RESET}  {BOLD}{}{RESET}", tools.len());
850
851    // List tools with their active rules
852    if !tools.is_empty() {
853        println!();
854        println!("  {DIM}Tool rules:{RESET}");
855        for (name, rules) in &tools {
856            let mut active = Vec::new();
857            if !rules.keep_fields.is_empty() {
858                active.push(format!("keep:{}", rules.keep_fields.len()));
859            }
860            if !rules.strip_fields.is_empty() {
861                active.push(format!("strip:{}", rules.strip_fields.len()));
862            }
863            if rules.condense_users == Some(true) {
864                active.push("condense_users".into());
865            }
866            if let Some(n) = rules.truncate_strings_at {
867                active.push(format!("truncate:{n}"));
868            }
869            if let Some(n) = rules.max_array_items {
870                active.push(format!("max_items:{n}"));
871            }
872            if rules.strip_nulls == Some(true) {
873                active.push("strip_nulls".into());
874            }
875            if rules.flatten == Some(true) {
876                active.push("flatten".into());
877            }
878            if !rules.custom_transforms.is_empty() {
879                active.push(format!("transforms:{}", rules.custom_transforms.len()));
880            }
881
882            println!(
883                "    {BOLD}{WHITE}{:<32}{RESET} {DIM}{}{RESET}",
884                name,
885                active.join(", ")
886            );
887        }
888    }
889
890    // Warnings
891    let mut warnings = Vec::new();
892    for (name, rules) in &tools {
893        if !rules.keep_fields.is_empty() && !rules.strip_fields.is_empty() {
894            warnings.push(format!(
895                "{name}: has both keep_fields and strip_fields (keep_fields takes priority, strip_fields may be redundant)"
896            ));
897        }
898        if rules.truncate_strings_at == Some(0) {
899            warnings.push(format!(
900                "{name}: truncate_strings_at is 0 (all strings will be empty)"
901            ));
902        }
903        if rules.max_array_items == Some(0) {
904            warnings.push(format!(
905                "{name}: max_array_items is 0 (all arrays will be empty)"
906            ));
907        }
908    }
909
910    // Validate custom_transforms regex patterns
911    for (name, rules) in &tools {
912        for (i, transform) in rules.custom_transforms.iter().enumerate() {
913            if regex::Regex::new(&transform.pattern).is_err() {
914                warnings.push(format!(
915                    "{name}: custom_transform[{i}] has invalid regex: {}",
916                    transform.pattern
917                ));
918            }
919        }
920    }
921
922    if !warnings.is_empty() {
923        println!();
924        println!("  {YELLOW}Warnings:{RESET}");
925        for w in &warnings {
926            println!("    {YELLOW}⚠{RESET}  {w}");
927        }
928    }
929
930    println!();
931    Ok(())
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    #[test]
939    fn load_defaults() {
940        let filters = Config::load_defaults().unwrap();
941        assert!(filters.default.strip_nulls.unwrap_or(false));
942        assert!(filters.default.condense_users.unwrap_or(false));
943        assert!(filters.default.flatten.unwrap_or(false));
944    }
945
946    #[test]
947    fn detect_gitlab_preset() {
948        assert_eq!(
949            Config::detect_preset(&["npx", "@nicepkg/gitlab-mcp"]),
950            Some("gitlab".to_string())
951        );
952        assert_eq!(
953            Config::detect_preset(&["node", "/path/to/gitlab-mcp/build/index.js"]),
954            Some("gitlab".to_string())
955        );
956    }
957
958    #[test]
959    fn detect_no_preset() {
960        assert_eq!(
961            Config::detect_preset(&["node", "/path/to/custom-server.js"]),
962            None
963        );
964    }
965
966    #[test]
967    fn from_upstream_with_gitlab_preset() {
968        let config = Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap();
969        assert_eq!(config.preset, Some("gitlab".to_string()));
970        assert_eq!(config.upstream.command, "npx");
971        assert_eq!(config.upstream.args, vec!["@nicepkg/gitlab-mcp"]);
972        // GitLab preset should have tool-specific rules
973        let rules = config.get_tool_rules("list_merge_requests");
974        assert!(!rules.keep_fields.is_empty());
975        assert!(rules.condense_users);
976    }
977
978    #[test]
979    fn from_upstream_without_preset() {
980        let config = Config::from_upstream(&["node", "my-custom-server.js"], None).unwrap();
981        assert_eq!(config.preset, None);
982        // Should still have generic defaults
983        let rules = config.get_tool_rules("any_tool");
984        assert!(rules.strip_nulls);
985        assert!(rules.condense_users);
986        assert!(rules.keep_fields.is_empty());
987    }
988
989    #[test]
990    fn from_upstream_no_args_fails() {
991        let result = Config::from_upstream(&[], None);
992        assert!(result.is_err());
993    }
994
995    #[test]
996    fn available_presets_includes_gitlab() {
997        let presets = Config::available_presets();
998        assert!(presets.iter().any(|p| p == "gitlab"));
999    }
1000
1001    #[test]
1002    fn get_tool_rules_merges_preset_and_defaults() {
1003        let config = Config::from_upstream(&["npx", "@nicepkg/gitlab-mcp"], None).unwrap();
1004        let rules = config.get_tool_rules("list_merge_requests");
1005        // From preset
1006        assert!(!rules.keep_fields.is_empty());
1007        // From defaults
1008        assert!(rules.strip_nulls);
1009        assert!(rules.strip_fields.contains(&"avatar_url".to_string()));
1010    }
1011
1012    #[test]
1013    fn glob_match_star() {
1014        assert!(glob_match("list_*", "list_issues"));
1015        assert!(glob_match("list_*", "list_merge_requests"));
1016        assert!(!glob_match("list_*", "get_issue"));
1017        assert!(glob_match("*_requests", "list_merge_requests"));
1018        assert!(glob_match("*", "anything"));
1019    }
1020
1021    #[test]
1022    fn glob_match_question() {
1023        assert!(glob_match("get_issue?", "get_issues"));
1024        assert!(!glob_match("get_issue?", "get_issue"));
1025        assert!(glob_match("get_?ssue", "get_issue"));
1026    }
1027
1028    #[test]
1029    fn glob_match_exact() {
1030        assert!(glob_match("list_issues", "list_issues"));
1031        assert!(!glob_match("list_issues", "list_merge_requests"));
1032    }
1033
1034    #[test]
1035    fn get_tool_rules_glob_pattern() {
1036        let mut config = Config::from_upstream(&["echo", "test-server"], None).unwrap();
1037        config.filters.tools.insert(
1038            "list_*".to_string(),
1039            ToolFilterRules {
1040                keep_fields: vec!["id".to_string(), "name".to_string()],
1041                max_array_items: Some(5),
1042                ..Default::default()
1043            },
1044        );
1045
1046        let rules = config.get_tool_rules("list_something");
1047        assert_eq!(rules.keep_fields, vec!["id", "name"]);
1048        assert_eq!(rules.max_array_items, 5);
1049    }
1050
1051    #[test]
1052    fn get_tool_rules_exact_match_takes_priority_over_glob() {
1053        let mut config = Config::from_upstream(&["echo", "test-server"], None).unwrap();
1054        config.filters.tools.insert(
1055            "list_*".to_string(),
1056            ToolFilterRules {
1057                keep_fields: vec!["id".to_string(), "name".to_string()],
1058                ..Default::default()
1059            },
1060        );
1061        config.filters.tools.insert(
1062            "list_special".to_string(),
1063            ToolFilterRules {
1064                keep_fields: vec!["special_field".to_string()],
1065                ..Default::default()
1066            },
1067        );
1068
1069        let rules = config.get_tool_rules("list_special");
1070        assert_eq!(rules.keep_fields, vec!["special_field"]);
1071    }
1072
1073    #[test]
1074    fn load_external_presets_from_dir() {
1075        let temp = std::env::temp_dir().join("mcp-rtk-test-ext-presets");
1076        let _ = std::fs::remove_dir_all(&temp);
1077        std::fs::create_dir_all(&temp).unwrap();
1078
1079        // Write an external preset with meta
1080        std::fs::write(
1081            temp.join("github.toml"),
1082            r#"
1083[meta]
1084keywords = ["github-mcp", "github"]
1085
1086[tools.list_repos]
1087keep_fields = ["id", "name", "full_name"]
1088max_array_items = 20
1089"#,
1090        )
1091        .unwrap();
1092
1093        // Write one without meta
1094        std::fs::write(
1095            temp.join("jira.toml"),
1096            r#"
1097[tools.list_issues]
1098keep_fields = ["key", "summary"]
1099"#,
1100        )
1101        .unwrap();
1102
1103        // Write an invalid file (should be skipped)
1104        std::fs::write(temp.join("bad.toml"), "not valid {{{{").unwrap();
1105
1106        // Write a non-toml file (should be skipped)
1107        std::fs::write(temp.join("readme.txt"), "ignore me").unwrap();
1108
1109        // Manually scan the temp dir
1110        let entries = std::fs::read_dir(&temp).unwrap();
1111        let mut presets = Vec::new();
1112        for entry in entries.flatten() {
1113            let path = entry.path();
1114            if path.extension() != Some(std::ffi::OsStr::new("toml")) {
1115                continue;
1116            }
1117            let content = match std::fs::read_to_string(&path) {
1118                Ok(c) => c,
1119                Err(_) => continue,
1120            };
1121            let config = match toml::from_str::<super::PresetConfig>(&content) {
1122                Ok(c) => c,
1123                Err(_) => continue,
1124            };
1125            let name = path
1126                .file_stem()
1127                .and_then(|s| s.to_str())
1128                .unwrap_or("unknown")
1129                .to_string();
1130            let keywords = config
1131                .meta
1132                .as_ref()
1133                .map(|m| m.keywords.clone())
1134                .unwrap_or_default();
1135            presets.push(super::ExternalPreset {
1136                name,
1137                keywords,
1138                config,
1139                path,
1140            });
1141        }
1142
1143        // Should find github and jira, not bad.toml or readme.txt
1144        assert_eq!(presets.len(), 2);
1145        let github = presets.iter().find(|p| p.name == "github").unwrap();
1146        assert_eq!(github.keywords, vec!["github-mcp", "github"]);
1147        assert!(github.config.tools.contains_key("list_repos"));
1148
1149        let jira = presets.iter().find(|p| p.name == "jira").unwrap();
1150        assert!(jira.keywords.is_empty());
1151        assert!(jira.config.tools.contains_key("list_issues"));
1152
1153        let _ = std::fs::remove_dir_all(&temp);
1154    }
1155
1156    #[test]
1157    fn detect_preset_all_finds_external() {
1158        let externals = vec![super::ExternalPreset {
1159            name: "github".to_string(),
1160            keywords: vec!["github-mcp".to_string(), "github".to_string()],
1161            config: super::PresetConfig {
1162                meta: None,
1163                tools: HashMap::new(),
1164            },
1165            path: std::path::PathBuf::from("/tmp/github.toml"),
1166        }];
1167
1168        // Embedded takes priority
1169        assert_eq!(
1170            Config::detect_preset_all(&["npx", "gitlab-mcp"], &externals),
1171            Some("gitlab".to_string())
1172        );
1173        // External is found
1174        assert_eq!(
1175            Config::detect_preset_all(&["npx", "github-mcp"], &externals),
1176            Some("github".to_string())
1177        );
1178        // No match
1179        assert_eq!(
1180            Config::detect_preset_all(&["node", "custom-server"], &externals),
1181            None
1182        );
1183    }
1184
1185    #[test]
1186    fn preset_config_parses_with_meta() {
1187        let toml_str = r#"
1188[meta]
1189keywords = ["test-mcp", "test"]
1190
1191[tools.list_items]
1192keep_fields = ["id", "name"]
1193"#;
1194        let config: super::PresetConfig = toml::from_str(toml_str).unwrap();
1195        let meta = config.meta.unwrap();
1196        assert_eq!(meta.keywords, vec!["test-mcp", "test"]);
1197        assert!(config.tools.contains_key("list_items"));
1198    }
1199
1200    #[test]
1201    fn preset_config_parses_without_meta() {
1202        let toml_str = r#"
1203[tools.list_items]
1204keep_fields = ["id", "name"]
1205"#;
1206        let config: super::PresetConfig = toml::from_str(toml_str).unwrap();
1207        assert!(config.meta.is_none());
1208        assert!(config.tools.contains_key("list_items"));
1209    }
1210}