464 lines
17 KiB
Rust
464 lines
17 KiB
Rust
#[cfg(not(windows))]
|
|
use std::env;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const MCP_PACKAGE_NAME: &str = "@dbx-app/mcp-server";
|
|
const MCP_LATEST_URL: &str = "https://registry.npmjs.org/@dbx-app%2fmcp-server/latest";
|
|
const MCP_INSTALL_COMMAND: &str = "npm install -g @dbx-app/mcp-server@latest --registry=https://registry.npmjs.org";
|
|
const SHELL_COMMAND_MARKER: &str = "__DBX_MCP_COMMAND_OUTPUT_START__";
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct McpServerStatus {
|
|
pub installed: bool,
|
|
pub npm_available: bool,
|
|
pub node_path: Option<String>,
|
|
pub node_version: Option<String>,
|
|
pub current_version: Option<String>,
|
|
pub latest_version: Option<String>,
|
|
pub update_available: bool,
|
|
pub bin_path: Option<String>,
|
|
pub script_path: Option<String>,
|
|
pub install_command: String,
|
|
pub update_command: String,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NpmLatestPackage {
|
|
version: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
|
|
let local_status = tauri::async_runtime::spawn_blocking(|| {
|
|
let npm_available = command_success("npm", &["--version"]);
|
|
let node_path = locate_command("node");
|
|
let node_command = node_path.as_deref().unwrap_or("node");
|
|
let node_version = command_stdout(node_command, &["--version"]).ok().and_then(first_non_empty_line);
|
|
let current_version = if npm_available { installed_mcp_version() } else { None };
|
|
let bin_path = locate_mcp_bin();
|
|
let script_path = if npm_available { installed_mcp_bin_script() } else { None };
|
|
(npm_available, node_path, node_version, current_version, bin_path, script_path)
|
|
});
|
|
let latest_version = fetch_latest_mcp_version();
|
|
let (local_status, latest_version) = tokio::join!(local_status, latest_version);
|
|
let (npm_available, node_path, node_version, current_version, bin_path, script_path) =
|
|
local_status.map_err(|err| err.to_string())?;
|
|
let latest_version = latest_version.ok();
|
|
let update_available = current_version
|
|
.as_deref()
|
|
.zip(latest_version.as_deref())
|
|
.is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current));
|
|
let error = if npm_available { None } else { Some("npm is not available in PATH.".to_string()) };
|
|
|
|
Ok(McpServerStatus {
|
|
installed: current_version.is_some() || bin_path.is_some() || script_path.is_some(),
|
|
npm_available,
|
|
node_path,
|
|
node_version,
|
|
current_version,
|
|
latest_version,
|
|
update_available,
|
|
bin_path,
|
|
script_path,
|
|
install_command: MCP_INSTALL_COMMAND.to_string(),
|
|
update_command: MCP_INSTALL_COMMAND.to_string(),
|
|
error,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn install_mcp_server() -> Result<String, String> {
|
|
tauri::async_runtime::spawn_blocking(|| {
|
|
let output = command_output(
|
|
"npm",
|
|
&["install", "-g", "@dbx-app/mcp-server@latest", "--registry=https://registry.npmjs.org"],
|
|
)?;
|
|
|
|
if !output.success {
|
|
let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout };
|
|
return Err(format!("Installation failed: {}", error_msg));
|
|
}
|
|
|
|
let version = installed_mcp_version().unwrap_or_else(|| "unknown".to_string());
|
|
Ok(format!("Successfully installed @dbx-app/mcp-server@{}", version))
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
async fn fetch_latest_mcp_version() -> Result<String, String> {
|
|
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker");
|
|
let proxy_url =
|
|
tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.map_err(|e| e.to_string())?;
|
|
if let Some(proxy_url) = proxy_url {
|
|
let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?;
|
|
builder = builder.proxy(proxy);
|
|
}
|
|
let client = builder.build().map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
|
let package = client
|
|
.get(MCP_LATEST_URL)
|
|
.send()
|
|
.await
|
|
.and_then(|r| r.error_for_status())
|
|
.map_err(|e| format!("Failed to check MCP Server updates: {e}"))?
|
|
.json::<NpmLatestPackage>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse MCP Server update response: {e}"))?;
|
|
Ok(package.version)
|
|
}
|
|
|
|
fn installed_mcp_version() -> Option<String> {
|
|
let root = command_stdout("npm", &["root", "-g"]).ok()?;
|
|
let pkg_json_path = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("package.json");
|
|
let content = std::fs::read_to_string(pkg_json_path).ok()?;
|
|
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
|
value.get("version")?.as_str().map(ToOwned::to_owned)
|
|
}
|
|
|
|
pub(crate) fn resolve_mcp_server_command() -> Option<(String, Vec<String>)> {
|
|
#[cfg(windows)]
|
|
{
|
|
// Node.js 18.20.2+ rejects direct spawn of .cmd/.bat files without a shell
|
|
// on Windows. Prefer the package's real JS entry point for Codex CLI.
|
|
return installed_mcp_bin_script()
|
|
.map(|script| (locate_command("node").unwrap_or_else(|| "node".to_string()), vec![script]))
|
|
.or_else(|| locate_mcp_bin().map(|path| (path, Vec::new())));
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
locate_mcp_bin()
|
|
.map(|path| (path, Vec::new()))
|
|
.or_else(|| installed_mcp_bin_script().map(|script| ("node".to_string(), vec![script])))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn locate_command(command: &str) -> Option<String> {
|
|
#[cfg(windows)]
|
|
{
|
|
return locate_windows_command(command);
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
command_stdout("which", &[command]).ok().and_then(first_non_empty_line)
|
|
}
|
|
}
|
|
|
|
fn locate_mcp_bin() -> Option<String> {
|
|
locate_command("dbx-mcp-server")
|
|
}
|
|
|
|
fn installed_mcp_bin_script() -> Option<String> {
|
|
let root = command_stdout("npm", &["root", "-g"]).ok()?;
|
|
let script = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("dist").join("index.js");
|
|
script.is_file().then(|| script.to_string_lossy().to_string())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn locate_windows_command(command: &str) -> Option<String> {
|
|
command_stdout("where", &[command])
|
|
.ok()
|
|
.and_then(first_windows_command_path)
|
|
.or_else(|| {
|
|
let script =
|
|
format!("(Get-Command -All {} -ErrorAction SilentlyContinue).Source", windows_shell_quote(command));
|
|
command_stdout("powershell.exe", &["-NoProfile", "-Command", &script])
|
|
.ok()
|
|
.and_then(first_windows_command_path)
|
|
})
|
|
.or_else(|| {
|
|
windows_command_candidates(command)
|
|
.into_iter()
|
|
.find(|candidate| is_windows_launchable_command(candidate) && Path::new(candidate).is_file())
|
|
})
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn first_windows_command_path(value: String) -> Option<String> {
|
|
let paths = value.lines().map(str::trim).filter(|line| !line.is_empty()).collect::<Vec<_>>();
|
|
paths
|
|
.into_iter()
|
|
.find(|path| is_windows_launchable_command(path) && Path::new(path).is_file())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_windows_launchable_command(path: &str) -> bool {
|
|
matches!(
|
|
Path::new(path).extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(),
|
|
Some("exe" | "cmd" | "bat" | "com")
|
|
)
|
|
}
|
|
|
|
fn command_success(command: &str, args: &[&str]) -> bool {
|
|
command_output(command, args).is_ok_and(|output| output.success)
|
|
}
|
|
|
|
fn command_stdout(command: &str, args: &[&str]) -> Result<String, String> {
|
|
let output = command_output(command, args)?;
|
|
if !output.success {
|
|
return Err(output.stderr.trim().to_string());
|
|
}
|
|
Ok(output.stdout.trim().to_string())
|
|
}
|
|
|
|
fn first_non_empty_line(value: String) -> Option<String> {
|
|
value.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CommandOutput {
|
|
success: bool,
|
|
stdout: String,
|
|
stderr: String,
|
|
}
|
|
|
|
fn command_output(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
|
|
let direct = run_command(command, args);
|
|
if direct.as_ref().is_ok_and(|output| output.success) {
|
|
return direct;
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
return run_windows_command_candidates(command, args).or(direct);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
run_command_through_user_shell(command, args).or(direct)
|
|
}
|
|
}
|
|
|
|
fn run_command(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
|
|
let mut cmd = dbx_core::process::new_std_command(command);
|
|
cmd.args(args);
|
|
let output = cmd.output().map_err(|e| e.to_string())?;
|
|
Ok(CommandOutput {
|
|
success: output.status.success(),
|
|
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
|
})
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn run_windows_command_candidates(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
|
|
for candidate in windows_command_candidates(command) {
|
|
let output = run_command(&candidate, args);
|
|
if output.as_ref().is_ok_and(|output| output.success) {
|
|
return output;
|
|
}
|
|
}
|
|
run_command_through_user_shell(command, args)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_command_candidates(command: &str) -> Vec<String> {
|
|
if Path::new(command).extension().is_some() {
|
|
return Vec::new();
|
|
}
|
|
let names = ["cmd", "exe", "bat", "com", "ps1"].iter().map(|extension| format!("{command}.{extension}"));
|
|
names
|
|
.clone()
|
|
.chain(
|
|
windows_common_command_dirs()
|
|
.into_iter()
|
|
.flat_map(|dir| names.clone().map(move |name| dir.join(name).to_string_lossy().to_string())),
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_common_command_dirs() -> Vec<std::path::PathBuf> {
|
|
let mut dirs = Vec::new();
|
|
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
|
|
dirs.push(nvm_symlink.into());
|
|
}
|
|
if let Ok(app_data) = std::env::var("APPDATA") {
|
|
dirs.push(std::path::PathBuf::from(app_data).join("npm"));
|
|
}
|
|
if let Ok(program_files) = std::env::var("ProgramFiles") {
|
|
dirs.push(std::path::PathBuf::from(program_files).join("nodejs"));
|
|
}
|
|
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
|
|
dirs.push(std::path::PathBuf::from(program_files_x86).join("nodejs"));
|
|
}
|
|
dirs.push(std::path::PathBuf::from(r"C:\nvm4w\nodejs"));
|
|
dirs
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
|
|
let script = windows_command_script(command, args);
|
|
let mut output = run_command("powershell.exe", &["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script])?;
|
|
output.stdout = stdout_after_shell_marker(&output.stdout);
|
|
Ok(output)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_command_script(command: &str, args: &[&str]) -> String {
|
|
let mut words = Vec::with_capacity(args.len() + 1);
|
|
words.push(windows_shell_quote(command));
|
|
words.extend(args.iter().map(|arg| windows_shell_quote(arg)));
|
|
format!("Write-Output {}; & {}", windows_shell_quote(SHELL_COMMAND_MARKER), words.join(" "))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_shell_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
|
|
let script = shell_command_script(command, args);
|
|
let (shell, shell_args) = user_shell_invocation_args(&script);
|
|
let shell_arg_refs = shell_args.iter().map(String::as_str).collect::<Vec<_>>();
|
|
let mut output = run_command(&shell, &shell_arg_refs)?;
|
|
output.stdout = stdout_after_shell_marker(&output.stdout);
|
|
Ok(output)
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn user_shell_invocation_args(script: &str) -> (String, Vec<String>) {
|
|
let shell = env::var("SHELL").ok().filter(|value| !value.trim().is_empty()).unwrap_or_else(default_user_shell);
|
|
let shell_name = Path::new(&shell).file_name().and_then(|value| value.to_str()).unwrap_or_default();
|
|
let args = match shell_name {
|
|
"fish" => vec!["-l".to_string(), "-i".to_string(), "-c".to_string(), script.to_string()],
|
|
"bash" => vec![
|
|
"--noprofile".to_string(),
|
|
"--norc".to_string(),
|
|
"-i".to_string(),
|
|
"-c".to_string(),
|
|
bash_login_script(script),
|
|
],
|
|
"sh" | "dash" => vec!["-ic".to_string(), script.to_string()],
|
|
"zsh" => vec!["-ilc".to_string(), script.to_string()],
|
|
_ => vec!["-lc".to_string(), script.to_string()],
|
|
};
|
|
(shell, args)
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn bash_login_script(script: &str) -> String {
|
|
format!(
|
|
"for dbx_profile in ~/.bash_profile ~/.bash_login ~/.profile ~/.bashrc; do \
|
|
[ -r \"$dbx_profile\" ] && . \"$dbx_profile\"; \
|
|
done; unset dbx_profile; {script}"
|
|
)
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn default_user_shell() -> String {
|
|
if Path::new("/bin/zsh").exists() {
|
|
"/bin/zsh".to_string()
|
|
} else {
|
|
"/bin/sh".to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn shell_command_script(command: &str, args: &[&str]) -> String {
|
|
let mut words = Vec::with_capacity(args.len() + 1);
|
|
words.push(shell_quote(command));
|
|
words.extend(args.iter().map(|arg| shell_quote(arg)));
|
|
format!("printf '%s\\n' {}; {}", shell_quote(SHELL_COMMAND_MARKER), words.join(" "))
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn shell_quote(value: &str) -> String {
|
|
if value.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
|
}
|
|
|
|
fn stdout_after_shell_marker(stdout: &str) -> String {
|
|
stdout
|
|
.find(SHELL_COMMAND_MARKER)
|
|
.map(|index| stdout[index + SHELL_COMMAND_MARKER.len()..].trim_start_matches(['\r', '\n']).to_string())
|
|
.unwrap_or_else(|| stdout.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[cfg(not(windows))]
|
|
use super::bash_login_script;
|
|
#[cfg(windows)]
|
|
use super::first_windows_command_path;
|
|
#[cfg(not(windows))]
|
|
use super::{shell_command_script, shell_quote};
|
|
use super::{stdout_after_shell_marker, SHELL_COMMAND_MARKER};
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn shell_quote_handles_empty_and_single_quotes() {
|
|
assert_eq!(shell_quote(""), "''");
|
|
assert_eq!(shell_quote("npm"), "'npm'");
|
|
assert_eq!(shell_quote("can't"), "'can'\"'\"'t'");
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn shell_command_script_marks_command_output_after_startup_noise() {
|
|
let script = shell_command_script("npm", &["list", "-g", "@dbx-app/mcp-server", "--json"]);
|
|
|
|
assert!(script.contains(SHELL_COMMAND_MARKER));
|
|
assert!(script.contains("'@dbx-app/mcp-server'"));
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn bash_login_script_sources_profile_and_rc_files() {
|
|
let script = bash_login_script("node --version");
|
|
|
|
assert!(script.contains("~/.bash_profile"));
|
|
assert!(script.contains("~/.bashrc"));
|
|
assert!(script.ends_with("node --version"));
|
|
}
|
|
|
|
#[test]
|
|
fn stdout_after_shell_marker_ignores_shell_startup_output() {
|
|
let stdout = format!("loading profile\n{SHELL_COMMAND_MARKER}\n22.19.0\n");
|
|
|
|
assert_eq!(stdout_after_shell_marker(&stdout), "22.19.0\n");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_command_lookup_prefers_cmd_over_extensionless_shim() {
|
|
let dir = std::env::temp_dir().join(format!("dbx-mcp-command-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let extensionless = dir.join("codex");
|
|
let cmd = dir.join("codex.cmd");
|
|
std::fs::write(&extensionless, "#!/bin/sh\n").unwrap();
|
|
std::fs::write(&cmd, "@echo off\n").unwrap();
|
|
|
|
let output = format!("{}\n{}\n", extensionless.display(), cmd.display());
|
|
let resolved = first_windows_command_path(output).unwrap();
|
|
|
|
assert_eq!(resolved, cmd.to_string_lossy().as_ref());
|
|
let _ = std::fs::remove_file(extensionless);
|
|
let _ = std::fs::remove_file(cmd);
|
|
let _ = std::fs::remove_dir(dir);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_command_lookup_rejects_extensionless_only_shim() {
|
|
let dir = std::env::temp_dir().join(format!("dbx-mcp-command-extensionless-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let extensionless = dir.join("codex");
|
|
std::fs::write(&extensionless, "#!/bin/sh\n").unwrap();
|
|
|
|
let resolved = first_windows_command_path(extensionless.display().to_string());
|
|
|
|
assert!(resolved.is_none());
|
|
let _ = std::fs::remove_file(extensionless);
|
|
let _ = std::fs::remove_dir(dir);
|
|
}
|
|
}
|