diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 288bb1dc..52da2fa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -310,7 +310,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan ### Explicit External Tool Setup Tolaria can register itself as an MCP server in: -- `~/.claude/mcp.json` (Claude Code) +- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups) - `~/.cursor/mcp.json` (Cursor) That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). @@ -332,7 +332,7 @@ flowchart TD end TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP - UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"] + UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json"] ``` ### WebSocket Bridge diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 43e8369c..cd2c446e 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -115,13 +115,17 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result { } fn mcp_config_paths() -> Vec { - [ - dirs::home_dir().map(|home| home.join(".claude").join("mcp.json")), - dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")), + dirs::home_dir() + .map(|home| mcp_config_paths_for_home(&home)) + .unwrap_or_default() +} + +fn mcp_config_paths_for_home(home: &Path) -> Vec { + vec![ + home.join(".claude.json"), + home.join(".claude").join("mcp.json"), + home.join(".cursor").join("mcp.json"), ] - .into_iter() - .flatten() - .collect() } fn read_registered_mcp_entry(config_path: &Path) -> Option { @@ -321,6 +325,32 @@ mod tests { 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, vault_path: &str) -> serde_json::Value { + serde_json::json!({ + "command": "node", + "args": [index_js], + "env": { "VAULT_PATH": vault_path } + }) + } + + 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::>(); + write_config_json(config_path, serde_json::json!({ "mcpServers": servers })); + } + fn write_index_js(dir: &Path) -> PathBuf { let index_js = dir.join("index.js"); std::fs::write(&index_js, "console.log('ok');").unwrap(); @@ -404,15 +434,11 @@ mod tests { #[test] fn upsert_preserves_other_servers() { - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join("mcp.json"); - - let existing = serde_json::json!({ - "mcpServers": { - "other-server": { "command": "other", "args": [] } - } - }); - std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap(); + 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 = build_mcp_entry("/test/index.js", "/vault"); upsert_mcp_config(&config_path, &entry).unwrap(); @@ -423,6 +449,30 @@ mod tests { 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 = build_mcp_entry("/test/index.js", "/vault"); + upsert_mcp_config(&config_path, &entry).unwrap(); + + let config = read_config(&config_path); + assert_eq!(config["model"], "sonnet"); + assert_eq!(config["theme"], "dark"); + assert!(config["mcpServers"]["other-server"].is_object()); + assert!(config["mcpServers"][MCP_SERVER_NAME].is_object()); + } + #[test] fn upsert_creates_parent_dirs() { let tmp = tempfile::tempdir().unwrap(); @@ -491,22 +541,42 @@ mod tests { #[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 cursor_cfg = tmp.path().join("cursor").join("mcp.json"); let entry = build_mcp_entry("/test/index.js", "/vault"); - register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]); + register_mcp_to_configs( + &entry, + &[claude_user_cfg.clone(), claude_cfg.clone(), cursor_cfg.clone()], + ); + assert!(claude_user_cfg.exists()); assert!(claude_cfg.exists()); assert!(cursor_cfg.exists()); - let raw = std::fs::read_to_string(&claude_cfg).unwrap(); + let raw = std::fs::read_to_string(&claude_user_cfg).unwrap(); let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert_eq!( config["mcpServers"][MCP_SERVER_NAME]["args"][0], "/test/index.js" ); } + + #[test] + fn mcp_config_paths_for_home_includes_claude_root_and_legacy_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(".cursor").join("mcp.json"), + ] + ); + } #[test] fn upsert_returns_error_for_invalid_json() { let tmp = tempfile::tempdir().unwrap(); @@ -528,23 +598,17 @@ mod tests { #[test] fn read_registered_mcp_entry_prefers_primary_server_name() { - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join("mcp.json"); - let config = serde_json::json!({ - "mcpServers": { - "tolaria": { - "command": "node", - "args": ["/primary/index.js"], - "env": { "VAULT_PATH": "/primary" } - }, - "laputa": { - "command": "node", - "args": ["/legacy/index.js"], - "env": { "VAULT_PATH": "/legacy" } - } - } - }); - std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + let (_tmp, config_path) = temp_config_path("mcp.json"); + write_mcp_servers_config( + &config_path, + vec![ + (MCP_SERVER_NAME, managed_server("/primary/index.js", "/primary")), + ( + LEGACY_MCP_SERVER_NAME, + managed_server("/legacy/index.js", "/legacy"), + ), + ], + ); let entry = read_registered_mcp_entry(&config_path).unwrap(); assert_eq!(entry["env"]["VAULT_PATH"], "/primary"); @@ -552,18 +616,14 @@ mod tests { #[test] fn read_registered_mcp_entry_uses_legacy_server_name() { - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join("mcp.json"); - let config = serde_json::json!({ - "mcpServers": { - "laputa": { - "command": "node", - "args": ["/legacy/index.js"], - "env": { "VAULT_PATH": "/legacy" } - } - } - }); - std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + let (_tmp, config_path) = temp_config_path("mcp.json"); + write_mcp_servers_config( + &config_path, + vec![( + LEGACY_MCP_SERVER_NAME, + managed_server("/legacy/index.js", "/legacy"), + )], + ); let entry = read_registered_mcp_entry(&config_path).unwrap(); assert_eq!(entry["env"]["VAULT_PATH"], "/legacy"); diff --git a/src/components/McpSetupDialog.test.tsx b/src/components/McpSetupDialog.test.tsx index cc2a6eb8..52e165c0 100644 --- a/src/components/McpSetupDialog.test.tsx +++ b/src/components/McpSetupDialog.test.tsx @@ -17,6 +17,9 @@ describe('McpSetupDialog', () => { expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument() expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument() + expect(screen.getAllByText('~/.claude.json')).toHaveLength(2) + expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument() + expect(screen.getByText(/Claude Code CLI reads/i)).toBeInTheDocument() expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools') expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument() }) diff --git a/src/components/McpSetupDialog.tsx b/src/components/McpSetupDialog.tsx index 2ba48343..0f6aa379 100644 --- a/src/components/McpSetupDialog.tsx +++ b/src/components/McpSetupDialog.tsx @@ -70,11 +70,12 @@ export function McpSetupDialog({ Confirming this action will write or update Tolaria's single tolaria MCP entry in:

+
~/.claude.json
~/.claude/mcp.json
~/.cursor/mcp.json

- The entry points those tools at the current vault. Cancel leaves both files untouched, reconnect is idempotent, and disconnect removes Tolaria's entry again. + Claude Code CLI reads ~/.claude.json, while Tolaria also refreshes the legacy Claude MCP file for compatibility with older setups. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria's entry again.