fix: write Claude Code MCP config to .claude.json
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -115,13 +115,17 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
}
|
||||
|
||||
fn mcp_config_paths() -> Vec<PathBuf> {
|
||||
[
|
||||
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<PathBuf> {
|
||||
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<serde_json::Value> {
|
||||
@@ -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::<serde_json::Map<_, _>>();
|
||||
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");
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -70,11 +70,12 @@ export function McpSetupDialog({
|
||||
Confirming this action will write or update Tolaria's single <code className="rounded bg-muted px-1 py-0.5 text-xs">tolaria</code> MCP entry in:
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
|
||||
<div>~/.claude.json</div>
|
||||
<div>~/.claude/mcp.json</div>
|
||||
<div>~/.cursor/mcp.json</div>
|
||||
</div>
|
||||
<p>
|
||||
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 <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user