From 38fb8a7370bfa7214b392a461dd2fac776c9018c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 28 Apr 2026 14:03:10 +0200 Subject: [PATCH] feat: copy mcp config from app --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 4 +- docs/GETTING-STARTED.md | 4 +- src-tauri/src/commands/system.rs | 15 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/mcp.rs | 38 ++++++ src/App.tsx | 22 +++- src/components/AiPanel.test.tsx | 9 ++ src/components/AiPanel.tsx | 6 + src/components/AiPanelChrome.tsx | 34 +++++- src/components/Editor.tsx | 6 + src/components/EditorRightPanel.tsx | 3 + src/components/McpSetupDialog.test.tsx | 43 ++++++- src/components/McpSetupDialog.tsx | 155 +++++++++++++++++++------ src/hooks/commands/settingsCommands.ts | 2 +- src/hooks/useMcpStatus.test.ts | 52 +++++++++ src/hooks/useMcpStatus.ts | 113 ++++++++++++++++-- src/mock-tauri/mock-handlers.ts | 13 +++ tests/helpers/fixtureVault.ts | 13 +++ tests/smoke/ai-chat-history.spec.ts | 2 +- tests/smoke/mcp-config-copy.spec.ts | 47 ++++++++ ui-design.pen | 107 +++++++++++++++++ 22 files changed, 631 insertions(+), 60 deletions(-) create mode 100644 tests/smoke/mcp-config-copy.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7a867589..83329eb0 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -345,7 +345,7 @@ Command-layer path access is fenced to the active vault before file operations r UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary. -The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. +The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. ### Vault Caching diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e95b5fb6..dbcf92ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -329,7 +329,7 @@ Tolaria can register itself as an MCP server in: - `~/.cursor/mcp.json` (Cursor) - `~/.config/mcp/mcp.json` (generic MCP-compatible clients) -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. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. +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. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. ### Architecture @@ -377,6 +377,7 @@ flowchart LR | `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env | | `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes | | `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request | +| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files | | `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs | | `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) | @@ -727,6 +728,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault | | `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config | | `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config | +| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault | | `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected | The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 1b1ed0c7..6f6c22ad 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -425,6 +425,6 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts ### Work with external MCP setup -1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711` -2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files +1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711` +2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files 3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 5d97c925..4dba1b85 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path)) + .await + .map_err(|e| format!("MCP config task failed: {e}"))? +} + #[cfg(desktop)] #[tauri::command] pub async fn sync_mcp_bridge_vault( @@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result Result { + Err("MCP is not available on mobile".into()) +} + #[cfg(mobile)] #[tauri::command] pub async fn sync_mcp_bridge_vault(_vault_path: Option) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e08f3045..ebf7cce5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -435,6 +435,7 @@ macro_rules! app_invoke_handler { commands::register_mcp_tools, commands::remove_mcp_tools, commands::check_mcp_status, + commands::get_mcp_config_snippet, commands::sync_mcp_bridge_vault, commands::repair_vault, commands::reinit_telemetry, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index e4b8eb28..9f078196 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -313,6 +313,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd }) } +fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result { + 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 { + let node = find_node().map_err(|e| { + format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}") + })?; + let server_dir = mcp_server_dir()?; + let index_js = server_dir.join("index.js").to_string_lossy().into_owned(); + let node_command = node.to_string_lossy().into_owned(); + let entry = build_mcp_entry(&node_command, &index_js, vault_path); + + build_mcp_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 { @@ -518,6 +540,22 @@ mod tests { assert_eq!(entry["env"]["WS_UI_PORT"], "9711"); } + #[test] + fn build_mcp_config_snippet_wraps_tolaria_server_entry() { + let entry = test_mcp_entry("/path/to/index.js", "/my/vault"); + 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_eq!( + config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"], + "/my/vault" + ); + } + #[test] fn first_node_lookup_path_uses_first_non_empty_line() { let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n"; diff --git a/src/App.tsx b/src/App.tsx index a899647f..c9ea6793 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -453,7 +453,16 @@ function App() { trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length }) } }, [vault.entries.length, gitRepoState, resolvedPath]) - const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage) + const { + mcpStatus, + connectMcp, + disconnectMcp, + mcpConfigSnippet, + mcpConfigLoading, + mcpConfigError, + loadMcpConfigSnippet, + copyMcpConfig, + } = useMcpStatus(resolvedPath, setToastMessage) const gitRemoteStatus = useGitRemoteStatus(resolvedPath) const loadVaultModifiedFiles = vault.loadModifiedFiles const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus @@ -493,6 +502,14 @@ function App() { } }, [disconnectMcp]) + const handleCopyMcpConfig = useCallback(() => { + void copyMcpConfig() + }, [copyMcpConfig]) + + const handleLoadMcpConfigSnippet = useCallback(() => { + void loadMcpConfigSnippet().catch(() => undefined) + }, [loadMcpConfigSnippet]) + // Detect external file renames on window focus const [detectedRenames, setDetectedRenames] = useState([]) useEffect(() => { @@ -1601,6 +1618,7 @@ function App() { onInitializeProperties={handleInitializeProperties} showAIChat={dialogs.showAIChat} onToggleAIChat={dialogs.toggleAIChat} + onCopyMcpConfig={handleCopyMcpConfig} vaultPath={resolvedPath} noteList={aiNoteList} noteListFilter={aiNoteListFilter} @@ -1685,7 +1703,7 @@ function App() { /> - + {deleteActions.confirmDelete && ( { expect(mockClearConversation).toHaveBeenCalledOnce() }) + it('copies the MCP config from the AI panel header action', () => { + const onCopyMcpConfig = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' })) + + expect(onCopyMcpConfig).toHaveBeenCalledOnce() + }) + it('renders empty state without context', () => { render() expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy() diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 59ec7fd7..072ff439 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent' interface AiPanelProps { onClose: () => void + onCopyMcpConfig?: () => void onOpenNote?: (path: string) => void onUnsupportedAiPaste?: (message: string) => void defaultAiAgent?: AiAgentId @@ -42,6 +43,7 @@ interface AiPanelProps { interface AiPanelViewProps { controller: AiPanelController onClose: () => void + onCopyMcpConfig?: () => void onOpenNote?: (path: string) => void onUnsupportedAiPaste?: (message: string) => void defaultAiAgent?: AiAgentId @@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness { export function AiPanelView({ controller, onClose, + onCopyMcpConfig, onOpenNote, onUnsupportedAiPaste, defaultAiAgent: providedDefaultAiAgent, @@ -113,6 +116,7 @@ export function AiPanelView({ agentLabel={agentLabel} agentReadiness={defaultAiAgentReadiness} onClose={onClose} + onCopyMcpConfig={onCopyMcpConfig} onNewChat={handleNewChat} /> {activeEntry && ( @@ -144,6 +148,7 @@ export function AiPanelView({ export function AiPanel({ onClose, + onCopyMcpConfig, onOpenNote, onUnsupportedAiPaste, defaultAiAgent: providedDefaultAiAgent, @@ -183,6 +188,7 @@ export function AiPanel({ void + onCopyMcpConfig?: () => void onNewChat: () => void } @@ -122,6 +125,7 @@ export function AiPanelHeader({ agentLabel, agentReadiness, onClose, + onCopyMcpConfig, onNewChat, }: AiPanelHeaderProps) { return ( @@ -140,21 +144,39 @@ export function AiPanelHeader({ : `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`} - + ) : null} + - + ) } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 377a5a1e..407c2cac 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -67,6 +67,7 @@ interface EditorProps { onInitializeProperties?: (path: string) => void showAIChat?: boolean onToggleAIChat?: () => void + onCopyMcpConfig?: () => void vaultPath?: string noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } @@ -338,6 +339,7 @@ function EditorLayout({ showDiffToggle, showAIChat, onToggleAIChat, + onCopyMcpConfig, inspectorCollapsed, onToggleInspector, onNavigateWikilink, @@ -400,6 +402,7 @@ function EditorLayout({ showDiffToggle: boolean showAIChat?: boolean onToggleAIChat?: () => void + onCopyMcpConfig?: () => void inspectorCollapsed: boolean onToggleInspector: () => void onNavigateWikilink: (target: string) => void @@ -520,6 +523,7 @@ function EditorLayout({ noteListFilter={noteListFilter} onToggleInspector={onToggleInspector} onToggleAIChat={onToggleAIChat} + onCopyMcpConfig={onCopyMcpConfig} onNavigateWikilink={onNavigateWikilink} onViewCommitDiff={handleViewCommitDiff} onUpdateFrontmatter={onUpdateFrontmatter} @@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) { inspectorEntry, inspectorContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, showAIChat, onToggleAIChat, + onCopyMcpConfig, vaultPath, noteList, noteListFilter, onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile, onDeleteNote, onArchiveNote, onUnarchiveNote, @@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) { showDiffToggle={showDiffToggle} showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} + onCopyMcpConfig={onCopyMcpConfig} inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} onNavigateWikilink={onNavigateWikilink} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index fa9ed95d..63f2cd3b 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -25,6 +25,7 @@ interface EditorRightPanelProps { noteListFilter?: { type: string | null; query: string } onToggleInspector: () => void onToggleAIChat?: () => void + onCopyMcpConfig?: () => void onNavigateWikilink: (target: string) => void onViewCommitDiff: (commitHash: string) => Promise onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise @@ -48,6 +49,7 @@ export function EditorRightPanel({ inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, noteList, noteListFilter, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, + onCopyMcpConfig, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote, onFileCreated, onFileModified, onVaultChanged, locale, @@ -87,6 +89,7 @@ export function EditorRightPanel({ onToggleAIChat?.()} + onCopyMcpConfig={onCopyMcpConfig} onOpenNote={onOpenNote} onUnsupportedAiPaste={onUnsupportedAiPaste} defaultAiAgent={defaultAiAgent} diff --git a/src/components/McpSetupDialog.test.tsx b/src/components/McpSetupDialog.test.tsx index ef2b87dc..6f22188c 100644 --- a/src/components/McpSetupDialog.test.tsx +++ b/src/components/McpSetupDialog.test.tsx @@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { McpSetupDialog } from './McpSetupDialog' +const MANUAL_CONFIG = JSON.stringify({ + mcpServers: { + tolaria: { + type: 'stdio', + command: 'node', + args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'], + env: { + VAULT_PATH: '/Users/luca/Laputa', + WS_UI_PORT: '9711', + }, + }, + }, +}, null, 2) + describe('McpSetupDialog', () => { it('renders the explicit setup flow without mutating config by default', () => { render( @@ -9,6 +23,7 @@ describe('McpSetupDialog', () => { open={true} status="not_installed" busyAction={null} + manualConfigSnippet={MANUAL_CONFIG} onClose={vi.fn()} onConnect={vi.fn()} onDisconnect={vi.fn()} @@ -18,9 +33,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.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument() - expect(screen.getByText(/type: stdio/i)).toBeInTheDocument() - expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument() - expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument() + expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"') + expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"') + expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"') expect(screen.getAllByText('~/.claude.json')).toHaveLength(2) expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument() expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2) @@ -49,6 +64,7 @@ describe('McpSetupDialog', () => { it('routes actions through the dialog buttons', () => { const onClose = vi.fn() const onConnect = vi.fn() + const onCopyManualConfig = vi.fn() const onDisconnect = vi.fn() render( @@ -58,16 +74,37 @@ describe('McpSetupDialog', () => { busyAction={null} onClose={onClose} onConnect={onConnect} + onCopyManualConfig={onCopyManualConfig} onDisconnect={onDisconnect} />, ) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByTestId('mcp-copy-config')) fireEvent.click(screen.getByTestId('mcp-setup-connect')) fireEvent.click(screen.getByTestId('mcp-setup-disconnect')) expect(onClose).toHaveBeenCalledOnce() + expect(onCopyManualConfig).toHaveBeenCalledOnce() expect(onConnect).toHaveBeenCalledOnce() expect(onDisconnect).toHaveBeenCalledOnce() }) + + it('loads exact manual config when opened', () => { + const onLoadManualConfig = vi.fn() + + render( + , + ) + + expect(onLoadManualConfig).toHaveBeenCalledOnce() + }) }) diff --git a/src/components/McpSetupDialog.tsx b/src/components/McpSetupDialog.tsx index 0f0e1585..a610c076 100644 --- a/src/components/McpSetupDialog.tsx +++ b/src/components/McpSetupDialog.tsx @@ -1,4 +1,5 @@ -import { ShieldCheck } from 'lucide-react' +import { useEffect } from 'react' +import { Copy, ShieldCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, @@ -14,6 +15,29 @@ interface McpSetupDialogProps { open: boolean status: McpStatus busyAction: 'connect' | 'disconnect' | null + manualConfigError?: string | null + manualConfigLoading?: boolean + manualConfigSnippet?: string | null + onClose: () => void + onConnect: () => void + onCopyManualConfig?: () => void + onDisconnect: () => void + onLoadManualConfig?: () => void +} + +interface ManualMcpConfigSectionProps { + error?: string | null + loading: boolean + onCopy?: () => void + snippet?: string | null +} + +interface McpSetupActionsProps { + buttonsDisabled: boolean + connectBusy: boolean + disconnectBusy: boolean + primaryLabel: string + secondaryLabel: string | null onClose: () => void onConnect: () => void onDisconnect: () => void @@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) { } } +function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string { + if (loading) return 'Loading exact MCP config...' + return error ?? snippet ?? 'Exact config is available after a vault is open.' +} + +function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) { + return ( +
+
+

