Skip to content

Commit 8b79c96

Browse files
authored
feat(desktop): add MCP setup in Integrations
Settings → Integrations starts the local HTTP path, shows URL + token, and copy-ready Claude Code / Codex snippets. Writes stay off until a second toggle. Closes the Inkdrop MCP-as-product-surface gap.
1 parent e0e1ee6 commit 8b79c96

19 files changed

Lines changed: 810 additions & 92 deletions

File tree

apps/desktop/src/main/handlers/localServerHandlers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* to the renderer (settings UI).
66
*/
77

8+
import { dirname } from 'path';
89
import { app } from 'electron';
910
import { z } from 'zod';
1011
import { createNoteId, createNoteOperation, updateNoteOperation } from '@dripnex/core';
@@ -13,6 +14,8 @@ import {
1314
getOrCreateApiToken,
1415
type LocalServerHandlers,
1516
} from '../services/localServer.js';
17+
import { resolveMcpLaunch } from '../services/mcpLaunch.js';
18+
import { writeMcpWritesConfig } from '../services/mcpWrites.js';
1619
import { defineIpcHandler } from '../ipc/registry.js';
1720
import type { SQLiteNoteRepository, DataPaths } from './types.js';
1821

@@ -184,6 +187,47 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
184187
}
185188
},
186189
});
190+
191+
defineIpcHandler({
192+
channel: 'localServer:connectionInfo',
193+
args: z.tuple([]),
194+
handler: async () => {
195+
try {
196+
if (!apiToken) {
197+
apiToken = await getOrCreateApiToken(dataPaths.root);
198+
}
199+
const launch = resolveMcpLaunch();
200+
const port = server.getPort();
201+
return {
202+
ok: true,
203+
running: server.isRunning(),
204+
port,
205+
url: `http://127.0.0.1:${port}`,
206+
token: apiToken,
207+
dbPath: dataPaths.database,
208+
mcpCommand: launch?.command ?? null,
209+
mcpArgs: launch?.args ?? null,
210+
};
211+
} catch (err) {
212+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
213+
}
214+
},
215+
});
216+
217+
defineIpcHandler({
218+
channel: 'localServer:setWrites',
219+
args: z.tuple([z.boolean()]),
220+
handler: async writes => {
221+
try {
222+
const override = process.env.DRIPNEX_DB_PATH;
223+
const dir = override ? dirname(override) : dataPaths.root;
224+
await writeMcpWritesConfig(dir, writes);
225+
return { ok: true };
226+
} catch (err) {
227+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
228+
}
229+
},
230+
});
187231
}
188232

