1use anyhow::{Context, Result};
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18static DEFAULT_FILTERS: &str = include_str!("../config/default.toml");
20
21static 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 ];
36
37#[derive(Debug, Clone)]
51pub struct Config {
52 pub upstream: UpstreamConfig,
54 pub filters: FilterConfig,
56 pub tracking: TrackingConfig,
58 pub preset: Option<String>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
64pub struct UpstreamConfig {
65 pub command: String,
67 #[serde(default)]
69 pub args: Vec<String>,
70 #[serde(default)]
73 pub env: HashMap<String, String>,
74}
75
76#[derive(Debug, Clone, Deserialize, Default)]
78pub struct FilterConfig {
79 #[serde(default)]
81 pub default: ToolFilterRules,
82 #[serde(default, alias = "tools")]
84 pub tools: HashMap<String, ToolFilterRules>,
85}
86
87#[derive(Debug, Clone, Deserialize, Default)]
92pub struct ToolFilterRules {
93 #[serde(default)]
95 pub keep_fields: Vec<String>,
96 #[serde(default)]
98 pub strip_fields: Vec<String>,
99 #[serde(default)]
101 pub condense_users: Option<bool>,
102 #[serde(default)]
104 pub truncate_strings_at: Option<usize>,
105 #[serde(default)]
107 pub max_array_items: Option<usize>,
108 #[serde(default)]
110 pub strip_nulls: Option<bool>,
111 #[serde(default)]
113 pub flatten: Option<bool>,
114 #[serde(default)]
116 pub custom_transforms: Vec<CustomTransform>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
121pub struct CustomTransform {
122 pub pattern: String,
124 pub replacement: String,
126}
127
128#[derive(Debug, Clone, Deserialize)]
130pub struct TrackingConfig {
131 #[serde(default = "default_tracking_enabled")]
133 pub enabled: bool,
134 #[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#[derive(Debug, Clone, Deserialize)]
169pub struct PresetConfig {
170 #[serde(default)]
172 pub meta: Option<PresetMeta>,
173 #[serde(default)]
174 pub tools: HashMap<String, ToolFilterRules>,
175}
176
177#[derive(Debug, Clone, Deserialize, Default)]
180pub struct PresetMeta {
181 #[serde(default)]
183 pub keywords: Vec<String>,
184}
185
186#[derive(Debug, Clone)]
188pub struct ExternalPreset {
189 pub name: String,
191 pub keywords: Vec<String>,
193 pub config: PresetConfig,
195 pub path: PathBuf,
197}
198
199pub 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#[derive(Debug, Clone, Deserialize)]
217pub(crate) struct UserConfig {
218 #[serde(default)]
220 pub upstream: Option<UpstreamConfig>,
221 #[serde(default)]
222 pub(crate) filters: Option<FilterConfig>,
223 #[serde(default)]
224 tracking: Option<TrackingConfig>,
225 #[serde(default)]
227 preset: Option<String>,
228}
229
230fn 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 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 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 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 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 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 if let Some(user) = user_config {
319 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 filters.default = merge_tool_rules(&filters.default, &user_filters.default);
328 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 let upstream = Self::resolve_env(upstream);
340
341 Ok(Config {
342 upstream,
343 filters,
344 tracking,
345 preset: preset_name,
346 })
347 }
348
349 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 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 fn load_defaults() -> Result<FilterConfig> {
410 toml::from_str(DEFAULT_FILTERS).context("Failed to parse built-in defaults")
411 }
412
413 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 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 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 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 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 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 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 pub fn get_tool_rules(&self, tool_name: &str) -> MergedRules {
577 let defaults = &self.filters.default;
578
579 if let Some(specific) = self.filters.tools.get(tool_name) {
581 return MergedRules::merge(defaults, Some(specific));
582 }
583
584 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 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
609pub 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 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 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
657pub fn show_preset(name: &str) -> Result<()> {
659 use crate::display::*;
660
661 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 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
720fn 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#[derive(Debug, Clone)]
751pub struct MergedRules {
752 pub keep_fields: Vec<String>,
754 pub strip_fields: Vec<String>,
756 pub condense_users: bool,
758 pub truncate_strings_at: usize,
760 pub max_array_items: usize,
762 pub strip_nulls: bool,
764 pub flatten: bool,
766 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
807pub 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 let preset_result = toml::from_str::<PresetConfig>(&content);
825 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 println!(" {DIM}Tools defined:{RESET} {BOLD}{}{RESET}", tools.len());
850
851 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 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 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 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 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 assert!(!rules.keep_fields.is_empty());
1007 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 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 std::fs::write(
1095 temp.join("jira.toml"),
1096 r#"
1097[tools.list_issues]
1098keep_fields = ["key", "summary"]
1099"#,
1100 )
1101 .unwrap();
1102
1103 std::fs::write(temp.join("bad.toml"), "not valid {{{{").unwrap();
1105
1106 std::fs::write(temp.join("readme.txt"), "ignore me").unwrap();
1108
1109 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 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 assert_eq!(
1170 Config::detect_preset_all(&["npx", "gitlab-mcp"], &externals),
1171 Some("gitlab".to_string())
1172 );
1173 assert_eq!(
1175 Config::detect_preset_all(&["npx", "github-mcp"], &externals),
1176 Some("github".to_string())
1177 );
1178 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}