冰朔 8739805f99
Some checks failed
Auto-update PR branches / Update open PR branches (push) Has been cancelled
CI / Frontend Static Quality Checks (push) Has been cancelled
CI / Frontend Tests & Coverage (push) Has been cancelled
CI / Rust Tests & Quality Checks (push) Has been cancelled
CI / Linux build verification (push) Has been cancelled
Release (Alpha) / Compute alpha version (push) Has been cancelled
Release (Alpha) / Build release artifacts (push) Has been cancelled
Release (Alpha) / GitHub Release (alpha) (push) Has been cancelled
Release (Alpha) / Update docs and release pages (push) Has been cancelled
Deploy docs / Build VitePress site (push) Has been cancelled
Deploy docs / Deploy to GitHub Pages (push) Has been cancelled
光湖开源源码快照 · Tolaria AGPL 分叉基线 · 独立更新链
2026-07-05 17:45:16 +08:00

1171 lines
41 KiB
Rust

use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Child;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod extraction;
mod opencode;
mod paths;
mod runtime;
mod subprocess;
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) use extraction::extract_mcp_server_to_stable_dir;
pub(crate) use runtime::{find_mcp_runtime, find_node};
const MCP_SERVER_NAME: &str = "tolaria";
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
/// Status of the MCP server installation.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing for the active vault.
NotInstalled,
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, prefers `CARGO_MANIFEST_DIR` and falls back to runtime checkout ancestors.
/// In release mode, uses launcher roots plus bundle paths derived from the executable.
pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = build_time_dev_mcp_server_dir();
let resource_roots = paths::runtime_resource_roots();
let candidates = mcp_server_dir_candidates(&dev_path, &resource_roots);
if let Some(path) = candidates
.iter()
.find(|path| mcp_server_dir_has_files(path))
{
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
}
let searched = candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"mcp-server not found. Searched these paths: {searched}"
))
}
fn mcp_server_dir_for_registration() -> Result<PathBuf, String> {
#[cfg(all(desktop, target_os = "linux"))]
if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() {
return Ok(stable_dir);
}
mcp_server_dir()
}
pub(crate) fn mcp_server_index_js_path_string() -> Result<String, String> {
Ok(index_js_client_path(&mcp_server_dir()?))
}
fn index_js_client_path(server_dir: &Path) -> String {
paths::client_script_path(&server_dir.join("index.js"))
}
fn build_time_dev_mcp_server_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server")
}
fn mcp_server_dir_candidates(dev_path: &Path, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
let current_dir = std::env::current_dir().ok();
mcp_server_dir_candidates_for(dev_path, resource_roots, current_dir.as_deref())
}
fn mcp_server_dir_candidates_for(
dev_path: &Path,
resource_roots: &[PathBuf],
current_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
push_unique_path(&mut candidates, dev_path.to_path_buf());
for root in resource_roots {
push_resource_root_candidates(&mut candidates, root);
}
for root in runtime_development_roots(current_dir) {
push_development_root_candidates(&mut candidates, &root);
}
push_linux_package_candidates(&mut candidates, Path::new("/usr/local"));
push_linux_package_candidates(&mut candidates, Path::new("/usr"));
candidates
}
fn push_resource_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
push_unique_path(candidates, root.join("mcp-server"));
push_unique_path(candidates, root.join("resources").join("mcp-server"));
push_linux_package_candidates(candidates, root);
}
fn push_development_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
push_unique_path(candidates, root.join("mcp-server"));
push_unique_path(candidates, root.join("resources").join("mcp-server"));
push_unique_path(
candidates,
root.join("src-tauri").join("resources").join("mcp-server"),
);
}
fn runtime_development_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(current_dir) = current_dir {
push_ancestor_paths(&mut roots, current_dir);
}
roots
}
fn push_ancestor_paths(paths: &mut Vec<PathBuf>, start: &Path) {
for ancestor in start.ancestors().filter(|path| path.parent().is_some()) {
push_unique_path(paths, ancestor.to_path_buf());
}
}
fn push_linux_package_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
for path in linux_package_mcp_server_dirs(root) {
push_unique_path(candidates, path);
}
}
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !path.as_os_str().is_empty() && !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn linux_package_mcp_server_dirs(root: &Path) -> Vec<PathBuf> {
vec![
root.join("Tolaria").join("mcp-server"),
root.join("Tolaria").join("resources").join("mcp-server"),
root.join("lib").join("Tolaria").join("mcp-server"),
root.join("lib")
.join("Tolaria")
.join("resources")
.join("mcp-server"),
root.join("lib").join("tolaria").join("mcp-server"),
root.join("lib")
.join("tolaria")
.join("resources")
.join("mcp-server"),
]
}
fn mcp_server_dir_has_files(path: &Path) -> bool {
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
spawn_ws_bridge_with_paths(vault_path, &[])
}
/// Spawn the WebSocket bridge with every active vault exposed to MCP tools.
pub fn spawn_ws_bridge_with_paths(
vault_path: impl AsRef<Path>,
vault_paths: &[PathBuf],
) -> Result<Child, String> {
let runtime = find_mcp_runtime()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let vault_path = vault_path.as_ref();
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
let mut command = subprocess::command(&runtime.binary);
let child = command
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("VAULT_PATHS", active_vault_paths)
.env("WS_PORT", "9710")
.env("WS_UI_PORT", "9711")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!(
"ws-bridge spawned (pid: {}, runtime: {:?}, vault: {})",
child.id(),
runtime.kind,
vault_path.display()
);
Ok(child)
}
fn active_vault_paths_json(vault_path: &Path, vault_paths: &[PathBuf]) -> String {
let mut paths = Vec::new();
push_unique_bridge_vault_path(&mut paths, vault_path);
for path in vault_paths {
push_unique_bridge_vault_path(&mut paths, path);
}
serde_json::to_string(&paths).unwrap_or_else(|_| "[]".to_string())
}
fn push_unique_bridge_vault_path(paths: &mut Vec<String>, path: &Path) {
let value = path.to_string_lossy().trim().to_string();
if value.is_empty() || paths.iter().any(|existing| existing == &value) {
return;
}
paths.push(value);
}
fn mcp_config_paths() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| mcp_config_paths_for_home(&home))
.unwrap_or_default()
}
fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("config").join("mcp_config.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
}
fn legacy_gemini_mcp_config_paths() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| legacy_gemini_mcp_config_paths_for_home(&home))
.unwrap_or_default()
}
fn legacy_gemini_mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![home.join(".gemini").join("settings.json")]
}
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(config_path).ok()?;
let config: serde_json::Value = serde_json::from_str(&raw).ok()?;
config
.get("mcpServers")
.and_then(|value| value.as_object())
.and_then(|servers| {
servers
.get(MCP_SERVER_NAME)
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
})
.cloned()
}
fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
entry["args"]
.as_array()
.and_then(|args| args.first())
.and_then(|value| value.as_str())
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
entry["type"].as_str() == Some("stdio")
}
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
/// Build the durable external MCP server entry JSON for an index.js path.
fn build_mcp_entry(runtime_command: &str, index_js: &str) -> serde_json::Value {
let index_js = paths::client_script_path(Path::new(index_js));
serde_json::json!({
"type": "stdio",
"command": runtime_command,
"args": [index_js],
"env": {
"WS_UI_PORT": "9711"
}
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let _ = vault_path;
let runtime = find_mcp_runtime().map_err(|e| {
format!(
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build MCP config: {e}"
)
})?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = index_js_client_path(&server_dir);
let runtime_command = runtime.binary.to_string_lossy().into_owned();
let entry = build_mcp_entry(&runtime_command, &index_js);
build_mcp_config_snippet(&entry)
}
/// Build the exact OpenCode MCP config JSON users can copy into opencode.json.
pub fn opencode_mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let _ = vault_path;
let runtime = find_mcp_runtime().map_err(|e| {
format!(
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build OpenCode MCP config: {e}"
)
})?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = index_js_client_path(&server_dir);
let runtime_command = runtime.binary.to_string_lossy().into_owned();
let entry = opencode::build_entry(&runtime_command, &index_js);
opencode::build_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
let mut status = "registered";
for config_path in config_paths {
match upsert_mcp_config(config_path, entry) {
Ok(true) => status = "updated",
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
status.to_string()
}
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let _ = vault_path;
let runtime = find_mcp_runtime().map_err(|e| {
format!(
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can register MCP tools: {e}"
)
})?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = index_js_client_path(&server_dir);
let runtime_command = runtime.binary.to_string_lossy().into_owned();
let entry = build_mcp_entry(&runtime_command, &index_js);
let opencode_entry = opencode::build_entry(&runtime_command, &index_js);
if let Some(config_path) = opencode::config_path() {
if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) {
log::warn!("Failed to update {}: {}", config_path.display(), e);
}
}
let status = register_mcp_to_configs(&entry, &mcp_config_paths());
let _ = remove_mcp_from_configs(&legacy_gemini_mcp_config_paths());
Ok(status)
}
/// Insert or update the Tolaria entry in an MCP config file.
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut config: serde_json::Value = if config_path.exists() {
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?
} else {
serde_json::json!({})
};
let servers = config
.as_object_mut()
.ok_or("Config is not a JSON object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers = servers
.as_object_mut()
.ok_or("mcpServers is not a JSON object")?;
let was_update =
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
servers.remove(LEGACY_MCP_SERVER_NAME);
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(was_update)
}
fn remove_mcp_from_configs(config_paths: &[PathBuf]) -> String {
let mut removed_any = false;
for config_path in config_paths {
match remove_mcp_from_config(config_path) {
Ok(true) => removed_any = true,
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
if removed_any {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
let mut config: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut("mcpServers") else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcpServers is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove("mcpServers");
}
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(true)
}
fn mcp_removal_status(removed_configs: &[bool]) -> String {
if removed_configs.iter().any(|removed| *removed) {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
pub fn remove_mcp() -> String {
let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed";
let removed_legacy_gemini =
remove_mcp_from_configs(&legacy_gemini_mcp_config_paths()) == "removed";
let removed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::remove_config(&config_path).unwrap_or_else(|e| {
log::warn!("Failed to update {}: {}", config_path.display(), e);
false
})
});
mcp_removal_status(&[removed_standard, removed_legacy_gemini, removed_opencode])
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the Tolaria entry exists for the active vault in
/// an external AI tool config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let _ = vault_path;
let installed_standard = mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry)
})
});
let installed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::read_registered_entry(&config_path)
.is_some_and(|entry| opencode::entry_is_installed(&entry))
});
if installed_standard || installed_opencode {
McpStatus::Installed
} else {
McpStatus::NotInstalled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn read_config(config_path: &Path) -> serde_json::Value {
let raw = std::fs::read_to_string(config_path).unwrap();
serde_json::from_str(&raw).unwrap()
}
fn temp_config_path(file_name: &str) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join(file_name);
(tmp, config_path)
}
fn write_config_json(config_path: &Path, config: serde_json::Value) {
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
fn managed_server(index_js: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str) -> serde_json::Value {
build_mcp_entry("node", index_js)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
let servers = servers
.into_iter()
.map(|(name, server)| (name.to_string(), server))
.collect::<serde_json::Map<_, _>>();
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
struct ExpectedMcpServer<'a> {
index_js: &'a str,
}
fn assert_registered_tolaria_server(
config: &serde_json::Value,
expected: ExpectedMcpServer<'_>,
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert!(server["env"]["VAULT_PATH"].is_null());
assert_eq!(server["env"]["WS_UI_PORT"], "9711");
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
index_js
}
fn assert_candidates_include(candidates: &[PathBuf], expected: &[PathBuf]) {
for candidate in expected {
assert!(
candidates.contains(candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js");
assert_eq!(
entry,
serde_json::json!({
"type": "stdio",
"command": "/usr/local/bin/node",
"args": ["/path/to/index.js"],
"env": {
"WS_UI_PORT": "9711"
}
})
);
}
#[test]
fn build_mcp_entry_strips_windows_extended_length_script_prefix() {
let entry = build_mcp_entry("node", r"\\?\D:\Tolaria\mcp-server\index.js");
assert_eq!(entry["args"][0], r"D:\Tolaria\mcp-server\index.js",);
}
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
fn mcp_server_dir_candidates_prefer_resource_root_before_linux_packages() {
let dev_path = Path::new("/repo/mcp-server");
let resource_roots = vec![PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources",
)];
let candidates = mcp_server_dir_candidates(dev_path, &resource_roots);
let resource_dir = PathBuf::from("/Applications/Tolaria.app/Contents/Resources/mcp-server");
let linux_pos = candidates
.iter()
.position(|path| path == &PathBuf::from("/usr/local/Tolaria/mcp-server"))
.unwrap();
assert_eq!(candidates[0], dev_path);
assert_eq!(candidates[1], resource_dir);
assert!(1 < linux_pos);
}
#[test]
fn mcp_server_dir_candidates_include_linux_package_resource_roots() {
let dev_path = Path::new("/repo/mcp-server");
let resource_roots = vec![PathBuf::from("/opt/tolaria")];
let candidates = mcp_server_dir_candidates(dev_path, &resource_roots);
let expected = vec![
PathBuf::from("/opt/tolaria/Tolaria/mcp-server"),
PathBuf::from("/opt/tolaria/Tolaria/resources/mcp-server"),
PathBuf::from("/opt/tolaria/lib/Tolaria/mcp-server"),
PathBuf::from("/opt/tolaria/lib/Tolaria/resources/mcp-server"),
PathBuf::from("/opt/tolaria/lib/tolaria/mcp-server"),
PathBuf::from("/opt/tolaria/lib/tolaria/resources/mcp-server"),
PathBuf::from("/usr/local/Tolaria/mcp-server"),
PathBuf::from("/usr/local/Tolaria/resources/mcp-server"),
PathBuf::from("/usr/local/lib/Tolaria/mcp-server"),
PathBuf::from("/usr/local/lib/Tolaria/resources/mcp-server"),
PathBuf::from("/usr/local/lib/tolaria/mcp-server"),
PathBuf::from("/usr/local/lib/tolaria/resources/mcp-server"),
PathBuf::from("/usr/lib/Tolaria/mcp-server"),
PathBuf::from("/usr/lib/Tolaria/resources/mcp-server"),
PathBuf::from("/usr/lib/tolaria/mcp-server"),
PathBuf::from("/usr/lib/tolaria/resources/mcp-server"),
];
assert_candidates_include(&candidates, &expected);
}
#[test]
fn mcp_server_dir_candidates_include_deb_capitalized_lib_root() {
let dev_path = Path::new("/repo/mcp-server");
let candidates = mcp_server_dir_candidates(dev_path, &[]);
assert!(candidates.contains(&PathBuf::from("/usr/lib/Tolaria/mcp-server")));
}
#[test]
fn mcp_server_dir_candidates_include_linux_appimage_resource_root() {
let dev_path = Path::new("/repo/mcp-server");
let resource_roots = vec![PathBuf::from("/tmp/.mount_tolaria/usr")];
let candidates = mcp_server_dir_candidates(dev_path, &resource_roots);
assert_candidates_include(
&candidates,
&[PathBuf::from(
"/tmp/.mount_tolaria/usr/lib/tolaria/resources/mcp-server",
)],
);
}
#[test]
fn mcp_server_dir_candidates_include_runtime_dev_roots_when_build_path_is_stale() {
let stale_dev_path = Path::new("/Users/runner/work/tolaria/tolaria/mcp-server");
let current_dir = Path::new("/Users/luca/Workspace/tolaria");
let candidates = mcp_server_dir_candidates_for(stale_dev_path, &[], Some(current_dir));
assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server")));
assert!(candidates.contains(&PathBuf::from(
"/Users/luca/Workspace/tolaria/src-tauri/resources/mcp-server"
)));
}
#[test]
fn mcp_server_dir_candidates_include_macos_bundle_resources() {
let dev_path = Path::new("/repo/mcp-server");
let resource_roots = vec![PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources",
)];
let candidates = mcp_server_dir_candidates_for(dev_path, &resource_roots, None);
assert_candidates_include(
&candidates,
&[PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources/mcp-server",
)],
);
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
let config = read_config(&config_path);
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
},
);
}
#[test]
fn upsert_updates_existing_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
let config = read_config(&config_path);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
fn upsert_migrates_legacy_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let existing = serde_json::json!({
"mcpServers": {
"laputa": {
"command": "node",
"args": ["/old/index.js"],
"env": { "VAULT_PATH": "/old" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
let config = read_config(&config_path);
assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
}
#[test]
fn upsert_preserves_other_servers() {
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![(
"other-server",
serde_json::json!({ "command": "other", "args": [] }),
)],
);
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(config["mcpServers"]["other-server"].is_object());
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_other_top_level_settings() {
let (_tmp, config_path) = temp_config_path(".claude.json");
write_config_json(
&config_path,
serde_json::json!({
"model": "sonnet",
"theme": "dark",
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
}),
);
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert_eq!(
(
config["model"].as_str(),
config["theme"].as_str(),
config["mcpServers"]["other-server"].is_object(),
config["mcpServers"][MCP_SERVER_NAME].is_object(),
),
(Some("sonnet"), Some("dark"), true, true)
);
}
#[test]
fn upsert_preserves_antigravity_mcp_config_fields() {
let (_tmp, config_path) = temp_config_path("mcp_config.json");
write_config_json(
&config_path,
serde_json::json!({
"theme": "GitHub",
"mcpServers": {
"other": { "command": "example" }
}
}),
);
let entry = test_mcp_entry("/antigravity/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["theme"], "GitHub");
assert_eq!(config["mcpServers"]["other"]["command"], "example");
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/antigravity/index.js",
},
);
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
}
#[test]
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = test_mcp_entry("/test/index.js");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
}
#[test]
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
// Second call
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "updated");
}
#[test]
fn mcp_server_dir_resolves_in_dev() {
let dir = mcp_server_dir().unwrap();
assert!(dir.join("ws-bridge.js").exists());
assert!(dir.join("index.js").exists());
assert!(dir.join("vault.js").exists());
}
#[test]
fn spawn_ws_bridge_starts_and_can_be_killed() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().to_str().unwrap();
let mut child = spawn_ws_bridge(vault_path).unwrap();
assert!(child.id() > 0, "child process should have a valid PID");
// Clean up: kill the spawned process
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn register_mcp_to_configs_writes_multiple_configs() {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let antigravity_cfg = tmp
.path()
.join(".gemini")
.join("config")
.join("mcp_config.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js");
register_mcp_to_configs(
&entry,
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
antigravity_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
);
let config_paths = [
&claude_user_cfg,
&claude_cfg,
&antigravity_cfg,
&cursor_cfg,
&generic_cfg,
];
assert!(config_paths.iter().all(|config_path| config_path.exists()));
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
},
);
}
#[test]
fn mcp_config_paths_for_home_includes_all_supported_config_paths() {
let home = Path::new("/Users/tester");
let paths = mcp_config_paths_for_home(home);
assert_eq!(
paths,
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("config").join("mcp_config.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
);
}
#[test]
fn legacy_gemini_mcp_config_paths_for_home_points_to_settings_json() {
let home = Path::new("/Users/tester");
let paths = legacy_gemini_mcp_config_paths_for_home(home);
assert_eq!(paths, vec![home.join(".gemini").join("settings.json")]);
}
#[test]
fn upsert_returns_error_for_invalid_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = test_mcp_entry("/test/index.js");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
assert_eq!(status, "registered");
}
#[test]
fn read_registered_mcp_entry_prefers_primary_then_uses_legacy_server_name() {
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![
(MCP_SERVER_NAME, managed_server("/primary/index.js")),
(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js")),
],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["args"][0], "/primary/index.js");
write_mcp_servers_config(
&config_path,
vec![(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js"))],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["args"][0], "/legacy/index.js");
}
#[test]
fn read_registered_mcp_entry_returns_none_for_invalid_or_missing_servers() {
let tmp = tempfile::tempdir().unwrap();
let invalid_path = tmp.path().join("invalid.json");
std::fs::write(&invalid_path, "{not json").unwrap();
assert!(read_registered_mcp_entry(&invalid_path).is_none());
let empty_path = tmp.path().join("empty.json");
let empty_config = serde_json::json!({ "other": {} });
std::fs::write(&empty_path, serde_json::to_string(&empty_config).unwrap()).unwrap();
assert!(read_registered_mcp_entry(&empty_path).is_none());
let missing_path = tmp.path().join("missing.json");
assert!(read_registered_mcp_entry(&missing_path).is_none());
}
#[test]
fn entry_index_js_exists_requires_existing_first_arg() {
let tmp = tempfile::tempdir().unwrap();
let index_js = tmp.path().join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
let existing = serde_json::json!({
"args": [index_js.to_string_lossy()]
});
assert!(entry_index_js_exists(&existing));
let missing = serde_json::json!({
"args": [tmp.path().join("missing.js").to_string_lossy()]
});
assert!(!entry_index_js_exists(&missing));
let no_args = serde_json::json!({});
assert!(!entry_index_js_exists(&no_args));
}
#[test]
fn upsert_returns_error_for_non_object_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
#[test]
fn upsert_returns_error_for_non_object_mcp_servers() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": []
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
);
}
#[test]
fn remove_mcp_from_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": { "command": "node", "args": ["/index.js"] },
"laputa": { "command": "node", "args": ["/legacy.js"] },
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(removed);
let updated = read_config(&config_path);
assert!(updated["mcpServers"][MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"]["other-server"].is_object());
}
#[test]
fn remove_mcp_from_configs_removes_legacy_gemini_settings_entry() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join(".gemini").join("settings.json");
let config = serde_json::json!({
"preferredEditor": "vim",
"mcpServers": {
"tolaria": { "command": "node", "args": ["/index.js"] }
}
});
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let status = remove_mcp_from_configs(std::slice::from_ref(&config_path));
let updated = read_config(&config_path);
assert_eq!(status, "removed");
assert_eq!(updated["preferredEditor"], "vim");
assert!(updated["mcpServers"].is_null());
}
#[test]
fn remove_mcp_from_config_returns_false_when_entry_missing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(!removed);
}
#[test]
fn durable_entry_is_status_eligible_without_vault_env() {
let tmp = tempfile::tempdir().unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"type": "stdio",
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "WS_UI_PORT": "9711" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_uses_stdio(&entry));
assert!(entry_index_js_exists(&entry));
assert!(entry_has_ui_port(&entry));
}
#[test]
fn mcp_status_serializes_to_snake_case() {
let json = serde_json::to_string(&McpStatus::Installed).unwrap();
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
}
}