189233
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { existsSync } from 'fs';
2+
import { join } from 'path';
3+
import { app } from 'electron';
4+
5+
export interface McpLaunchSpec {
6+
command: string;
7+
args: string[];
8+
}
9+
10+
/**
11+
* Find a runnable @dripnex/mcp-server entry on this machine.
12+
* Packaged builds typically return null — the UI then shows a fallback path.
13+
*/
14+
export function resolveMcpLaunch(): McpLaunchSpec | null {
15+
const roots = [app.getAppPath(), process.cwd()];
16+
const distRels = [
17+
'packages/mcp-server/dist/index.js',
18+
'../packages/mcp-server/dist/index.js',
19+
'../../packages/mcp-server/dist/index.js',
20+
'../../../packages/mcp-server/dist/index.js',
21+
];
22+
const srcRels = distRels.map(rel => rel.replace('dist/index.js', 'src/index.ts'));
23+
24+
for (const root of roots) {
25+
for (const rel of distRels) {
26+
const candidate = join(root, rel);
27+
if (existsSync(candidate)) return { command: 'node', args: [candidate] };
28+
}
29+
}
30+
for (const root of roots) {
31+
for (const rel of srcRels) {
32+
const candidate = join(root, rel);
33+
if (existsSync(candidate)) return { command: 'npx', args: ['-y', 'tsx', candidate] };
34+
}
35+
}
36+
return null;
37+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { rename, writeFile } from 'fs/promises';
2+
import { join } from 'path';
3+
4+
export const MCP_WRITES_FILE = 'mcp.json';
5+
6+
export async function writeMcpWritesConfig(dataRoot: string, writes: boolean): Promise<void> {
7+
const dest = join(dataRoot, MCP_WRITES_FILE);
8+
const tmp = `${dest}.${process.pid}.tmp`;
9+
await writeFile(tmp, `${JSON.stringify({ writes }, null, 2)}\n`, {
10+
encoding: 'utf-8',
11+
mode: 0o600,
12+
});
13+
await rename(tmp, dest);
14+
}

apps/desktop/src/preload/api/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export type {
6060
} from './app';
6161

6262
export { createLocalServerApi } from './localServer';
63-
export type { LocalServerAPI } from './localServer';
63+
export type { LocalServerAPI, LocalServerConnectionInfo } from './localServer';
6464

6565
export { createIntegrationsApi } from './integrations';
6666
export type {

apps/desktop/src/preload/api/localServer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,23 @@
66

77
import { ipcRenderer } from 'electron';
88

9+
export interface LocalServerConnectionInfo {
10+
running: boolean;
11+
port: number;
12+
url: string;
13+
token: string;
14+
dbPath: string;
15+
mcpCommand: string | null;
16+
mcpArgs: string[] | null;
17+
}
18+
919
export interface LocalServerAPI {
1020
start: (port?: number) => Promise<{ ok: boolean; port?: number; error?: string }>;
1121
stop: () => Promise<{ ok: boolean }>;
1222
status: () => Promise<{ running: boolean; port: number }>;
1323
getToken: () => Promise<string>;
24+
connectionInfo: () => Promise<LocalServerConnectionInfo>;
25+
setWrites: (writes: boolean) => Promise<{ ok: boolean; error?: string }>;
1426
}
1527

1628
export function createLocalServerApi(): LocalServerAPI {
@@ -23,5 +35,19 @@ export function createLocalServerApi(): LocalServerAPI {
2335
if (!result.ok) throw new Error(result.error ?? 'Failed to get token');
2436
return result.value as string;
2537
},
38+
connectionInfo: async () => {
39+
const result = await ipcRenderer.invoke('localServer:connectionInfo');
40+
if (!result.ok) throw new Error(result.error ?? 'Failed to load MCP connection');
41+
return {
42+
running: result.running as boolean,
43+
port: result.port as number,
44+
url: result.url as string,
45+
token: result.token as string,
46+
dbPath: result.dbPath as string,
47+
mcpCommand: (result.mcpCommand as string | null) ?? null,
48+
mcpArgs: (result.mcpArgs as string[] | null) ?? null,
49+
};
50+
},
51+
setWrites: writes => ipcRenderer.invoke('localServer:setWrites', writes),
2652
};
2753
}

apps/desktop/src/renderer/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ import { useAppearanceSettings } from './hooks/useAppearanceSettings';
3838
import { useOfficialThemes } from './hooks/useOfficialThemes';
3939
import { useResizableLayout } from './hooks/useResizableLayout';
4040
import { useSyncStore } from './stores/syncStore';
41-
4241
import { useDeepLinks } from './hooks/useDeepLinks';
4342
import { useAutoSave } from './hooks/useAutoSave';
4443
import { useNoteActions } from './hooks/useNoteActions';
4544
import { useAppCommands } from './hooks/useAppCommands';
4645
import { useEnsureNowBoard } from './hooks/useNowBoard';
4746
import { useRefreshOnWindowFocus } from './hooks/useRefreshOnWindowFocus';
4847
import { usePluginRuntime } from './hooks/usePluginRuntime';
48+
import { useMcpLocalPath } from './hooks/useMcpLocalPath';
4949

5050
/**
5151
* Main Notes Application
@@ -56,6 +56,7 @@ function NotesApp() {
5656
useOfficialThemes();
5757
useEnsureNowBoard();
5858
useRefreshOnWindowFocus();
59+
useMcpLocalPath();
5960
useThemeOverrides(); // Applies active theme tokens
6061
useCssVariables();
6162

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useEffect, useState } from 'react';
2+
import { useSettingsStore } from '../stores/settings';
3+
4+
function useSettingsHydrated(): boolean {
5+
const [hydrated, setHydrated] = useState(() => {
6+
const persist = (useSettingsStore as { persist?: { hasHydrated: () => boolean } }).persist;
7+
return persist?.hasHydrated() ?? true;
8+
});
9+
10+
useEffect(() => {
11+
const persist = (
12+
useSettingsStore as {
13+
persist?: {
14+
hasHydrated: () => boolean;
15+
onFinishHydration: (fn: () => void) => () => void;
16+
};
17+
}
18+
).persist;
19+
if (!persist) {
20+
setHydrated(true);
21+
return;
22+
}
23+
if (persist.hasHydrated()) {
24+
setHydrated(true);
25+
return;
26+
}
27+
return persist.onFinishHydration(() => setHydrated(true));
28+
}, []);
29+
30+
return hydrated;
31+
}
32+
33+
/**
34+
* Start/stop the local HTTP path and persist the MCP writes sidecar
35+
* whenever Integrations settings change. Lives in the main window so a
36+
* Settings toggle still takes effect after the settings window closes.
37+
*/
38+
export function useMcpLocalPath(): void {
39+
const hydrated = useSettingsHydrated();
40+
const enabled = useSettingsStore(s => s.settings.integrations?.mcpEnabled ?? false);
41+
const writes = useSettingsStore(s => s.settings.integrations?.mcpWrites ?? false);
42+
43+
useEffect(() => {
44+
if (!hydrated) return;
45+
const api = window.dripnex?.localServer;
46+
if (!api?.setWrites) return;
47+
48+
let cancelled = false;
49+
void (async () => {
50+
const result = await api.setWrites(writes);
51+
if (cancelled || !result.ok) return;
52+
if (enabled) await api.start();
53+
else await api.stop();
54+
})();
55+
56+
return () => {
57+
cancelled = true;
58+
};
59+
}, [hydrated, enabled, writes]);
60+
}

apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.module.css

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
filter: invert(1);
6363
}
6464

