Skip to main content

cargo_ravenrs/
mcp.rs

1use crate::{CLI_VERSION, VERSION, read};
2use serde_json::{Value, json};
3use std::io::{BufRead, BufReader, Write};
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::sync::mpsc;
7use std::time::Duration;
8use toml_edit::DocumentMut;
9
10/// Probe a real MCP command with a bounded initialize + tools/list exchange.
11pub(crate) fn probe(command: &str, args: &[String], root: &Path) -> Result<(), String> {
12    let mut child = Command::new(command)
13        .args(args)
14        .current_dir(root)
15        .stdin(Stdio::piped())
16        .stdout(Stdio::piped())
17        .stderr(Stdio::inherit())
18        .spawn()
19        .map_err(|e| format!("MCP {command}: {e}"))?;
20    let result = (|| {
21        let mut stdin = child.stdin.take().ok_or("MCP stdin ausente")?;
22        let stdout = child.stdout.take().ok_or("MCP stdout ausente")?;
23        let (tx, rx) = mpsc::channel();
24        std::thread::spawn(move || {
25            for line in BufReader::new(stdout).lines() {
26                if tx.send(line).is_err() {
27                    break;
28                }
29            }
30        });
31        let send = |stdin: &mut std::process::ChildStdin, value: Value| -> Result<(), String> {
32            writeln!(stdin, "{value}").and_then(|()| stdin.flush()).map_err(|e| e.to_string())
33        };
34        let receive = |id: u64| -> Result<Value, String> {
35            let deadline = std::time::Instant::now() + Duration::from_secs(20);
36            loop {
37                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
38                let line = rx
39                    .recv_timeout(remaining)
40                    .map_err(|e| format!("MCP não respondeu em 20s: {e}"))?
41                    .map_err(|e| e.to_string())?;
42                let reply: Value = serde_json::from_str(&line).map_err(|e| format!("MCP: {e}"))?;
43                if reply["id"] == id {
44                    if !reply["error"].is_null() {
45                        return Err(format!("MCP: {}", reply["error"]));
46                    }
47                    return Ok(reply["result"].clone());
48                }
49            }
50        };
51        send(
52            &mut stdin,
53            json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"cargo-ravenrs-doctor","version":CLI_VERSION}}}),
54        )?;
55        let initialized = receive(1)?;
56        if initialized["serverInfo"]["version"] != VERSION {
57            return Err(format!(
58                "MCP versão {}, framework esperado {VERSION}",
59                initialized["serverInfo"]["version"]
60            ));
61        }
62        send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}))?;
63        send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}))?;
64        let tools = receive(2)?;
65        if !tools["tools"].as_array().is_some_and(|t| t.iter().any(|t| t["name"] == "component_api")) {
66            return Err("MCP não retornou component_api".into());
67        }
68        Ok(())
69    })();
70    let _ = child.kill();
71    let _ = child.wait();
72    result
73}
74
75/// Verify the current project version, managed files and every registered MCP command.
76pub fn doctor(root: &Path) -> Result<(), String> {
77    let manifest: DocumentMut =
78        read(&root.join("Cargo.toml"))?.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
79    let mut versions = Vec::new();
80    collect_versions(manifest.as_item(), &mut versions);
81    if versions.is_empty() {
82        return Err("Nenhuma dependência RavenRS com versão explícita; doctor requer um Cargo.toml de projeto independente.".into());
83    }
84    for (name, version) in versions {
85        if version.trim_start_matches(['=', '^', '~']) != VERSION {
86            return Err(format!(
87                "{name} usa {version}, mas cargo-ravenrs usa {VERSION}. Instale a tag correspondente."
88            ));
89        }
90    }
91    println!("✓ CLI: {CLI_VERSION}; crates e MCP: {VERSION}");
92    if root.join(".ravenrs-project.toml").is_file() && !root.join(".ravenrs-ai.toml").is_file() {
93        let state: DocumentMut =
94            read(&root.join(".ravenrs-project.toml"))?.parse().map_err(|e| format!("Projeto: {e}"))?;
95        if state["ai"].as_bool() == Some(false) {
96            probe("cargo", &["ravenrs".into(), "mcp".into()], root)?;
97            println!("✓ MCP; integração com assistentes desativada por --no-ai.");
98            return Ok(());
99        }
100    }
101    let state_path = root.join(".ravenrs-ai.toml");
102    if !state_path.is_file() {
103        return Err("Arquivos de IA ausentes. Execute cargo ravenrs ai.".into());
104    }
105    let state: DocumentMut = read(&state_path)?.parse().map_err(|e| format!("Estado de IA: {e}"))?;
106    if state["version"].as_str() != Some(VERSION) {
107        return Err("Arquivos de IA desatualizados. Execute cargo ravenrs ai.".into());
108    }
109    let plugin = state["plugin"].as_bool().unwrap_or(false);
110    let expected = crate::ai::ai_files(root, plugin)?;
111    for (path, contents) in expected {
112        if read(&root.join(&path))? != contents {
113            return Err(format!("{path} desatualizado. Execute cargo ravenrs ai."));
114        }
115    }
116    println!("✓ Arquivos de IA e skill");
117    for (path, key) in
118        [(".mcp.json", "mcpServers"), (".cursor/mcp.json", "mcpServers"), (".vscode/mcp.json", "servers")]
119    {
120        let config: Value =
121            serde_json::from_str(&read(&root.join(path))?).map_err(|e| format!("{path}: {e}"))?;
122        let server = &config[key]["ravenrs"];
123        let command = server["command"].as_str().ok_or("MCP command ausente")?;
124        let args: Vec<String> = server["args"]
125            .as_array()
126            .ok_or("MCP args ausentes")?
127            .iter()
128            .map(|v| v.as_str().map(str::to_owned).ok_or("MCP argumento inválido".to_owned()))
129            .collect::<Result<_, _>>()?;
130        probe(command, &args, root)?;
131        println!("✓ MCP {path}");
132    }
133    let codex: DocumentMut =
134        read(&root.join(".codex/config.toml"))?.parse().map_err(|e| format!("Codex: {e}"))?;
135    let server = &codex["mcp_servers"]["ravenrs"];
136    let args = server["args"]
137        .as_array()
138        .ok_or("Codex args ausentes")?
139        .iter()
140        .map(|v| v.as_str().map(str::to_owned).ok_or("Codex argumento inválido".into()))
141        .collect::<Result<Vec<String>, String>>()?;
142    probe(server["command"].as_str().ok_or("Codex command ausente")?, &args, root)?;
143    println!("✓ MCP .codex/config.toml\nDoctor: tudo certo.");
144    Ok(())
145}
146
147fn collect_versions(item: &toml_edit::Item, versions: &mut Vec<(String, String)>) {
148    if let Some(table) = item.as_table_like() {
149        for (key, item) in table.iter() {
150            if key.starts_with("ravenrs") {
151                if let Some(version) =
152                    item.as_str().or_else(|| item.get("version").and_then(toml_edit::Item::as_str))
153                {
154                    versions.push((key.into(), version.into()));
155                }
156            } else {
157                collect_versions(item, versions);
158            }
159        }
160    }
161}