Skip to main content

greentic_dev/
mcp_cmd.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, btree_map::Entry};
7
8use crate::path_safety::normalize_under_root;
9
10pub fn doctor(target: &str, json: bool) -> Result<()> {
11    let workspace_root = std::env::current_dir()
12        .context("failed to resolve workspace root")?
13        .canonicalize()
14        .context("failed to canonicalize workspace root")?;
15    let config_path = locate_toolmap(&workspace_root, target)?;
16    let config = load_tool_map_config(&config_path)
17        .with_context(|| format!("failed to load MCP tool map from {}", config_path.display()))?;
18    let map = ToolMap::from_config(&config).context("tool map contains duplicate tool names")?;
19    let report = ToolMapReport::from_map(&config_path, &map);
20
21    if json {
22        println!(
23            "{}",
24            serde_json::to_string_pretty(&report).context("failed to encode JSON report")?
25        );
26    } else {
27        print_report(&report);
28    }
29
30    Ok(())
31}
32
33#[derive(Debug, Clone, Deserialize)]
34struct ToolRef {
35    name: String,
36    component: String,
37    entry: String,
38    #[serde(default)]
39    timeout_ms: Option<u64>,
40    #[serde(default)]
41    max_retries: Option<u32>,
42    #[serde(default)]
43    retry_backoff_ms: Option<u64>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47struct ToolMapConfig {
48    tools: Vec<ToolRef>,
49}
50
51#[derive(Debug, Clone)]
52struct ToolMap {
53    tools: BTreeMap<String, ToolRef>,
54}
55
56impl ToolMap {
57    fn from_config(config: &ToolMapConfig) -> Result<Self> {
58        let mut tools = BTreeMap::new();
59        for tool in &config.tools {
60            match tools.entry(tool.name.clone()) {
61                Entry::Vacant(slot) => {
62                    slot.insert(tool.clone());
63                }
64                Entry::Occupied(_) => {
65                    bail!("tool map contains duplicate tool names");
66                }
67            }
68        }
69        Ok(Self { tools })
70    }
71
72    fn iter(&self) -> impl Iterator<Item = (&String, &ToolRef)> {
73        self.tools.iter()
74    }
75}
76
77fn load_tool_map_config(path: &Path) -> Result<ToolMapConfig> {
78    let content = fs::read_to_string(path)
79        .with_context(|| format!("failed to read MCP tool map {}", path.display()))?;
80    if is_json(path, &content) {
81        Ok(serde_json::from_str(&content)
82            .with_context(|| format!("invalid MCP tool map JSON {}", path.display()))?)
83    } else {
84        Ok(serde_yaml_bw::from_str(&content)
85            .with_context(|| format!("invalid MCP tool map YAML {}", path.display()))?)
86    }
87}
88
89fn is_json(path: &Path, content: &str) -> bool {
90    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
91        if matches!(ext, "json") {
92            return true;
93        }
94        if matches!(ext, "yaml" | "yml") {
95            return false;
96        }
97    }
98    content
99        .chars()
100        .find(|c| !c.is_whitespace())
101        .is_some_and(|c| c == '{' || c == '[')
102}
103
104fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
105    let initial = PathBuf::from(target);
106    if initial.is_absolute() {
107        bail!("tool map path must be relative to the workspace root");
108    }
109
110    let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
111
112    for candidate in candidates {
113        let joined = workspace_root.join(&candidate);
114        if joined.is_file() {
115            return normalize_under_root(workspace_root, &candidate);
116        }
117        if joined.is_dir() {
118            let safe_dir = normalize_under_root(workspace_root, &candidate)?;
119            for name in [
120                "toolmap.yaml",
121                "toolmap.yml",
122                "toolmap.json",
123                "mcp.yaml",
124                "mcp.json",
125            ] {
126                let file = safe_dir.join(name);
127                if file.is_file() {
128                    return Ok(file);
129                }
130            }
131        }
132    }
133
134    bail!("unable to find MCP tool map at `{target}`")
135}
136
137#[derive(Debug, Serialize)]
138struct ToolMapReport {
139    tool_map_path: String,
140    tool_count: usize,
141    tools: Vec<ToolHealth>,
142    warnings: Vec<String>,
143}
144
145#[derive(Debug, Serialize)]
146struct ToolHealth {
147    name: String,
148    entry: String,
149    component: String,
150    resolved_path: String,
151    exists: bool,
152    size_bytes: Option<u64>,
153    timeout_ms: Option<u64>,
154    max_retries: u32,
155    retry_backoff_ms: u64,
156}
157
158impl ToolMapReport {
159    fn from_map(config_path: &Path, map: &ToolMap) -> Self {
160        let base_dir = config_path
161            .parent()
162            .map(|parent| parent.to_path_buf())
163            .unwrap_or_else(|| PathBuf::from("."));
164
165        let mut warnings = Vec::new();
166        let mut tools = Vec::new();
167
168        for (_, tool) in map.iter() {
169            let resolved_path = resolve_component_path(&base_dir, &tool.component);
170            let (exists, size) = match fs::metadata(&resolved_path) {
171                Ok(meta) if meta.is_file() => (true, Some(meta.len())),
172                _ => {
173                    warnings.push(format!(
174                        "tool `{}` component missing at {}",
175                        tool.name,
176                        resolved_path.display()
177                    ));
178                    (false, None)
179                }
180            };
181
182            tools.push(ToolHealth {
183                name: tool.name.clone(),
184                entry: tool.entry.clone(),
185                component: tool.component.clone(),
186                resolved_path: resolved_path.display().to_string(),
187                exists,
188                size_bytes: size,
189                timeout_ms: tool.timeout_ms,
190                max_retries: tool.max_retries.unwrap_or(0),
191                retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
192            });
193        }
194
195        Self {
196            tool_map_path: config_path.display().to_string(),
197            tool_count: tools.len(),
198            tools,
199            warnings,
200        }
201    }
202}
203
204fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
205    let raw = PathBuf::from(component);
206    if raw.is_absolute() {
207        raw
208    } else {
209        base_dir.join(raw)
210    }
211}
212
213fn print_report(report: &ToolMapReport) {
214    println!("MCP tool map: {}", report.tool_map_path);
215    println!("Tools: {}", report.tool_count);
216    for tool in &report.tools {
217        println!("- {}", tool.name);
218        println!("  entry: {}", tool.entry);
219        println!(
220            "  component: {}{}",
221            tool.resolved_path,
222            if tool.exists { "" } else { " (missing)" }
223        );
224        println!(
225            "  timeout: {}",
226            tool.timeout_ms
227                .map(|ms| format!("{ms} ms"))
228                .unwrap_or_else(|| "not set".into())
229        );
230        println!(
231            "  retries: {} (backoff {} ms)",
232            tool.max_retries, tool.retry_backoff_ms
233        );
234        if let Some(size) = tool.size_bytes {
235            println!("  size: {size} bytes");
236        }
237    }
238    if !report.warnings.is_empty() {
239        println!("\nWarnings:");
240        for warning in &report.warnings {
241            println!("  - {warning}");
242        }
243    }
244}