Manual MCP config

+ +
+
+        {manualConfigText(props)}
+      
+
+ ) +} + +function McpSetupActions({ + buttonsDisabled, + connectBusy, + disconnectBusy, + primaryLabel, + secondaryLabel, + onClose, + onConnect, + onDisconnect, +}: McpSetupActionsProps) { + return ( + + + {secondaryLabel ? ( + + ) : null} + + + ) +} + export function McpSetupDialog({ open, status, busyAction, + manualConfigError, + manualConfigLoading = false, + manualConfigSnippet, onClose, onConnect, + onCopyManualConfig, onDisconnect, + onLoadManualConfig, }: McpSetupDialogProps) { const copy = actionCopy(status) const connectBusy = busyAction === 'connect' const disconnectBusy = busyAction === 'disconnect' const buttonsDisabled = busyAction !== null || status === 'checking' + useEffect(() => { + if (open) onLoadManualConfig?.() + }, [open, onLoadManualConfig]) + return ( { if (!next) onClose() }}> @@ -78,43 +183,27 @@ export function McpSetupDialog({
~/.cursor/mcp.json
~/.config/mcp/mcp.json
-
-
type: stdio
-
command: node
-
args: <Tolaria resources>/mcp-server/index.js
-
VAULT_PATH: active vault
-
WS_UI_PORT: 9711
-
+

Claude Code CLI reads ~/.claude.json, Cursor reads ~/.cursor/mcp.json, and the generic ~/.config/mcp/mcp.json path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria's entry again.

- - - {copy.secondaryLabel ? ( - - ) : null} - - +
) diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index e09491a9..50f8f515 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -149,7 +149,7 @@ function buildMaintenanceCommands({ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…', group: 'Settings', - keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'], + keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'], enabled: true, execute: () => onInstallMcp?.(), }, diff --git a/src/hooks/useMcpStatus.test.ts b/src/hooks/useMcpStatus.test.ts index 3bbd72eb..a269255f 100644 --- a/src/hooks/useMcpStatus.test.ts +++ b/src/hooks/useMcpStatus.test.ts @@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) { return renderHook(() => useMcpStatus('/vault', onToast)) } +function mockClipboard(writeText = vi.fn(() => Promise.resolve())) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return writeText +} + function mockStatusFlow( initialStatus: 'installed' | 'not_installed', overrides: Partial> = {}, @@ -76,6 +84,7 @@ async function runMutationScenario({ describe('useMcpStatus', () => { beforeEach(() => { vi.clearAllMocks() + mockClipboard() }) it('checks the active vault status without auto-registering on mount', async () => { @@ -160,4 +169,47 @@ describe('useMcpStatus', () => { expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs) expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment)) }) + + it('loads the exact manual MCP config snippet for the active vault', async () => { + const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } }) + mockCommands({ + check_mcp_status: 'installed', + get_mcp_config_snippet: snippet, + }) + const { result } = renderSubject() + + await waitFor(() => { + expect(result.current.mcpStatus).toBe('installed') + }) + + await act(async () => { + await result.current.loadMcpConfigSnippet() + }) + + expect(result.current.mcpConfigSnippet).toBe(snippet) + expect(result.current.mcpConfigError).toBeNull() + expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' }) + }) + + it('copies the manual MCP config snippet to the clipboard', async () => { + const writeText = mockClipboard() + const onToast = vi.fn() + const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } }) + mockCommands({ + check_mcp_status: 'not_installed', + get_mcp_config_snippet: snippet, + }) + const { result } = renderSubject(onToast) + + await waitFor(() => { + expect(result.current.mcpStatus).toBe('not_installed') + }) + + await act(async () => { + await expect(result.current.copyMcpConfig()).resolves.toBe(true) + }) + + expect(writeText).toHaveBeenCalledWith(snippet) + expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard') + }) }) diff --git a/src/hooks/useMcpStatus.ts b/src/hooks/useMcpStatus.ts index ec21a93a..502f37d8 100644 --- a/src/hooks/useMcpStatus.ts +++ b/src/hooks/useMcpStatus.ts @@ -1,49 +1,136 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' export type McpStatus = 'checking' | 'installed' | 'not_installed' +type ManualConfigSnippet = string +type McpCommand = + | 'check_mcp_status' + | 'get_mcp_config_snippet' + | 'register_mcp_tools' + | 'remove_mcp_tools' +type McpCommandResult = string +type McpStatusResponse = string +type ToastHandler = (msg: ToastMessage) => void +type ToastMessage = string +type VaultPath = string -function tauriCall(command: string, args?: Record): Promise { +interface ManualMcpConfigState { + error: ToastMessage | null + loading: boolean + snippet: ManualConfigSnippet | null + vaultPath: VaultPath +} + +const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = { + error: null, + loading: false, + snippet: null, + vaultPath: '', +} + +function tauriCall(command: McpCommand, args?: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(command, args) } -function normalizeMcpStatus(value: string | null | undefined): McpStatus { +function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus { return value === 'installed' ? 'installed' : 'not_installed' } -async function fetchMcpStatus(vaultPath: string): Promise { +async function fetchMcpStatus(vaultPath: VaultPath): Promise { try { - const result = await tauriCall('check_mcp_status', { vaultPath }) + const result = await tauriCall('check_mcp_status', { vaultPath }) return normalizeMcpStatus(result) } catch { return 'not_installed' } } -function connectSuccessToast(result: string): string { +function connectSuccessToast(result: McpCommandResult): ToastMessage { return result === 'registered' ? 'Tolaria external AI tools connected successfully' : 'Tolaria external AI tools setup refreshed successfully' } -function disconnectSuccessToast(result: string): string { +function disconnectSuccessToast(result: McpCommandResult): ToastMessage { return result === 'removed' ? 'Tolaria external AI tools disconnected successfully' : 'Tolaria external AI tools were already disconnected' } +function errorMessage(error: unknown): ToastMessage { + return error instanceof Error ? error.message : String(error) +} + +function visibleManualConfig( + state: ManualMcpConfigState, + vaultPath: VaultPath, +): ManualMcpConfigState { + return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG +} + +async function writeClipboardText(value: ManualConfigSnippet): Promise { + if (!navigator.clipboard?.writeText) { + throw new Error('Clipboard API is unavailable') + } + + await navigator.clipboard.writeText(value) +} + +function useManualMcpConfig( + vaultPath: VaultPath, + onToastRef: MutableRefObject, +) { + const [manualConfig, setManualConfig] = useState(EMPTY_MANUAL_CONFIG) + + const loadMcpConfigSnippet = useCallback(async () => { + setManualConfig({ error: null, loading: true, snippet: null, vaultPath }) + try { + const snippet = await tauriCall('get_mcp_config_snippet', { vaultPath }) + setManualConfig({ error: null, loading: false, snippet, vaultPath }) + return snippet + } catch (error) { + const message = errorMessage(error) + setManualConfig({ error: message, loading: false, snippet: null, vaultPath }) + throw error + } + }, [vaultPath]) + + const copyMcpConfig = useCallback(async () => { + try { + const snippet = await loadMcpConfigSnippet() + await writeClipboardText(snippet) + onToastRef.current('Tolaria MCP config copied to clipboard') + return true + } catch (error) { + onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`) + return false + } + }, [loadMcpConfigSnippet, onToastRef]) + + const currentManualConfig = visibleManualConfig(manualConfig, vaultPath) + + return { + mcpConfigSnippet: currentManualConfig.snippet, + mcpConfigLoading: currentManualConfig.loading, + mcpConfigError: currentManualConfig.error, + loadMcpConfigSnippet, + copyMcpConfig, + } +} + /** * Detects whether the active vault is explicitly connected to external MCP * clients and exposes connect / disconnect actions. */ export function useMcpStatus( - vaultPath: string, - onToast: (msg: string) => void, + vaultPath: VaultPath, + onToast: ToastHandler, ) { const [status, setStatus] = useState('checking') const onToastRef = useRef(onToast) useEffect(() => { onToastRef.current = onToast }) + const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef) const refreshMcpStatus = useCallback(async () => { const nextStatus = await fetchMcpStatus(vaultPath) @@ -91,5 +178,11 @@ export function useMcpStatus( } }, [refreshMcpStatus]) - return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp } + return { + mcpStatus: status, + refreshMcpStatus, + connectMcp, + disconnectMcp, + ...manualConfigActions, + } } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 34562236..727679f2 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -486,6 +486,19 @@ export const mockHandlers: Record any> = { }, register_mcp_tools: () => 'registered', check_mcp_status: () => 'installed', + get_mcp_config_snippet: (args: { vaultPath?: string }) => JSON.stringify({ + mcpServers: { + tolaria: { + type: 'stdio', + command: 'node', + args: ['/mock/Tolaria/mcp-server/index.js'], + env: { + VAULT_PATH: args.vaultPath ?? '/Users/mock/Documents/Getting Started', + WS_UI_PORT: '9711', + }, + }, + }, + }, null, 2), sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped', repair_vault: (): string => { mockVaultAiGuidanceStatus = { diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index d8f7657b..7c2e858e 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -252,6 +252,19 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix save_vault_list: () => null, save_settings: () => null, register_mcp_tools: () => null, + get_mcp_config_snippet: () => JSON.stringify({ + mcpServers: { + tolaria: { + type: 'stdio', + command: 'node', + args: ['/fixture/Tolaria/mcp-server/index.js'], + env: { + VAULT_PATH: resolvedVaultPath, + WS_UI_PORT: '9711', + }, + }, + }, + }, null, 2), reinit_telemetry: () => null, update_menu_state: () => null, get_settings: () => ({ diff --git a/tests/smoke/ai-chat-history.spec.ts b/tests/smoke/ai-chat-history.spec.ts index 876971c0..c0b45a8d 100644 --- a/tests/smoke/ai-chat-history.spec.ts +++ b/tests/smoke/ai-chat-history.spec.ts @@ -100,7 +100,7 @@ test.describe('AI chat conversation history', () => { await expect(restoredMessage).toContainText('Keep this thread alive') await expect(restoredMessage).toContainText('[mock-claude code]') - await page.keyboard.press('Tab') + await page.getByTitle('New AI chat').focus() await expect(page.getByTitle('New AI chat')).toBeFocused() await page.keyboard.press('Enter') await expect(page.getByTestId('ai-message')).toHaveCount(0) diff --git a/tests/smoke/mcp-config-copy.spec.ts b/tests/smoke/mcp-config-copy.spec.ts new file mode 100644 index 00000000..61a32366 --- /dev/null +++ b/tests/smoke/mcp-config-copy.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +test.describe('MCP config copy', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 })) + await page.goto('/') + await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 }) + }) + + test('copies the active-vault MCP config from the AI panel using only the keyboard', async ({ context, page }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + await page.locator('.app__note-list .cursor-pointer').first().click() + await page.locator('.bn-editor').click() + await sendShortcut(page, 'L', ['Meta', 'Shift']) + await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 }) + + await page.getByTestId('agent-input').focus() + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Shift+Tab') + const copyButton = page.getByRole('button', { name: 'Copy MCP config' }) + await expect(copyButton).toBeFocused() + await copyButton.press('Enter') + + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mcpServers"') + const copiedConfig = await page.evaluate(() => navigator.clipboard.readText()) + const parsedConfig = JSON.parse(copiedConfig) as { + mcpServers: { + tolaria: { + args: string[] + command: string + env: Record + type: string + } + } + } + const tolariaServer = parsedConfig.mcpServers.tolaria + + expect(tolariaServer.type).toBe('stdio') + expect(tolariaServer.command).toBe('node') + expect(tolariaServer.args[0]).toContain('mcp-server/index.js') + expect(tolariaServer.env.VAULT_PATH).toBeTruthy() + expect(tolariaServer.env.WS_UI_PORT).toBe('9711') + }) +}) diff --git a/ui-design.pen b/ui-design.pen index 2457aa28..f16cdd32 100644 --- a/ui-design.pen +++ b/ui-design.pen @@ -14098,6 +14098,113 @@ ] } ] + }, + { + "type": "frame", + "id": "mcp_config_copy_design", + "x": 0, + "y": 12580, + "name": "MCP Config Copy — Light", + "theme": { + "Mode": "Light" + }, + "width": 1440, + "fill": "$--background", + "layout": "vertical", + "gap": 20, + "padding": 40, + "children": [ + { + "type": "text", + "id": "mcp_config_copy_title", + "fill": "$--foreground", + "content": "MCP Config Copy", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700", + "letterSpacing": 0 + }, + { + "type": "text", + "id": "mcp_config_copy_description", + "fill": "$--muted-foreground", + "content": "AI panel exposes a compact Copy MCP config action; setup dialog shows the exact generated JSON with a keyboard-focusable code block and copy button.", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal", + "letterSpacing": 0 + }, + { + "type": "frame", + "id": "mcp_config_copy_ai_header", + "name": "AI panel header action", + "width": 520, + "height": 52, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "mcp_config_copy_ai_label", + "fill": "$--muted-foreground", + "content": "AI Agent · Claude Code", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600", + "letterSpacing": 0 + }, + { + "type": "rectangle", + "id": "mcp_config_copy_icon_button", + "name": "Copy MCP config icon button", + "x": 440, + "y": 14, + "width": 24, + "height": 24, + "cornerRadius": 6, + "fill": "$--muted" + } + ] + }, + { + "type": "frame", + "id": "mcp_config_copy_manual_block", + "name": "Manual MCP config block", + "width": 520, + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "mcp_config_copy_manual_title", + "fill": "$--foreground", + "content": "Manual MCP config", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600", + "letterSpacing": 0 + }, + { + "type": "rectangle", + "id": "mcp_config_copy_code_block", + "width": 520, + "height": 148, + "cornerRadius": 8, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + } + } + ] + } + ] } ], "themes": {