65+
.brandMark svg {
66+
color: #fff;
67+
}
68+
6569
.cardTop {
6670
display: flex;
6771
align-items: flex-start;
@@ -285,3 +289,62 @@
285289
font-size: 11px;
286290
color: var(--text-muted);
287291
}
292+
293+
.copyRow {
294+
display: flex;
295+
align-items: center;
296+
gap: var(--space-2);
297+
min-width: 0;
298+
}
299+
300+
.monoValue {
301+
min-width: 0;
302+
flex: 1;
303+
overflow: hidden;
304+
text-overflow: ellipsis;
305+
white-space: nowrap;
306+
padding: 8px 10px;
307+
border: 1px solid var(--border);
308+
border-radius: 8px;
309+
background: var(--bg-base);
310+
color: var(--text-primary);
311+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
312+
font-size: 12px;
313+
}
314+
315+
.snippet {
316+
display: flex;
317+
flex-direction: column;
318+
gap: 6px;
319+
}
320+
321+
.snippetBar {
322+
display: flex;
323+
align-items: center;
324+
justify-content: space-between;
325+
gap: var(--space-2);
326+
}
327+
328+
.snippetPre {
329+
margin: 0;
330+
padding: 10px 12px;
331+
overflow-x: auto;
332+
border: 1px solid var(--border);
333+
border-radius: 8px;
334+
background: var(--bg-base);
335+
color: var(--text-primary);
336+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
337+
font-size: 12px;
338+
line-height: 1.45;
339+
white-space: pre-wrap;
340+
word-break: break-all;
341+
}
342+
343+
.writesRow {
344+
display: flex;
345+
align-items: flex-start;
346+
justify-content: space-between;
347+
gap: var(--space-3);
348+
padding-top: var(--space-2);
349+
border-top: 1px solid var(--border-subtle);
350+
}

apps/desktop/src/renderer/pages/settings/sections/IntegrationsSection.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { OnePasswordMark } from '../../../integrations/OnePasswordMark';
44
import { discoverOnePassword, setOnePasswordAccount } from '../../../integrations/onepassword';
55
import { Button } from '../../../ui/primitives';
66
import { GitHubCard } from './GitHubCard';
7+
import { McpCard } from './McpCard';
78
import styles from './IntegrationsSection.module.css';
89

910
interface IntegrationsSectionProps {
@@ -69,6 +70,8 @@ export function IntegrationsSection({ onOpenEncryption }: IntegrationsSectionPro
6970
<p className={styles.lede}>Connect tools you already use. Secrets stay on this machine.</p>
7071
</header>
7172

73+
<McpCard />
74+
7275
<GitHubCard />
7376

7477
<article className={styles.card} data-tone={badgeTone}>

0 commit comments

Comments
 (0)