greentic_dev/
mcp_cmd.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use greentic_mcp::{ToolMap, load_tool_map_config};
6use serde::Serialize;
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
33fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
34    let initial = PathBuf::from(target);
35    if initial.is_absolute() {
36        bail!("tool map path must be relative to the workspace root");
37    }
38
39    let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
40
41    for candidate in candidates {
42        let joined = workspace_root.join(&candidate);
43        if joined.is_file() {
44            return normalize_under_root(workspace_root, &candidate);
45        }
46        if joined.is_dir() {
47            let safe_dir = normalize_under_root(workspace_root, &candidate)?;
48            for name in [
49                "toolmap.yaml",
50                "toolmap.yml",
51                "toolmap.json",
52                "mcp.yaml",
53                "mcp.json",
54            ] {
55                let file = safe_dir.join(name);
56                if file.is_file() {
57                    return Ok(file);
58                }
59            }
60        }
61    }
62
63    bail!("unable to find MCP tool map at `{target}`")
64}
65
66#[derive(Debug, Serialize)]
67struct ToolMapReport {
68    tool_map_path: String,
69    tool_count: usize,
70    tools: Vec<ToolHealth>,
71    warnings: Vec<String>,
72}
73
74#[derive(Debug, Serialize)]
75struct ToolHealth {
76    name: String,
77    entry: String,
78    component: String,
79    resolved_path: String,
80    exists: bool,
81    size_bytes: Option<u64>,
82    timeout_ms: Option<u64>,
83    max_retries: u32,
84    retry_backoff_ms: u64,
85}
86
87impl ToolMapReport {
88    fn from_map(config_path: &Path, map: &ToolMap) -> Self {
89        let base_dir = config_path
90            .parent()
91            .map(|parent| parent.to_path_buf())
92            .unwrap_or_else(|| PathBuf::from("."));
93
94        let mut warnings = Vec::new();
95        let mut tools = Vec::new();
96
97        for (_, tool) in map.iter() {
98            let resolved_path = resolve_component_path(&base_dir, &tool.component);
99            let (exists, size) = match fs::metadata(&resolved_path) {
100                Ok(meta) if meta.is_file() => (true, Some(meta.len())),
101                _ => {
102                    warnings.push(format!(
103                        "tool `{}` component missing at {}",
104                        tool.name,
105                        resolved_path.display()
106                    ));
107                    (false, None)
108                }
109            };
110
111            tools.push(ToolHealth {
112                name: tool.name.clone(),
113                entry: tool.entry.clone(),
114                component: tool.component.clone(),
115                resolved_path: resolved_path.display().to_string(),
116                exists,
117                size_bytes: size,
118                timeout_ms: tool.timeout_ms,
119                max_retries: tool.max_retries.unwrap_or(0),
120                retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
121            });
122        }
123
124        Self {
125            tool_map_path: config_path.display().to_string(),
126            tool_count: tools.len(),
127            tools,
128            warnings,
129        }
130    }
131}
132
133fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
134    let raw = PathBuf::from(component);
135    if raw.is_absolute() {
136        raw
137    } else {
138        base_dir.join(raw)
139    }
140}
141
142fn print_report(report: &ToolMapReport) {
143    println!("MCP tool map: {}", report.tool_map_path);
144    println!("Tools: {}", report.tool_count);
145    for tool in &report.tools {
146        println!("- {}", tool.name);
147        println!("  entry: {}", tool.entry);
148        println!(
149            "  component: {}{}",
150            tool.resolved_path,
151            if tool.exists { "" } else { " (missing)" }
152        );
153        println!(
154            "  timeout: {}",
155            tool.timeout_ms
156                .map(|ms| format!("{ms} ms"))
157                .unwrap_or_else(|| "not set".into())
158        );
159        println!(
160            "  retries: {} (backoff {} ms)",
161            tool.max_retries, tool.retry_backoff_ms
162        );
163        if let Some(size) = tool.size_bytes {
164            println!("  size: {size} bytes");
165        }
166    }
167    if !report.warnings.is_empty() {
168        println!("\nWarnings:");
169        for warning in &report.warnings {
170            println!("  - {warning}");
171        }
172    }
173}