Skip to main content

greentic_dev/
mcp_cmd.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command as ProcessCommand;
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, btree_map::Entry};
8
9use crate::path_safety::normalize_under_root;
10
11pub fn doctor(target: Option<&str>, json: bool) -> Result<()> {
12    let Some(target) = target else {
13        // No tool map supplied: report only generator + wasm toolchain readiness.
14        let generator = GeneratorStatus::detect();
15        if json {
16            println!(
17                "{}",
18                serde_json::to_string_pretty(&generator).context("failed to encode JSON report")?
19            );
20        } else {
21            print_generator_status(&generator);
22        }
23        return Ok(());
24    };
25
26    let workspace_root = std::env::current_dir()
27        .context("failed to resolve workspace root")?
28        .canonicalize()
29        .context("failed to canonicalize workspace root")?;
30    let config_path = locate_toolmap(&workspace_root, target)?;
31    let config = load_tool_map_config(&config_path)
32        .with_context(|| format!("failed to load MCP tool map from {}", config_path.display()))?;
33    let map = ToolMap::from_config(&config).context("tool map contains duplicate tool names")?;
34    let report = ToolMapReport::from_map(&config_path, &map);
35
36    if json {
37        println!(
38            "{}",
39            serde_json::to_string_pretty(&report).context("failed to encode JSON report")?
40        );
41    } else {
42        print_report(&report);
43    }
44
45    Ok(())
46}
47
48#[derive(Debug, Clone, Deserialize)]
49struct ToolRef {
50    name: String,
51    component: String,
52    entry: String,
53    #[serde(default)]
54    timeout_ms: Option<u64>,
55    #[serde(default)]
56    max_retries: Option<u32>,
57    #[serde(default)]
58    retry_backoff_ms: Option<u64>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62struct ToolMapConfig {
63    tools: Vec<ToolRef>,
64}
65
66#[derive(Debug, Serialize)]
67struct GeneratorStatus {
68    binary_name: String,
69    resolved_path: Option<String>,
70    version: Option<String>,
71    cargo_available: bool,
72    wasm_target_installed: bool,
73}
74
75impl GeneratorStatus {
76    fn absent() -> Self {
77        Self {
78            binary_name: "greentic-mcp-gen".to_string(),
79            resolved_path: None,
80            version: None,
81            cargo_available: cargo_available(),
82            wasm_target_installed: wasm_target_installed(),
83        }
84    }
85
86    fn detect() -> Self {
87        match crate::passthrough::resolve_external_tool("greentic-mcp-gen") {
88            Ok(path) => {
89                let version = ProcessCommand::new(&path)
90                    .arg("--version")
91                    .output()
92                    .ok()
93                    .filter(|out| out.status.success())
94                    .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
95                    .filter(|s| !s.is_empty());
96                Self {
97                    binary_name: "greentic-mcp-gen".to_string(),
98                    resolved_path: Some(path.display().to_string()),
99                    version,
100                    cargo_available: cargo_available(),
101                    wasm_target_installed: wasm_target_installed(),
102                }
103            }
104            Err(_) => Self::absent(),
105        }
106    }
107}
108
109/// Best-effort: is `cargo` invokable?
110fn cargo_available() -> bool {
111    ProcessCommand::new("cargo")
112        .arg("--version")
113        .output()
114        .map(|out| out.status.success())
115        .unwrap_or(false)
116}
117
118/// Best-effort: is the `wasm32-wasip2` target installed?
119fn wasm_target_installed() -> bool {
120    ProcessCommand::new("rustup")
121        .args(["target", "list", "--installed"])
122        .output()
123        .ok()
124        .filter(|out| out.status.success())
125        .map(|out| String::from_utf8_lossy(&out.stdout).contains("wasm32-wasip2"))
126        .unwrap_or(false)
127}
128
129#[derive(Debug, Clone)]
130struct ToolMap {
131    tools: BTreeMap<String, ToolRef>,
132}
133
134impl ToolMap {
135    fn from_config(config: &ToolMapConfig) -> Result<Self> {
136        let mut tools = BTreeMap::new();
137        for tool in &config.tools {
138            match tools.entry(tool.name.clone()) {
139                Entry::Vacant(slot) => {
140                    slot.insert(tool.clone());
141                }
142                Entry::Occupied(_) => {
143                    bail!("tool map contains duplicate tool names");
144                }
145            }
146        }
147        Ok(Self { tools })
148    }
149
150    fn iter(&self) -> impl Iterator<Item = (&String, &ToolRef)> {
151        self.tools.iter()
152    }
153}
154
155fn load_tool_map_config(path: &Path) -> Result<ToolMapConfig> {
156    let content = fs::read_to_string(path)
157        .with_context(|| format!("failed to read MCP tool map {}", path.display()))?;
158    if is_json(path, &content) {
159        Ok(serde_json::from_str(&content)
160            .with_context(|| format!("invalid MCP tool map JSON {}", path.display()))?)
161    } else {
162        Ok(serde_yaml_bw::from_str(&content)
163            .with_context(|| format!("invalid MCP tool map YAML {}", path.display()))?)
164    }
165}
166
167fn is_json(path: &Path, content: &str) -> bool {
168    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
169        if matches!(ext, "json") {
170            return true;
171        }
172        if matches!(ext, "yaml" | "yml") {
173            return false;
174        }
175    }
176    content
177        .chars()
178        .find(|c| !c.is_whitespace())
179        .is_some_and(|c| c == '{' || c == '[')
180}
181
182fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
183    let initial = PathBuf::from(target);
184    if initial.is_absolute() {
185        bail!("tool map path must be relative to the workspace root");
186    }
187
188    let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
189
190    for candidate in candidates {
191        let joined = workspace_root.join(&candidate);
192        if joined.is_file() {
193            return normalize_under_root(workspace_root, &candidate);
194        }
195        if joined.is_dir() {
196            let safe_dir = normalize_under_root(workspace_root, &candidate)?;
197            for name in [
198                "toolmap.yaml",
199                "toolmap.yml",
200                "toolmap.json",
201                "mcp.yaml",
202                "mcp.json",
203            ] {
204                let file = safe_dir.join(name);
205                if file.is_file() {
206                    return Ok(file);
207                }
208            }
209        }
210    }
211
212    bail!("unable to find MCP tool map at `{target}`")
213}
214
215#[derive(Debug, Serialize)]
216struct ToolMapReport {
217    tool_map_path: String,
218    tool_count: usize,
219    tools: Vec<ToolHealth>,
220    warnings: Vec<String>,
221    generator: GeneratorStatus,
222}
223
224#[derive(Debug, Serialize)]
225struct ToolHealth {
226    name: String,
227    entry: String,
228    component: String,
229    resolved_path: String,
230    exists: bool,
231    size_bytes: Option<u64>,
232    timeout_ms: Option<u64>,
233    max_retries: u32,
234    retry_backoff_ms: u64,
235}
236
237impl ToolMapReport {
238    fn from_map(config_path: &Path, map: &ToolMap) -> Self {
239        let base_dir = config_path
240            .parent()
241            .map(|parent| parent.to_path_buf())
242            .unwrap_or_else(|| PathBuf::from("."));
243
244        let mut warnings = Vec::new();
245        let mut tools = Vec::new();
246
247        for (_, tool) in map.iter() {
248            let resolved_path = resolve_component_path(&base_dir, &tool.component);
249            let (exists, size) = match fs::metadata(&resolved_path) {
250                Ok(meta) if meta.is_file() => (true, Some(meta.len())),
251                _ => {
252                    warnings.push(format!(
253                        "tool `{}` component missing at {}",
254                        tool.name,
255                        resolved_path.display()
256                    ));
257                    (false, None)
258                }
259            };
260
261            tools.push(ToolHealth {
262                name: tool.name.clone(),
263                entry: tool.entry.clone(),
264                component: tool.component.clone(),
265                resolved_path: resolved_path.display().to_string(),
266                exists,
267                size_bytes: size,
268                timeout_ms: tool.timeout_ms,
269                max_retries: tool.max_retries.unwrap_or(0),
270                retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
271            });
272        }
273
274        Self {
275            tool_map_path: config_path.display().to_string(),
276            tool_count: tools.len(),
277            tools,
278            warnings,
279            generator: GeneratorStatus::detect(),
280        }
281    }
282}
283
284fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
285    let raw = PathBuf::from(component);
286    if raw.is_absolute() {
287        raw
288    } else {
289        base_dir.join(raw)
290    }
291}
292
293fn print_report(report: &ToolMapReport) {
294    println!("MCP tool map: {}", report.tool_map_path);
295    println!("Tools: {}", report.tool_count);
296    for tool in &report.tools {
297        println!("- {}", tool.name);
298        println!("  entry: {}", tool.entry);
299        println!(
300            "  component: {}{}",
301            tool.resolved_path,
302            if tool.exists { "" } else { " (missing)" }
303        );
304        println!(
305            "  timeout: {}",
306            tool.timeout_ms
307                .map(|ms| format!("{ms} ms"))
308                .unwrap_or_else(|| "not set".into())
309        );
310        println!(
311            "  retries: {} (backoff {} ms)",
312            tool.max_retries, tool.retry_backoff_ms
313        );
314        if let Some(size) = tool.size_bytes {
315            println!("  size: {size} bytes");
316        }
317    }
318    if !report.warnings.is_empty() {
319        println!("\nWarnings:");
320        for warning in &report.warnings {
321            println!("  - {warning}");
322        }
323    }
324    print_generator_status(&report.generator);
325}
326
327fn print_generator_status(generator: &GeneratorStatus) {
328    let locale = crate::i18n::select_locale(None);
329    println!(
330        "{}",
331        crate::i18n::t(&locale, "cli.command.mcp.doctor.generator.header")
332    );
333    match (&generator.resolved_path, &generator.version) {
334        (Some(path), Some(version)) => println!(
335            "  {}",
336            crate::i18n::tf(
337                &locale,
338                "cli.command.mcp.doctor.generator.found",
339                &[("path", path.clone()), ("version", version.clone())],
340            )
341        ),
342        (Some(path), None) => println!(
343            "  {}",
344            crate::i18n::tf(
345                &locale,
346                "cli.command.mcp.doctor.generator.found",
347                &[("path", path.clone()), ("version", "unknown".to_string())],
348            )
349        ),
350        _ => println!(
351            "  {}",
352            crate::i18n::t(&locale, "cli.command.mcp.doctor.generator.missing")
353        ),
354    }
355    let toolchain_ready = generator.cargo_available && generator.wasm_target_installed;
356    if toolchain_ready {
357        println!(
358            "  {}",
359            crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.ready")
360        );
361    }
362    if !toolchain_ready && !generator.cargo_available {
363        println!(
364            "  {}",
365            crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.cargo_missing")
366        );
367    }
368    if !toolchain_ready && !generator.wasm_target_installed {
369        println!(
370            "  {}",
371            crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.wasm_missing")
372        );
373    }
374}
375
376#[cfg(test)]
377mod generator_tests {
378    use super::*;
379
380    #[test]
381    fn absent_generator_status_has_no_path_or_version() {
382        let status = GeneratorStatus::absent();
383        assert_eq!(status.binary_name, "greentic-mcp-gen");
384        assert!(status.resolved_path.is_none());
385        assert!(status.version.is_none());
386    }
387
388    #[test]
389    fn generator_status_serializes_expected_fields() {
390        let json = serde_json::to_value(GeneratorStatus::absent()).unwrap();
391        assert!(json.get("binary_name").is_some());
392        assert!(json.get("resolved_path").is_some()); // present as null
393        assert!(json.get("version").is_some());
394        assert!(json.get("cargo_available").is_some());
395        assert!(json.get("wasm_target_installed").is_some());
396    }
397
398    #[test]
399    fn doctor_without_toolmap_returns_ok() {
400        // No toolmap: doctor must succeed, printing only the generator/toolchain
401        // section (best-effort detect never hard-errors).
402        assert!(super::doctor(None, true).is_ok());
403        assert!(super::doctor(None, false).is_ok());
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{
410        ToolMap, ToolMapConfig, ToolMapReport, ToolRef, is_json, load_tool_map_config,
411        locate_toolmap,
412    };
413    use tempfile::tempdir;
414
415    fn sample_tool(name: &str) -> ToolRef {
416        ToolRef {
417            name: name.to_string(),
418            component: "component.wasm".to_string(),
419            entry: "run".to_string(),
420            timeout_ms: Some(500),
421            max_retries: Some(2),
422            retry_backoff_ms: Some(100),
423        }
424    }
425
426    #[test]
427    fn json_detection_prefers_extension_and_content() {
428        assert!(is_json(std::path::Path::new("toolmap.json"), "tools: []"));
429        assert!(!is_json(
430            std::path::Path::new("toolmap.yaml"),
431            "{\"tools\":[]}"
432        ));
433        assert!(is_json(
434            std::path::Path::new("toolmap"),
435            "  {\"tools\": []}"
436        ));
437    }
438
439    #[test]
440    fn duplicate_tool_names_are_rejected() {
441        let config = ToolMapConfig {
442            tools: vec![sample_tool("demo"), sample_tool("demo")],
443        };
444
445        let err = ToolMap::from_config(&config).unwrap_err();
446        assert!(err.to_string().contains("duplicate tool names"));
447    }
448
449    #[test]
450    fn load_tool_map_config_supports_json_and_yaml() {
451        let dir = tempdir().unwrap();
452        let json_path = dir.path().join("toolmap.json");
453        let yaml_path = dir.path().join("toolmap.yaml");
454        std::fs::write(
455            &json_path,
456            r#"{"tools":[{"name":"demo","component":"component.wasm","entry":"run"}]}"#,
457        )
458        .unwrap();
459        std::fs::write(
460            &yaml_path,
461            "tools:\n  - name: demo\n    component: component.wasm\n    entry: run\n",
462        )
463        .unwrap();
464
465        assert_eq!(load_tool_map_config(&json_path).unwrap().tools.len(), 1);
466        assert_eq!(load_tool_map_config(&yaml_path).unwrap().tools.len(), 1);
467    }
468
469    #[test]
470    fn locate_toolmap_finds_directory_default_files() {
471        let dir = tempdir().unwrap();
472        let provider_dir = dir.path().join("demo");
473        std::fs::create_dir_all(&provider_dir).unwrap();
474        let toolmap = provider_dir.join("toolmap.yaml");
475        std::fs::write(&toolmap, "tools: []\n").unwrap();
476
477        let located = locate_toolmap(dir.path(), "demo").unwrap();
478        assert_eq!(located, toolmap.canonicalize().unwrap());
479    }
480
481    #[test]
482    fn report_marks_missing_components_as_warnings() {
483        let dir = tempdir().unwrap();
484        let config_path = dir.path().join("toolmap.yaml");
485        std::fs::write(&config_path, "tools: []\n").unwrap();
486        let map = ToolMap::from_config(&ToolMapConfig {
487            tools: vec![sample_tool("demo")],
488        })
489        .unwrap();
490
491        let report = ToolMapReport::from_map(&config_path, &map);
492        assert_eq!(report.tool_count, 1);
493        assert_eq!(report.tools[0].name, "demo");
494        assert!(!report.tools[0].exists);
495        assert_eq!(report.warnings.len(), 1);
496    }
497}