diff --git a/apps/desktop/src/main/handlers/localServerHandlers.ts b/apps/desktop/src/main/handlers/localServerHandlers.ts index c4dbbfbb..50d3c21c 100644 --- a/apps/desktop/src/main/handlers/localServerHandlers.ts +++ b/apps/desktop/src/main/handlers/localServerHandlers.ts @@ -5,6 +5,7 @@ * to the renderer (settings UI). */ +import { dirname } from 'path'; import { app } from 'electron'; import { z } from 'zod'; import { createNoteId, createNoteOperation, updateNoteOperation } from '@dripnex/core'; @@ -13,6 +14,8 @@ import { getOrCreateApiToken, type LocalServerHandlers, } from '../services/localServer.js'; +import { resolveMcpLaunch } from '../services/mcpLaunch.js'; +import { writeMcpWritesConfig } from '../services/mcpWrites.js'; import { defineIpcHandler } from '../ipc/registry.js'; import type { SQLiteNoteRepository, DataPaths } from './types.js'; @@ -184,6 +187,47 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void } }, }); + + defineIpcHandler({ + channel: 'localServer:connectionInfo', + args: z.tuple([]), + handler: async () => { + try { + if (!apiToken) { + apiToken = await getOrCreateApiToken(dataPaths.root); + } + const launch = resolveMcpLaunch(); + const port = server.getPort(); + return { + ok: true, + running: server.isRunning(), + port, + url: `http://127.0.0.1:${port}`, + token: apiToken, + dbPath: dataPaths.database, + mcpCommand: launch?.command ?? null, + mcpArgs: launch?.args ?? null, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + }); + + defineIpcHandler({ + channel: 'localServer:setWrites', + args: z.tuple([z.boolean()]), + handler: async writes => { + try { + const override = process.env.DRIPNEX_DB_PATH; + const dir = override ? dirname(override) : dataPaths.root; + await writeMcpWritesConfig(dir, writes); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + }); } /** diff --git a/apps/desktop/src/main/services/mcpLaunch.ts b/apps/desktop/src/main/services/mcpLaunch.ts new file mode 100644 index 00000000..9eea433c --- /dev/null +++ b/apps/desktop/src/main/services/mcpLaunch.ts @@ -0,0 +1,37 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; + +export interface McpLaunchSpec { + command: string; + args: string[]; +} + +/** + * Find a runnable @dripnex/mcp-server entry on this machine. + * Packaged builds typically return null — the UI then shows a fallback path. + */ +export function resolveMcpLaunch(): McpLaunchSpec | null { + const roots = [app.getAppPath(), process.cwd()]; + const distRels = [ + 'packages/mcp-server/dist/index.js', + '../packages/mcp-server/dist/index.js', + '../../packages/mcp-server/dist/index.js', + '../../../packages/mcp-server/dist/index.js', + ]; + const srcRels = distRels.map(rel => rel.replace('dist/index.js', 'src/index.ts')); + + for (const root of roots) { + for (const rel of distRels) { + const candidate = join(root, rel); + if (existsSync(candidate)) return { command: 'node', args: [candidate] }; + } + } + for (const root of roots) { + for (const rel of srcRels) { + const candidate = join(root, rel); + if (existsSync(candidate)) return { command: 'npx', args: ['-y', 'tsx', candidate] }; + } + } + return null; +} diff --git a/apps/desktop/src/main/services/mcpWrites.ts b/apps/desktop/src/main/services/mcpWrites.ts new file mode 100644 index 00000000..b9ebc7ef --- /dev/null +++ b/apps/desktop/src/main/services/mcpWrites.ts @@ -0,0 +1,14 @@ +import { rename, writeFile } from 'fs/promises'; +import { join } from 'path'; + +export const MCP_WRITES_FILE = 'mcp.json'; + +export async function writeMcpWritesConfig(dataRoot: string, writes: boolean): Promise { + const dest = join(dataRoot, MCP_WRITES_FILE); + const tmp = `${dest}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ writes }, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600, + }); + await rename(tmp, dest); +} diff --git a/apps/desktop/src/preload/api/index.ts b/apps/desktop/src/preload/api/index.ts index 932788d6..6c92fc9e 100644 --- a/apps/desktop/src/preload/api/index.ts +++ b/apps/desktop/src/preload/api/index.ts @@ -60,7 +60,7 @@ export type { } from './app'; export { createLocalServerApi } from './localServer'; -export type { LocalServerAPI } from './localServer'; +export type { LocalServerAPI, LocalServerConnectionInfo } from './localServer'; export { createIntegrationsApi } from './integrations'; export type { diff --git a/apps/desktop/src/preload/api/localServer.ts b/apps/desktop/src/preload/api/localServer.ts index 215ad95e..e46fdc2d 100644 --- a/apps/desktop/src/preload/api/localServer.ts +++ b/apps/desktop/src/preload/api/localServer.ts @@ -6,11 +6,23 @@ import { ipcRenderer } from 'electron'; +export interface LocalServerConnectionInfo { + running: boolean; + port: number; + url: string; + token: string; + dbPath: string; + mcpCommand: string | null; + mcpArgs: string[] | null; +} + export interface LocalServerAPI { start: (port?: number) => Promise<{ ok: boolean; port?: number; error?: string }>; stop: () => Promise<{ ok: boolean }>; status: () => Promise<{ running: boolean; port: number }>; getToken: () => Promise; + connectionInfo: () => Promise; + setWrites: (writes: boolean) => Promise<{ ok: boolean; error?: string }>; } export function createLocalServerApi(): LocalServerAPI { @@ -23,5 +35,19 @@ export function createLocalServerApi(): LocalServerAPI { if (!result.ok) throw new Error(result.error ?? 'Failed to get token'); return result.value as string; }, + connectionInfo: async () => { + const result = await ipcRenderer.invoke('localServer:connectionInfo'); + if (!result.ok) throw new Error(result.error ?? 'Failed to load MCP connection'); + return { + running: result.running as boolean, + port: result.port as number, + url: result.url as string, + token: result.token as string, + dbPath: result.dbPath as string, + mcpCommand: (result.mcpCommand as string | null) ?? null, + mcpArgs: (result.mcpArgs as string[] | null) ?? null, + }; + }, + setWrites: writes => ipcRenderer.invoke('localServer:setWrites', writes), }; } diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 7494b51c..4caee1a0 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -38,7 +38,6 @@ import { useAppearanceSettings } from './hooks/useAppearanceSettings'; import { useOfficialThemes } from './hooks/useOfficialThemes'; import { useResizableLayout } from './hooks/useResizableLayout'; import { useSyncStore } from './stores/syncStore'; - import { useDeepLinks } from './hooks/useDeepLinks'; import { useAutoSave } from './hooks/useAutoSave'; import { useNoteActions } from './hooks/useNoteActions'; @@ -46,6 +45,7 @@ import { useAppCommands } from './hooks/useAppCommands'; import { useEnsureNowBoard } from './hooks/useNowBoard'; import { useRefreshOnWindowFocus } from './hooks/useRefreshOnWindowFocus'; import { usePluginRuntime } from './hooks/usePluginRuntime'; +import { useMcpLocalPath } from './hooks/useMcpLocalPath'; /** * Main Notes Application @@ -56,6 +56,7 @@ function NotesApp() { useOfficialThemes(); useEnsureNowBoard(); useRefreshOnWindowFocus(); + useMcpLocalPath(); useThemeOverrides(); // Applies active theme tokens useCssVariables(); diff --git a/apps/desktop/src/renderer/hooks/useMcpLocalPath.ts b/apps/desktop/src/renderer/hooks/useMcpLocalPath.ts new file mode 100644 index 00000000..b62edb05 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useMcpLocalPath.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { useSettingsStore } from '../stores/settings'; + +function useSettingsHydrated(): boolean { + const [hydrated, setHydrated] = useState(() => { + const persist = (useSettingsStore as { persist?: { hasHydrated: () => boolean } }).persist; + return persist?.hasHydrated() ?? true; + }); + + useEffect(() => { + const persist = ( + useSettingsStore as { + persist?: { + hasHydrated: () => boolean; + onFinishHydration: (fn: () => void) => () => void; + }; + } + ).persist; + if (!persist) { + setHydrated(true); + return; + } + if (persist.hasHydrated()) { + setHydrated(true); + return; + } + return persist.onFinishHydration(() => setHydrated(true)); + }, []); + + return hydrated; +} + +/** + * Start/stop the local HTTP path and persist the MCP writes sidecar + * whenever Integrations settings change. Lives in the main window so a + * Settings toggle still takes effect after the settings window closes. + */ +export function useMcpLocalPath(): void { + const hydrated = useSettingsHydrated(); + const enabled = useSettingsStore(s => s.settings.integrations?.mcpEnabled ?? false); + const writes = useSettingsStore(s => s.settings.integrations?.mcpWrites ?? false); + + useEffect(() => { + if (!hydrated) return; + const api = window.dripnex?.localServer; + if (!api?.setWrites) return; + + let cancelled = false; + void (async () => { + const result = await api.setWrites(writes); + if (cancelled || !result.ok) return; + if (enabled) await api.start(); + else await api.stop(); + })(); + + return () => { + cancelled = true; + }; + }, [hydrated, enabled, writes]); +} diff --git a/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.module.css b/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.module.css index 19c81cc8..6a45f6e0 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.module.css +++ b/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.module.css @@ -62,6 +62,10 @@ filter: invert(1); } +.brandMark svg { + color: #fff; +} + .cardTop { display: flex; align-items: flex-start; @@ -285,3 +289,62 @@ font-size: 11px; color: var(--text-muted); } + +.copyRow { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.monoValue { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.snippet { + display: flex; + flex-direction: column; + gap: 6px; +} + +.snippetBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.snippetPre { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-all; +} + +.writesRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--border-subtle); +} diff --git a/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.tsx index 9a458847..1bf3f59d 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.tsx @@ -4,6 +4,7 @@ import { OnePasswordMark } from '../../../integrations/OnePasswordMark'; import { discoverOnePassword, setOnePasswordAccount } from '../../../integrations/onepassword'; import { Button } from '../../../ui/primitives'; import { GitHubCard } from './GitHubCard'; +import { McpCard } from './McpCard'; import styles from './IntegrationsSection.module.css'; interface IntegrationsSectionProps { @@ -69,6 +70,8 @@ export function IntegrationsSection({ onOpenEncryption }: IntegrationsSectionPro

Connect tools you already use. Secrets stay on this machine.

+ +
diff --git a/apps/desktop/src/renderer/pages/settings/sections/McpCard.tsx b/apps/desktop/src/renderer/pages/settings/sections/McpCard.tsx new file mode 100644 index 00000000..3637648c --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/sections/McpCard.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Bot, Check, Copy, Eye, EyeOff } from 'lucide-react'; +import { Button, Field, Toggle } from '../../../ui/primitives'; +import { useSettingsStore, selectIntegrations } from '../../../stores/settings'; +import { + buildClaudeSnippet, + buildCodexSnippet, + launchFromConnection, +} from '../../../utils/mcpSnippets'; +import type { LocalServerConnectionInfo } from '../../../../preload/api/localServer'; +import styles from './IntegrationsSection.module.css'; + +const START_POLL_MS = 750; +const MAX_START_POLLS = 20; + +async function copyText(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } +} + +export function McpCard() { + const integrations = useSettingsStore(selectIntegrations); + const updateIntegrations = useSettingsStore(s => s.updateIntegrations); + const api = window.dripnex?.localServer; + const ready = typeof api?.connectionInfo === 'function'; + + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + const [showToken, setShowToken] = useState(false); + const [copied, setCopied] = useState(null); + + const refresh = useCallback(async () => { + if (!api?.connectionInfo) return; + try { + setInfo(await api.connectionInfo()); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not load MCP connection.'); + } + }, [api]); + + useEffect(() => { + if (!ready) return; + void refresh(); + }, [ready, refresh, integrations.mcpEnabled]); + + useEffect(() => { + if (!ready || !integrations.mcpEnabled || info?.running) return; + let attempts = 0; + const id = window.setInterval(() => { + attempts += 1; + if (attempts > MAX_START_POLLS) { + window.clearInterval(id); + setError('The local MCP server did not start.'); + return; + } + void refresh(); + }, START_POLL_MS); + return () => window.clearInterval(id); + }, [ready, refresh, integrations.mcpEnabled, info?.running]); + + useEffect(() => { + if (!copied) return; + const id = window.setTimeout(() => setCopied(null), 1600); + return () => window.clearTimeout(id); + }, [copied]); + + const launch = info + ? launchFromConnection({ + dbPath: info.dbPath, + mcpCommand: info.mcpCommand, + mcpArgs: info.mcpArgs, + }) + : null; + const claude = launch ? buildClaudeSnippet(launch) : ''; + const codex = launch ? buildCodexSnippet(launch) : ''; + + const copy = async (key: string, value: string) => { + if (await copyText(value)) setCopied(key); + }; + + const enabled = integrations.mcpEnabled; + const badge = !ready ? 'Restart Dripnex' : enabled ? (info?.running ? 'On' : 'Starting') : 'Off'; + const badgeTone = !ready ? 'warn' : enabled && info?.running ? 'ok' : 'idle'; + + return ( +
+
+ +
+
+

MCP

+ + {badge} + +
+

+ Let Claude Code and Codex search your notes. This starts the local HTTP API on this + machine. +

+
+ updateIntegrations({ mcpEnabled: checked })} + /> +
+ + {!ready ? ( +

+ This window opened before the MCP bridge loaded. Quit Dripnex completely and open it again + — Settings does not pick up preload changes on refresh. +

+ ) : null} + + {ready && enabled && info ? ( +
+ +
+ + {info.url} + + void copy('url', info.url)} + /> +
+
+ + +
+ + {showToken ? info.token : '•'.repeat(12) + info.token.slice(-4)} + + + void copy('token', info.token)} + /> +
+
+ + void copy('claude', claude)} + /> + void copy('codex', codex)} + /> + +
+
+ +

+ Create, update, and trash from agents. Off until you flip this — no need to recopy + the snippet. +

+
+ updateIntegrations({ mcpWrites: checked })} + /> +
+
+ ) : null} + + {error ?

{error}

: null} +
+ ); +} + +function CopyButton({ + label, + copied, + onClick, +}: { + label: string; + copied: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function SnippetBlock({ + id, + label, + value, + copied, + onCopy, +}: { + id: string; + label: string; + value: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+ {label} + +
+
+        {value}
+      
+
+ ); +} diff --git a/apps/desktop/src/renderer/stores/settings/__tests__/settingsStore.test.ts b/apps/desktop/src/renderer/stores/settings/__tests__/settingsStore.test.ts index e7dda9e4..3e82b764 100644 --- a/apps/desktop/src/renderer/stores/settings/__tests__/settingsStore.test.ts +++ b/apps/desktop/src/renderer/stores/settings/__tests__/settingsStore.test.ts @@ -22,4 +22,14 @@ describe('settingsStore persistence', () => { expect(partial.settings.ai.apiKey).toBe(''); expect(JSON.stringify(partial)).not.toContain('sk-secret'); }); + + it('defaults MCP off and keeps integrations through persist', () => { + expect(DEFAULT_SETTINGS.integrations).toEqual({ + mcpEnabled: false, + mcpWrites: false, + }); + const partial = partializeSettings(useSettingsStore.getState()); + expect(partial.settings.integrations.mcpEnabled).toBe(false); + expect(partial.settings.integrations.mcpWrites).toBe(false); + }); }); diff --git a/apps/desktop/src/renderer/stores/settings/schema.ts b/apps/desktop/src/renderer/stores/settings/schema.ts index b860d23f..e27d1fe8 100644 --- a/apps/desktop/src/renderer/stores/settings/schema.ts +++ b/apps/desktop/src/renderer/stores/settings/schema.ts @@ -16,7 +16,7 @@ import { DEFAULT_MODEL } from '@dripnex/ai-core'; // Version // ============================================================================ -export const SETTINGS_VERSION = 5; +export const SETTINGS_VERSION = 6; // ============================================================================ // Section Types @@ -82,6 +82,14 @@ export interface AiSettings { embedModel: string; } +/** Local integrations (MCP / local HTTP). */ +export interface IntegrationsSettings { + /** Start the local HTTP API and show MCP connection snippets. */ + mcpEnabled: boolean; + /** Allow MCP create/update/trash. Written to mcp.json next to the DB. */ + mcpWrites: boolean; +} + /** Editor settings for CodeMirror */ export interface EditorSettings { /** Font size in pixels */ @@ -138,8 +146,13 @@ export interface SettingsSchemaV5 extends Omit { version: 5; } +export interface SettingsSchemaV6 extends Omit { + version: 6; + integrations: IntegrationsSettings; +} + /** Current settings schema type */ -export type SettingsSchema = SettingsSchemaV5; +export type SettingsSchema = SettingsSchemaV6; /** Section keys (excluding version) */ export type SettingsSection = keyof Omit; @@ -197,13 +210,19 @@ export const DEFAULT_BACKUP: BackupSettings = { lastBackupAt: null, }; +export const DEFAULT_INTEGRATIONS: IntegrationsSettings = { + mcpEnabled: false, + mcpWrites: false, +}; + /** Complete default settings */ export const DEFAULT_SETTINGS: SettingsSchema = { - version: 5, + version: 6, general: DEFAULT_GENERAL, updates: DEFAULT_UPDATES, appearance: DEFAULT_APPEARANCE, ai: DEFAULT_AI, editor: DEFAULT_EDITOR, backup: DEFAULT_BACKUP, + integrations: DEFAULT_INTEGRATIONS, }; diff --git a/apps/desktop/src/renderer/stores/settings/settingsStore.ts b/apps/desktop/src/renderer/stores/settings/settingsStore.ts index 4b450418..2bb0e8af 100644 --- a/apps/desktop/src/renderer/stores/settings/settingsStore.ts +++ b/apps/desktop/src/renderer/stores/settings/settingsStore.ts @@ -16,6 +16,7 @@ import { AiSettings, EditorSettings, BackupSettings, + IntegrationsSettings, DEFAULT_SETTINGS, DEFAULT_GENERAL, DEFAULT_UPDATES, @@ -23,6 +24,7 @@ import { DEFAULT_AI, DEFAULT_EDITOR, DEFAULT_BACKUP, + DEFAULT_INTEGRATIONS, SETTINGS_VERSION, } from './schema'; @@ -43,6 +45,7 @@ interface SettingsStore { updateAi: (updates: Partial) => void; updateEditor: (updates: Partial) => void; updateBackup: (updates: Partial) => void; + updateIntegrations: (updates: Partial) => void; // Reset actions resetSection: (section: SettingsSection) => void; @@ -131,6 +134,15 @@ function migrateSettings(persisted: unknown, version: number): { settings: Setti }; } + // Migration: v5 -> v6 (MCP / local HTTP in Integrations) + if (version < 6) { + mutable = { + ...mutable, + version: 6, + integrations: { ...DEFAULT_INTEGRATIONS, ...mutable.integrations }, + }; + } + settings = mutable as SettingsSchema; return { settings }; } @@ -209,6 +221,18 @@ export const useSettingsStore = create()( }, })), + updateIntegrations: updates => + set(state => ({ + settings: { + ...state.settings, + integrations: { + ...DEFAULT_INTEGRATIONS, + ...state.settings.integrations, + ...updates, + }, + }, + })), + // Reset a specific section to defaults resetSection: section => set(state => { @@ -219,6 +243,7 @@ export const useSettingsStore = create()( ai: DEFAULT_AI, editor: DEFAULT_EDITOR, backup: DEFAULT_BACKUP, + integrations: DEFAULT_INTEGRATIONS, }; return { settings: { @@ -256,6 +281,7 @@ export const selectAi = (state: SettingsStore) => state.settings.ai; export const selectAiKeyHydrationError = (state: SettingsStore) => state.aiKeyHydrationError; export const selectEditor = (state: SettingsStore) => state.settings.editor; export const selectBackup = (state: SettingsStore) => state.settings.backup; +export const selectIntegrations = (state: SettingsStore) => state.settings.integrations; // Individual editor settings selectors (for CodeMirror integration) export const selectFontSize = (state: SettingsStore) => state.settings.editor.fontSize; @@ -369,6 +395,7 @@ if (typeof window !== 'undefined' && window.dripnex?.settings) { ai: { ...DEFAULT_AI, ...s.ai, apiKey: preservedApiKey }, editor: { ...DEFAULT_EDITOR, ...s.editor }, backup: { ...DEFAULT_BACKUP, ...s.backup }, + integrations: { ...DEFAULT_INTEGRATIONS, ...s.integrations }, }; isRemoteUpdate = true; diff --git a/apps/desktop/src/renderer/utils/__tests__/mcpSnippets.test.ts b/apps/desktop/src/renderer/utils/__tests__/mcpSnippets.test.ts new file mode 100644 index 00000000..07d8a929 --- /dev/null +++ b/apps/desktop/src/renderer/utils/__tests__/mcpSnippets.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + FALLBACK_MCP_ENTRY, + buildClaudeSnippet, + buildCodexSnippet, + launchFromConnection, + shellQuote, +} from '../mcpSnippets'; + +const launch = { + command: 'npx', + args: ['-y', 'tsx', '/tmp/mcp/index.ts'], + dbPath: '/tmp/dripnex.db', +}; + +describe('mcpSnippets', () => { + it('quotes only values that need a shell quote', () => { + expect(shellQuote('/tmp/dripnex.db')).toBe('/tmp/dripnex.db'); + expect(shellQuote('/Users/me/My Notes/dripnex.db')).toBe("'/Users/me/My Notes/dripnex.db'"); + expect(shellQuote('/Users/Tomás/Library/dripnex.db')).toBe("'/Users/Tomás/Library/dripnex.db'"); + expect(shellQuote("it's")).toBe(`'it'\\''s'`); + }); + + it('builds a Claude Code add command', () => { + expect(buildClaudeSnippet(launch)).toBe( + 'claude mcp add dripnex --env DRIPNEX_DB_PATH=/tmp/dripnex.db -- npx -y tsx /tmp/mcp/index.ts' + ); + }); + + it('builds a Codex config.toml block', () => { + expect(buildCodexSnippet(launch)).toBe( + [ + '[mcp_servers.dripnex]', + 'command = "npx"', + 'args = ["-y", "tsx", "/tmp/mcp/index.ts"]', + 'env = { DRIPNEX_DB_PATH = "/tmp/dripnex.db" }', + ].join('\n') + ); + }); + + it('falls back to a documented path when the app cannot see mcp-server', () => { + const fallback = launchFromConnection({ + dbPath: '/tmp/dripnex.db', + mcpCommand: null, + mcpArgs: null, + }); + expect(fallback.args.at(-1)).toBe(FALLBACK_MCP_ENTRY); + expect( + launchFromConnection({ + dbPath: '/tmp/dripnex.db', + mcpCommand: 'node', + mcpArgs: ['/built/index.js'], + }) + ).toEqual({ + command: 'node', + args: ['/built/index.js'], + dbPath: '/tmp/dripnex.db', + }); + }); +}); diff --git a/apps/desktop/src/renderer/utils/mcpSnippets.ts b/apps/desktop/src/renderer/utils/mcpSnippets.ts new file mode 100644 index 00000000..32e3dd1a --- /dev/null +++ b/apps/desktop/src/renderer/utils/mcpSnippets.ts @@ -0,0 +1,40 @@ +export interface McpLaunch { + command: string; + args: string[]; + dbPath: string; +} + +export function shellQuote(value: string): string { + if (value.length === 0) return "''"; + if (/^[\w./:@%+=,-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildClaudeSnippet(launch: McpLaunch): string { + const env = `--env DRIPNEX_DB_PATH=${shellQuote(launch.dbPath)}`; + const command = [launch.command, ...launch.args].map(shellQuote).join(' '); + return `claude mcp add dripnex ${env} -- ${command}`; +} + +export function buildCodexSnippet(launch: McpLaunch): string { + const args = launch.args.map(value => JSON.stringify(value)).join(', '); + return [ + '[mcp_servers.dripnex]', + `command = ${JSON.stringify(launch.command)}`, + `args = [${args}]`, + `env = { DRIPNEX_DB_PATH = ${JSON.stringify(launch.dbPath)} }`, + ].join('\n'); +} + +export const FALLBACK_MCP_ENTRY = '/ABS/PATH/dripnex/packages/mcp-server/src/index.ts'; + +export function launchFromConnection(info: { + dbPath: string; + mcpCommand: string | null; + mcpArgs: string[] | null; +}): McpLaunch { + if (info.mcpCommand && info.mcpArgs) { + return { command: info.mcpCommand, args: info.mcpArgs, dbPath: info.dbPath }; + } + return { command: 'npx', args: ['-y', 'tsx', FALLBACK_MCP_ENTRY], dbPath: info.dbPath }; +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index bbb94802..31535475 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -34,9 +34,10 @@ Requires Node ≥ 22.5. Read path (the product): `dripnex_search_notes`, `dripnex_read_note`, `dripnex_list_notes`, `dripnex_list_notebooks`, `dripnex_list_tags`. -Writes (`create` / `update` / `trash`) are **off by default**. Set -`DRIPNEX_MCP_WRITES=1` to enable them. After a write the server touches -`dripnex.external-write` next to the DB so the desktop can refetch. +Writes (`create` / `update` / `trash`) are **off by default**. Enable them +in **Settings → Integrations → Allow writes** (writes `mcp.json` next to +the DB). `DRIPNEX_MCP_WRITES=1` still overrides. After a write the server +touches `dripnex.external-write` next to the DB so the desktop can refetch. If you pass a notebook name that does not exist, create fails instead of silently landing in Inbox. Titles use the first non-empty line (same rule diff --git a/packages/mcp-server/src/__tests__/writes.test.ts b/packages/mcp-server/src/__tests__/writes.test.ts new file mode 100644 index 00000000..a6bb8b07 --- /dev/null +++ b/packages/mcp-server/src/__tests__/writes.test.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { MCP_WRITES_FILE, writesEnabled } from '../writes'; + +describe('writesEnabled', () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function dbInTmp(writes?: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'dripnex-mcp-writes-')); + dirs.push(dir); + if (writes !== undefined) { + writeFileSync(join(dir, MCP_WRITES_FILE), JSON.stringify({ writes })); + } + return join(dir, 'dripnex.db'); + } + + it('defaults to off with no env and no sidecar', () => { + expect(writesEnabled(dbInTmp(), {})).toBe(false); + expect(writesEnabled(undefined, {})).toBe(false); + }); + + it('reads the sidecar next to the database', () => { + expect(writesEnabled(dbInTmp(true), {})).toBe(true); + expect(writesEnabled(dbInTmp(false), {})).toBe(false); + }); + + it('lets the env override the sidecar', () => { + const dbPath = dbInTmp(false); + expect(writesEnabled(dbPath, { DRIPNEX_MCP_WRITES: '1' })).toBe(true); + expect(writesEnabled(dbInTmp(true), { DRIPNEX_MCP_WRITES: '0' })).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 6748f25d..7c4e7003 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -13,7 +13,8 @@ * - dripnex_list_notebooks: List all notebooks * - dripnex_list_tags: List tags with note counts * - * Writes (create/update/trash) are off unless DRIPNEX_MCP_WRITES=1. + * Writes (create/update/trash) are off unless Settings → Integrations + * enables them (mcp.json next to the DB) or DRIPNEX_MCP_WRITES=1. * Writes go through @dripnex/core operations + NoteRepository. * The desktop refetches on focus / dripnex.external-write. */ @@ -31,6 +32,7 @@ import type { Database } from './db.js'; import { openDb, resolveDbPath } from './db.js'; import { markExternalWrite, packageDirFromModuleUrl, readPackageVersion } from './notes.js'; import { NodeSqliteNoteRepository } from './sqliteRepo.js'; +import { writesDisabledMessage, writesEnabled } from './writes.js'; function query(db: Database, sql: string, params: unknown[] = []): Record[] { return db.prepare(sql).all(...(params as never[])) as Record[]; @@ -52,10 +54,6 @@ function prepareFtsQuery(input: string): string { return terms.map(t => `"${t}"*`).join(' OR '); } -function writesEnabled(): boolean { - return process.env.DRIPNEX_MCP_WRITES === '1'; -} - function createServer(db: Database, options: { dbPath?: string } = {}) { const version = readPackageVersion(packageDirFromModuleUrl(import.meta.url)); const server = new McpServer({ @@ -66,7 +64,11 @@ function createServer(db: Database, options: { dbPath?: string } = {}) { const afterWrite = () => { if (options.dbPath) markExternalWrite(options.dbPath); }; - const allowWrites = writesEnabled(); + const denyWrites = () => ({ + content: [{ type: 'text' as const, text: writesDisabledMessage() }], + isError: true, + }); + const allowWrites = () => writesEnabled(options.dbPath); // ── List notes ────────────────────────────────────────────────────────── @@ -172,80 +174,82 @@ function createServer(db: Database, options: { dbPath?: string } = {}) { } ); - if (allowWrites) { - // ── Create note ───────────────────────────────────────────────────────── - - server.registerTool( - 'dripnex_create_note', - { - description: 'Create a new note in Dripnex. Content should be markdown.', - inputSchema: { - content: z.string().describe('Markdown content for the note'), - notebook: z.string().optional().describe('Notebook name (defaults to Inbox)'), - }, + // ── Create note ───────────────────────────────────────────────────────── + + server.registerTool( + 'dripnex_create_note', + { + description: 'Create a new note in Dripnex. Content should be markdown.', + inputSchema: { + content: z.string().describe('Markdown content for the note'), + notebook: z.string().optional().describe('Notebook name (defaults to Inbox)'), }, - async ({ content, notebook }) => { - let notebookId: string | undefined; - if (notebook) { - const id = notes.findNotebookIdByName(notebook); - if (!id) { - return { - content: [ - { - type: 'text' as const, - text: `Notebook "${notebook}" not found. Note was not created.`, - }, - ], - isError: true, - }; - } - notebookId = id; - } + }, + async ({ content, notebook }) => { + if (!allowWrites()) return denyWrites(); - const result = await createNoteOperation({ content, notebookId }, notes); - if (!result.ok) { + let notebookId: string | undefined; + if (notebook) { + const id = notes.findNotebookIdByName(notebook); + if (!id) { return { - content: [{ type: 'text' as const, text: 'Failed to create note.' }], + content: [ + { + type: 'text' as const, + text: `Notebook "${notebook}" not found. Note was not created.`, + }, + ], isError: true, }; } - afterWrite(); + notebookId = id; + } + const result = await createNoteOperation({ content, notebookId }, notes); + if (!result.ok) { return { - content: [ - { - type: 'text' as const, - text: `Note created: "${result.data.title}" (ID: ${result.data.id})`, - }, - ], + content: [{ type: 'text' as const, text: 'Failed to create note.' }], + isError: true, }; } - ); - - // ── Update note ───────────────────────────────────────────────────────── - - server.registerTool( - 'dripnex_update_note', - { - description: 'Update an existing note. Replaces the full content.', - inputSchema: { - id: z.string().describe('Note ID'), - content: z.string().describe('New markdown content'), - }, + afterWrite(); + + return { + content: [ + { + type: 'text' as const, + text: `Note created: "${result.data.title}" (ID: ${result.data.id})`, + }, + ], + }; + } + ); + + // ── Update note ───────────────────────────────────────────────────────── + + server.registerTool( + 'dripnex_update_note', + { + description: 'Update an existing note. Replaces the full content.', + inputSchema: { + id: z.string().describe('Note ID'), + content: z.string().describe('New markdown content'), }, - async ({ id, content }) => { - const result = await updateNoteOperation({ id: createNoteId(id), content }, notes); - if (!result.ok) { - return { content: [{ type: 'text' as const, text: 'Note not found.' }] }; - } - afterWrite(); + }, + async ({ id, content }) => { + if (!allowWrites()) return denyWrites(); - return { - content: [{ type: 'text' as const, text: `Note updated: "${result.data.title}"` }], - }; + const result = await updateNoteOperation({ id: createNoteId(id), content }, notes); + if (!result.ok) { + return { content: [{ type: 'text' as const, text: 'Note not found.' }] }; } - ); - } + afterWrite(); + + return { + content: [{ type: 'text' as const, text: `Note updated: "${result.data.title}"` }], + }; + } + ); // ── Search notes (FTS5) ────────────────────────────────────────────────── @@ -341,26 +345,26 @@ function createServer(db: Database, options: { dbPath?: string } = {}) { } ); - if (allowWrites) { - server.registerTool( - 'dripnex_trash_note', - { - description: 'Move a note to trash (soft delete).', - inputSchema: { - id: z.string().describe('Note ID'), - }, + server.registerTool( + 'dripnex_trash_note', + { + description: 'Move a note to trash (soft delete).', + inputSchema: { + id: z.string().describe('Note ID'), }, - async ({ id }) => { - const result = await trashNoteOperation({ id: createNoteId(id) }, notes); - if (!result.ok) { - return { content: [{ type: 'text' as const, text: 'Note not found.' }] }; - } - afterWrite(); + }, + async ({ id }) => { + if (!allowWrites()) return denyWrites(); - return { content: [{ type: 'text' as const, text: 'Note moved to trash.' }] }; + const result = await trashNoteOperation({ id: createNoteId(id) }, notes); + if (!result.ok) { + return { content: [{ type: 'text' as const, text: 'Note not found.' }] }; } - ); - } + afterWrite(); + + return { content: [{ type: 'text' as const, text: 'Note moved to trash.' }] }; + } + ); return server; } diff --git a/packages/mcp-server/src/writes.ts b/packages/mcp-server/src/writes.ts new file mode 100644 index 00000000..8955cd7f --- /dev/null +++ b/packages/mcp-server/src/writes.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** Sidecar next to the DB. Desktop Settings writes this; env still wins. */ +export const MCP_WRITES_FILE = 'mcp.json'; + +export function writesEnabled(dbPath?: string, env: NodeJS.ProcessEnv = process.env): boolean { + if (env.DRIPNEX_MCP_WRITES === '1') return true; + if (env.DRIPNEX_MCP_WRITES === '0') return false; + if (!dbPath) return false; + try { + const raw = readFileSync(join(dirname(dbPath), MCP_WRITES_FILE), 'utf8'); + const parsed = JSON.parse(raw) as { writes?: unknown }; + return parsed.writes === true; + } catch { + return false; + } +} + +export function writesDisabledMessage(): string { + return 'Writes are off. Enable them in Dripnex → Settings → Integrations → Allow writes.'; +}