phaedo-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.mjs ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ // `phaedo-mcp` one-click installer — wires the Phaedo MCP server into the MCP
3
+ // clients you already have (Claude Desktop, Cursor, Claude Code), so injection
4
+ // "just works" without hand-editing JSON.
5
+ //
6
+ // What it does:
7
+ // 1. Find each MCP client's config file for this OS.
8
+ // 2. Merge a `phaedo` server entry into its `mcpServers` map (never clobbering
9
+ // your other servers or settings — the file is backed up first).
10
+ // 3. Point that entry at THIS checkout's server.js, run by THIS node binary
11
+ // (absolute path — a GUI-launched client often has no `node` on PATH).
12
+ // 4. Optionally run the one-time pairing (`--pair`) and print next steps.
13
+ //
14
+ // Pure Node builtins — runs before `npm install`. (Pairing/serving need the deps;
15
+ // the platform launchers in installers/ run `npm install` first.)
16
+ //
17
+ // Usage:
18
+ // node install.mjs configure every detected client
19
+ // node install.mjs --pair configure, then run the pairing flow
20
+ // node install.mjs --uninstall remove the phaedo entry from every client
21
+ // node install.mjs --dry-run show what would change, write nothing
22
+ // node install.mjs --only cursor restrict to one client id
23
+ // node install.mjs --help
24
+
25
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
26
+ import { dirname, join, resolve } from 'path';
27
+ import { homedir } from 'os';
28
+ import { fileURLToPath } from 'url';
29
+ import { spawnSync } from 'child_process';
30
+
31
+ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
32
+ const SERVER_PATH = join(BASE_DIR, 'server.js');
33
+ const HOOK_PATH = join(BASE_DIR, 'hooks', 'session-start.mjs');
34
+ const ENTRY_KEY = 'phaedo';
35
+ const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
36
+
37
+ // ── Client registry ──────────────────────────────────────────────────────────
38
+ // Every supported client stores MCP servers as a top-level `mcpServers` object,
39
+ // so one merge shape covers them all. `configPath` is resolved per-OS from an
40
+ // injectable env (so tests can drive every platform without touching real files).
41
+ export function detectClients({ platform = process.platform, home = homedir(), env = process.env } = {}) {
42
+ const appData = env.APPDATA || join(home, 'AppData', 'Roaming');
43
+ const macSupport = join(home, 'Library', 'Application Support');
44
+ const xdgConfig = env.XDG_CONFIG_HOME || join(home, '.config');
45
+
46
+ const claudeDesktop = {
47
+ win32: join(appData, 'Claude', 'claude_desktop_config.json'),
48
+ darwin: join(macSupport, 'Claude', 'claude_desktop_config.json'),
49
+ linux: join(xdgConfig, 'Claude', 'claude_desktop_config.json'),
50
+ }[platform];
51
+
52
+ const clients = [
53
+ { id: 'claude-desktop', name: 'Claude Desktop', configPath: claudeDesktop, createIfMissing: true,
54
+ restartHint: 'Quit and reopen Claude Desktop.' },
55
+ // Cursor: global MCP config in ~/.cursor/mcp.json.
56
+ { id: 'cursor', name: 'Cursor', configPath: join(home, '.cursor', 'mcp.json'), createIfMissing: true,
57
+ restartHint: 'Restart Cursor (or toggle the MCP server in Settings → MCP).' },
58
+ // Claude Code: the CLI manages ~/.claude.json. We only merge into it when it
59
+ // already exists — never hand-create that large, CLI-owned file.
60
+ { id: 'claude-code', name: 'Claude Code', configPath: join(home, '.claude.json'), createIfMissing: false,
61
+ restartHint: 'Start a new Claude Code session (or run `/mcp` to confirm).' },
62
+ ];
63
+ return clients.filter((c) => Boolean(c.configPath));
64
+ }
65
+
66
+ // The server entry written into a client's mcpServers map. Uses the absolute
67
+ // node binary (process.execPath) because GUI-launched clients often lack a PATH
68
+ // that resolves bare `node`. `nodePath`/`serverPath`/`env` injectable for tests.
69
+ export function serverEntry({ nodePath = process.execPath, serverPath = SERVER_PATH, env } = {}) {
70
+ const entry = { command: nodePath, args: [serverPath] };
71
+ if (env && Object.keys(env).length) entry.env = env;
72
+ return entry;
73
+ }
74
+
75
+ // ── Consult model judge config (on by default) ────────────────────────────────
76
+ // The MCP server runs the consult model judge when PHAEDO_CONSULT_MODEL is set;
77
+ // it falls back to the local rule engine if the model/Ollama isn't there, so
78
+ // defaulting it ON is safe. We write a LOCAL endpoint only (privacy-clean); a
79
+ // frontier (off-device) judge is intentionally not wired here.
80
+ const DEFAULT_CONSULT_MODEL = 'llama3.1:8b';
81
+ const DEFAULT_CONSULT_URL = 'http://localhost:11434';
82
+
83
+ export function buildConsultEnv({ model = DEFAULT_CONSULT_MODEL, url = DEFAULT_CONSULT_URL, disabled = false } = {}) {
84
+ if (disabled) return {};
85
+ return { PHAEDO_CONSULT_MODEL: model, PHAEDO_CONSULT_MODEL_URL: url };
86
+ }
87
+
88
+ // Probe local Ollama so "on by default" works with whatever model the user has.
89
+ // Picks the first installed general-purpose model (skips the phaedo extraction
90
+ // fine-tune). Falls back to the default string + reachable:false on any failure.
91
+ export async function detectConsultModel({ url = DEFAULT_CONSULT_URL, fallback = DEFAULT_CONSULT_MODEL, fetchImpl = globalThis.fetch } = {}) {
92
+ try {
93
+ const res = await fetchImpl(`${url.replace(/\/$/, '')}/api/tags`, { signal: AbortSignal.timeout(1500) });
94
+ if (!res.ok) return { model: fallback, reachable: false, hadModels: false };
95
+ const data = await res.json();
96
+ const names = (data.models || []).map((m) => m.name || m.model).filter(Boolean);
97
+ const general = names.find((n) => !/phaedo|extract/i.test(n));
98
+ return { model: general || fallback, reachable: true, hadModels: names.length > 0, picked: !!general };
99
+ } catch {
100
+ return { model: fallback, reachable: false, hadModels: false };
101
+ }
102
+ }
103
+
104
+ export function readConfig(path) {
105
+ // Returns { config, existed }. Throws on malformed JSON so the caller can
106
+ // refuse to overwrite a file it can't safely parse (never destroy hand-edits).
107
+ if (!existsSync(path)) return { config: {}, existed: false };
108
+ const raw = readFileSync(path, 'utf8');
109
+ if (raw.trim() === '') return { config: {}, existed: true };
110
+ try {
111
+ const config = JSON.parse(raw);
112
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
113
+ throw new Error('top-level value is not a JSON object');
114
+ }
115
+ return { config, existed: true };
116
+ } catch (err) {
117
+ throw new Error(`malformed JSON (${err.message})`);
118
+ }
119
+ }
120
+
121
+ // Merge our entry under mcpServers.phaedo, preserving every other server and
122
+ // every other top-level key. Returns { config, changed }.
123
+ export function mergeServerEntry(config, entry, key = ENTRY_KEY) {
124
+ const next = { ...config, mcpServers: { ...(config.mcpServers || {}) } };
125
+ const before = JSON.stringify(next.mcpServers[key] || null);
126
+ next.mcpServers[key] = entry;
127
+ return { config: next, changed: JSON.stringify(entry) !== before };
128
+ }
129
+
130
+ // Remove our entry. Returns { config, changed }.
131
+ export function removeServerEntry(config, key = ENTRY_KEY) {
132
+ if (!config.mcpServers || !(key in config.mcpServers)) return { config, changed: false };
133
+ const servers = { ...config.mcpServers };
134
+ delete servers[key];
135
+ return { config: { ...config, mcpServers: servers }, changed: true };
136
+ }
137
+
138
+ // ── Claude Code SessionStart hook (--code-hook) ───────────────────────────────
139
+ // True auto-injection for Claude Code: ~/.claude/settings.json runs our hook
140
+ // script at every session start. Merged non-destructively into settings.hooks.
141
+ export function claudeSettingsPath({ home = homedir() } = {}) {
142
+ return join(home, '.claude', 'settings.json');
143
+ }
144
+
145
+ export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH } = {}) {
146
+ // Quote both — node path / checkout path may contain spaces; the hook runs in a shell.
147
+ return `"${nodePath}" "${hookPath}"`;
148
+ }
149
+
150
+ const isPhaedoHookEntry = (e) =>
151
+ e && Array.isArray(e.hooks) &&
152
+ e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_MARKER));
153
+
154
+ export function mergeSessionStartHook(settings, command) {
155
+ const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
156
+ const prior = Array.isArray(next.hooks.SessionStart) ? next.hooks.SessionStart : [];
157
+ const others = prior.filter((e) => !isPhaedoHookEntry(e)); // drop a stale phaedo entry (path refresh)
158
+ const existing = prior.find(isPhaedoHookEntry);
159
+ const already = !!existing && existing.hooks.some((h) => h.command === command);
160
+ next.hooks.SessionStart = [...others, { hooks: [{ type: 'command', command }] }];
161
+ return { settings: next, changed: !already };
162
+ }
163
+
164
+ export function removeSessionStartHook(settings) {
165
+ if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) return { settings, changed: false };
166
+ const prior = settings.hooks.SessionStart;
167
+ const others = prior.filter((e) => !isPhaedoHookEntry(e));
168
+ if (others.length === prior.length) return { settings, changed: false };
169
+ const hooks = { ...settings.hooks };
170
+ if (others.length) hooks.SessionStart = others; else delete hooks.SessionStart;
171
+ const next = { ...settings, hooks };
172
+ if (Object.keys(next.hooks).length === 0) delete next.hooks;
173
+ return { settings: next, changed: true };
174
+ }
175
+
176
+ // action: 'install' | 'uninstall'. Returns a result record. settings.json is a
177
+ // small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
178
+ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
179
+ const path = claudeSettingsPath({ home });
180
+ const name = 'Claude Code hook';
181
+ let read;
182
+ try {
183
+ read = readConfig(path);
184
+ } catch (err) {
185
+ return { name, status: 'error', detail: `skipped — ${err.message}; not touched` };
186
+ }
187
+ if (action === 'uninstall') {
188
+ if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
189
+ const { settings, changed } = removeSessionStartHook(read.config);
190
+ if (!changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
191
+ if (!dryRun) { backup(path); writeConfig(path, settings); }
192
+ return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
193
+ }
194
+ const { settings, changed } = mergeSessionStartHook(read.config, hookCommand());
195
+ if (!changed) return { name, status: 'unchanged', detail: 'already configured' };
196
+ if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
197
+ return { name, status: dryRun ? 'would-write' : 'configured', detail: path };
198
+ }
199
+
200
+ function backup(path) {
201
+ const bak = `${path}.phaedo.bak`;
202
+ copyFileSync(path, bak);
203
+ return bak;
204
+ }
205
+
206
+ function writeConfig(path, config) {
207
+ mkdirSync(dirname(path), { recursive: true });
208
+ writeFileSync(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
209
+ }
210
+
211
+ // ── Per-client apply ───────────────────────────────────────────────────────-
212
+ // action: 'install' | 'uninstall'. Returns a result record for reporting.
213
+ export function applyToClient(client, { action, entry, dryRun = false } = {}) {
214
+ const { configPath } = client;
215
+ let read;
216
+ try {
217
+ read = readConfig(configPath);
218
+ } catch (err) {
219
+ return { ...client, status: 'error', detail: `skipped — ${err.message}; not touched` };
220
+ }
221
+
222
+ if (action === 'uninstall') {
223
+ if (!read.existed) return { ...client, status: 'absent', detail: 'no config file' };
224
+ const { config, changed } = removeServerEntry(read.config);
225
+ if (!changed) return { ...client, status: 'absent', detail: 'no phaedo entry to remove' };
226
+ if (!dryRun) { backup(configPath); writeConfig(configPath, config); }
227
+ return { ...client, status: dryRun ? 'would-remove' : 'removed', detail: configPath };
228
+ }
229
+
230
+ // install
231
+ if (!read.existed && !client.createIfMissing) {
232
+ return { ...client, status: 'absent', detail: 'not installed (config file not found)' };
233
+ }
234
+ const { config, changed } = mergeServerEntry(read.config, entry);
235
+ if (!changed) return { ...client, status: 'unchanged', detail: 'already configured' };
236
+ if (!dryRun) {
237
+ if (read.existed) backup(configPath);
238
+ writeConfig(configPath, config);
239
+ }
240
+ return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
241
+ }
242
+
243
+ function parseArgs(argv) {
244
+ const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
245
+ for (let i = 0; i < argv.length; i++) {
246
+ const a = argv[i];
247
+ if (a === '--pair') opts.pair = true;
248
+ else if (a === '--uninstall') opts.uninstall = true;
249
+ else if (a === '--dry-run') opts.dryRun = true;
250
+ else if (a === '--only') opts.only = argv[++i];
251
+ else if (a === '--code-hook') opts.codeHook = true;
252
+ else if (a === '--consult-model') opts.consultModel = argv[++i];
253
+ else if (a === '--consult-url') opts.consultUrl = argv[++i];
254
+ else if (a === '--no-model-judge') opts.noModelJudge = true;
255
+ else if (a === '--help' || a === '-h') opts.help = true;
256
+ else throw new Error(`unknown option: ${a}`);
257
+ }
258
+ return opts;
259
+ }
260
+
261
+ const HELP = `Phaedo MCP installer
262
+
263
+ node install.mjs configure every detected MCP client
264
+ node install.mjs --pair configure, then run the one-time pairing
265
+ node install.mjs --code-hook also add the Claude Code SessionStart hook
266
+ (true auto-injection; ~/.claude/settings.json)
267
+ node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
268
+ node install.mjs --dry-run preview changes, write nothing
269
+ node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
270
+
271
+ Consult model judge (ON by default, local Ollama, falls back to the rule engine):
272
+ node install.mjs --consult-model <id> pin the model (e.g. llama3.1:8b)
273
+ node install.mjs --consult-url <url> endpoint (default http://localhost:11434)
274
+ node install.mjs --no-model-judge rule engine only (no model env)
275
+ `;
276
+
277
+ function runPairing() {
278
+ if (!existsSync(join(BASE_DIR, 'node_modules'))) {
279
+ console.log('\n⚠ Dependencies not installed — run `npm install` in this folder first,');
280
+ console.log(' then `npm run pair`. (The platform launcher does this for you.)');
281
+ return 1;
282
+ }
283
+ console.log('\n— Pairing with your phone (one time) —\n');
284
+ const r = spawnSync(process.execPath, [join(BASE_DIR, 'pair.js')], { stdio: 'inherit' });
285
+ return r.status ?? 1;
286
+ }
287
+
288
+ async function main(argv) {
289
+ let opts;
290
+ try { opts = parseArgs(argv); }
291
+ catch (err) { console.error(err.message + '\n\n' + HELP); return 2; }
292
+ if (opts.help) { console.log(HELP); return 0; }
293
+
294
+ let clients = detectClients();
295
+ if (opts.only) {
296
+ clients = clients.filter((c) => c.id === opts.only);
297
+ if (!clients.length) { console.error(`No such client id: ${opts.only}`); return 2; }
298
+ }
299
+
300
+ const action = opts.uninstall ? 'uninstall' : 'install';
301
+
302
+ // Consult model judge: ON by default. Pin it if --consult-model is given,
303
+ // otherwise probe local Ollama and pick an installed general model. Off with
304
+ // --no-model-judge. Always falls back to the rule engine at runtime, so a
305
+ // missing model never breaks consultation.
306
+ let env = {}, consultNote = '';
307
+ if (action === 'install' && !opts.noModelJudge) {
308
+ const url = opts.consultUrl || DEFAULT_CONSULT_URL;
309
+ if (opts.consultModel) {
310
+ env = buildConsultEnv({ model: opts.consultModel, url });
311
+ consultNote = `model judge ON → ${opts.consultModel} @ ${url} (pinned)`;
312
+ } else {
313
+ const d = await detectConsultModel({ url });
314
+ env = buildConsultEnv({ model: d.model, url });
315
+ consultNote = d.reachable
316
+ ? `model judge ON → ${d.model} @ ${url} (${d.picked ? 'detected in Ollama' : 'default; `ollama pull ' + d.model + '`'})`
317
+ : `model judge ON → ${d.model} @ ${url} (Ollama not detected — falls back to the rule engine until it's running)`;
318
+ }
319
+ } else if (action === 'install') {
320
+ consultNote = 'model judge OFF (rule engine only)';
321
+ }
322
+ const entry = serverEntry({ env });
323
+ console.log(`Phaedo MCP — ${action}${opts.dryRun ? ' (dry run)' : ''}`);
324
+ console.log(`Server: ${SERVER_PATH}`);
325
+ console.log(`Node: ${process.execPath}`);
326
+ if (consultNote) console.log(`Consult: ${consultNote}`);
327
+ console.log('');
328
+
329
+ const results = clients.map((c) => applyToClient(c, { action, entry, dryRun: opts.dryRun }));
330
+ // The Claude Code SessionStart hook: opt-in on install (--code-hook), always
331
+ // cleaned up on uninstall.
332
+ if (opts.codeHook || action === 'uninstall') {
333
+ results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
334
+ }
335
+ const touched = [];
336
+ for (const r of results) {
337
+ const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
338
+ unchanged: '=', absent: '–', error: '✗' }[r.status] || '?';
339
+ console.log(` ${mark} ${r.name.padEnd(16)} ${r.detail}`);
340
+ if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
341
+ }
342
+
343
+ if (action === 'install') {
344
+ if (!touched.length) {
345
+ console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
346
+ } else if (!opts.dryRun) {
347
+ console.log('\nNext:');
348
+ console.log(' 1. Pair with your phone: npm run pair (or re-run with --pair)');
349
+ console.log(' 2. Restart any client that was already open:');
350
+ for (const r of touched) console.log(` • ${r.name}: ${r.restartHint}`);
351
+ console.log(' 3. Inject your fingerprint at session start:');
352
+ console.log(' • any client: run the `/phaedo` prompt (one click), or call');
353
+ console.log(' the `phaedo_request_injection` tool.');
354
+ if (opts.codeHook) {
355
+ console.log(' • Claude Code: AUTOMATIC — the SessionStart hook injects it for you.');
356
+ } else {
357
+ console.log(' • Claude Code: `--code-hook` makes it automatic at every session start.');
358
+ }
359
+ }
360
+ if (opts.pair && !opts.dryRun && touched.length) return runPairing();
361
+ }
362
+ return 0;
363
+ }
364
+
365
+ // Only run when invoked directly (so test-install.mjs can import the helpers).
366
+ if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) {
367
+ main(process.argv.slice(2)).then((code) => process.exit(code));
368
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # Run this to install Phaedo into your AI apps (Claude Desktop, Cursor, Claude
3
+ # Code) and pair with your phone: bash install-phaedo-mcp.sh
4
+ # Safe to run again any time — it updates, never clobbers.
5
+ set -e
6
+
7
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ MCP_DIR="$(cd "$HERE/../.." && pwd)"
9
+ cd "$MCP_DIR"
10
+
11
+ echo "Phaedo MCP installer"
12
+ echo "Folder: $MCP_DIR"
13
+ echo
14
+
15
+ if ! command -v node >/dev/null 2>&1; then
16
+ echo "✗ Node.js isn't installed. Install it from https://nodejs.org or your package"
17
+ echo " manager (e.g. 'sudo apt install nodejs npm'), then run this again."
18
+ exit 1
19
+ fi
20
+ echo "Using node: $(node --version) at $(command -v node)"
21
+ echo
22
+
23
+ if [ ! -d node_modules ]; then
24
+ echo "Installing dependencies (one time)…"
25
+ npm install --omit=dev
26
+ echo
27
+ fi
28
+
29
+ node install.mjs --pair
30
+
31
+ echo
32
+ echo "Done. If a client was already open, restart it so it picks up Phaedo."
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # Double-click this file to install Phaedo into your AI apps (Claude Desktop,
3
+ # Cursor, Claude Code) and pair with your phone. It opens in Terminal and runs
4
+ # the installer for you. Safe to run again any time — it updates, never clobbers.
5
+ set -e
6
+
7
+ # Resolve the mcp/ folder relative to this script (installers/macos/ -> mcp/).
8
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ MCP_DIR="$(cd "$HERE/../.." && pwd)"
10
+ cd "$MCP_DIR"
11
+
12
+ echo "Phaedo MCP installer"
13
+ echo "Folder: $MCP_DIR"
14
+ echo
15
+
16
+ if ! command -v node >/dev/null 2>&1; then
17
+ echo "✗ Node.js isn't installed."
18
+ echo " Install it from https://nodejs.org (the LTS button), then double-click this again."
19
+ echo
20
+ read -n 1 -s -r -p "Press any key to close."
21
+ exit 1
22
+ fi
23
+ echo "Using node: $(node --version) at $(command -v node)"
24
+ echo
25
+
26
+ if [ ! -d node_modules ]; then
27
+ echo "Installing dependencies (one time)…"
28
+ npm install --omit=dev
29
+ echo
30
+ fi
31
+
32
+ # Configure every detected client, then run the one-time phone pairing.
33
+ node install.mjs --pair
34
+
35
+ echo
36
+ echo "Done. If a client was already open, restart it so it picks up Phaedo."
37
+ read -n 1 -s -r -p "Press any key to close."
@@ -0,0 +1,39 @@
1
+ @echo off
2
+ REM Double-click to install Phaedo into your AI apps (Claude Desktop, Cursor,
3
+ REM Claude Code) and pair with your phone. Safe to run again any time.
4
+ setlocal
5
+
6
+ REM Resolve the mcp\ folder relative to this script (installers\windows\ -> mcp\).
7
+ set "HERE=%~dp0"
8
+ pushd "%HERE%..\.." || (echo Could not find the mcp folder & pause & exit /b 1)
9
+
10
+ echo Phaedo MCP installer
11
+ echo Folder: %CD%
12
+ echo.
13
+
14
+ where node >nul 2>nul
15
+ if errorlevel 1 (
16
+ echo Node.js isn't installed.
17
+ echo Install it from https://nodejs.org ^(the LTS button^), then double-click this again.
18
+ echo.
19
+ pause
20
+ popd
21
+ exit /b 1
22
+ )
23
+ for /f "delims=" %%v in ('node --version') do echo Using node %%v
24
+ echo.
25
+
26
+ if not exist node_modules (
27
+ echo Installing dependencies ^(one time^)...
28
+ call npm install --omit=dev
29
+ echo.
30
+ )
31
+
32
+ REM Configure every detected client, then run the one-time phone pairing.
33
+ node install.mjs --pair
34
+
35
+ echo.
36
+ echo Done. If a client was already open, restart it so it picks up Phaedo.
37
+ pause
38
+ popd
39
+ endlocal
@@ -0,0 +1,34 @@
1
+ // Minimal `globalThis.phaedoVault` shim for Node, so the repo-root identity.js
2
+ // (which reads/writes globalThis.phaedoVault) can persist the MCP server's
3
+ // long-term X25519 identity outside a browser.
4
+ //
5
+ // D2 (signed off 2026-06-05): a restricted-perms (0600) JSON file. The
6
+ // `privateKeyJwk` it holds is a long-term secret — same class as `pair_key` in
7
+ // pairing.json. OS keychain is the documented upgrade path.
8
+ //
9
+ // identity.js treats a falsy read() as "vault locked" and throws; this shim
10
+ // always returns a truthy object (empty {} when the file doesn't exist yet), so
11
+ // getOrCreate() generates a fresh identity and update()s it on first use.
12
+
13
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
14
+ import { resolve } from 'path';
15
+
16
+ export function installNodeVault(filePath) {
17
+ const path = resolve(filePath);
18
+
19
+ const read = () => {
20
+ if (!existsSync(path)) return {};
21
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
22
+ };
23
+
24
+ globalThis.phaedoVault = {
25
+ async read() { return read(); },
26
+ async update(patch) {
27
+ const next = { ...read(), ...patch };
28
+ writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
29
+ try { chmodSync(path, 0o600); } catch {} // enforce perms if the file pre-existed
30
+ },
31
+ };
32
+
33
+ return path;
34
+ }
@@ -0,0 +1,36 @@
1
+ // Loads the repo-root Phaedo crypto modules into Node and exposes them.
2
+ //
3
+ // They're classic scripts that set globalThis.Phaedo.* and (verified) load in
4
+ // Node with zero shims — Node has global crypto.subtle / TextEncoder / btoa.
5
+ // Load order matters: protocol → envelope → kdf → identity (envelope.js and
6
+ // kdf.js read Phaedo.Protocol at load).
7
+ //
8
+ // Option B (1b-i) uses only the STATELESS path: Kdf.* + Identity.generateEphemeral
9
+ // / deriveEphemeralShared + Envelope.unwrap. The vault-backed Identity functions
10
+ // (getOrCreate/getPublicKey) are NOT called here, so no phaedoVault shim is needed.
11
+
12
+ import { createRequire } from 'module';
13
+ import { existsSync } from 'fs';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, resolve } from 'path';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const HERE = dirname(fileURLToPath(import.meta.url)); // mcp/lib
19
+ const VENDOR = resolve(HERE, '..', 'vendor'); // mcp/vendor — bundled (npm package)
20
+ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
21
+
22
+ // Prefer the bundled copies (self-contained package) over the repo root (dev).
23
+ // Load order matters: protocol → envelope → kdf → identity.
24
+ for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
25
+ const vf = resolve(VENDOR, f);
26
+ require(existsSync(vf) ? vf : resolve(ROOT, f));
27
+ }
28
+
29
+ export const Phaedo = globalThis.Phaedo;
30
+ if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol) {
31
+ throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
32
+ }
33
+
34
+ // base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
35
+ export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
36
+ export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "phaedo-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
8
+ "mcpName": "io.github.galavoxx/phaedo",
9
+ "homepage": "https://getphaedo.com",
10
+ "repository": { "type": "git", "url": "https://github.com/galavoxx/phaedo", "directory": "mcp" },
11
+ "keywords": ["mcp", "model-context-protocol", "personalization", "fingerprint", "consultation", "agent"],
12
+ "bin": { "phaedo-mcp": "server.js" },
13
+ "files": [
14
+ "server.js", "server.json", "fingerprint-source.js", "projection.js", "errors.js",
15
+ "consult.js", "consult-core.js", "consult-policy.js", "receipts.js", "agreement.js", "phone-source.js",
16
+ "cache.js", "pairing.js", "pair.js", "pull.js",
17
+ "install.mjs", "build-package.mjs", "vendor/", "lib/", "hooks/", "installers/",
18
+ "policy-editor.html", "sample-fingerprint.json", "pairing.sample.json",
19
+ "consult-policies.sample.json", "README.md"
20
+ ],
21
+ "scripts": {
22
+ "start": "node server.js",
23
+ "build": "node build-package.mjs",
24
+ "prepack": "node build-package.mjs",
25
+ "setup": "node install.mjs",
26
+ "setup:pair": "node install.mjs --pair",
27
+ "setup:code-hook": "node install.mjs --code-hook",
28
+ "unsetup": "node install.mjs --uninstall",
29
+ "pair": "node pair.js",
30
+ "pull": "node pull.js",
31
+ "test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-receipts.mjs && node test-agreement.mjs",
32
+ "receipts": "node receipts.js list",
33
+ "agreement": "node agreement.js",
34
+ "smoke": "node test-smoke.mjs",
35
+ "smoke:phone": "node test-phone-boundary.mjs",
36
+ "smoke:cache": "node test-cache.mjs",
37
+ "smoke:pair": "node test-pairing.mjs",
38
+ "smoke:revoke": "node test-revoke.mjs",
39
+ "smoke:install": "node test-install.mjs",
40
+ "smoke:consult": "node test-consult.mjs",
41
+ "smoke:policy": "node test-consult-policy.mjs",
42
+ "smoke:receipts": "node test-receipts.mjs",
43
+ "smoke:agreement": "node test-agreement.mjs"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.0.0",
47
+ "zod": "^3.23.0"
48
+ }
49
+ }
package/pair.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // `phaedo-mcp pair` — pair this MCP server with the phone vault as its own
3
+ // SAS-approved, revocable client (plan: mcp-revocable-approval-plan.md, P3).
4
+ //
5
+ // Writes the server's long-term identity to identity.json (0600) and the
6
+ // resulting pairing record to pairing.json (0600) — the file phone-source.js
7
+ // reads. Run once; the server then authorizes autonomously.
8
+ //
9
+ // node pair.js
10
+ // PHAEDO_IDENTITY=/secure/id.json PHAEDO_PAIRING=/secure/pairing.json node pair.js
11
+
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, resolve } from 'path';
14
+ import { pair } from './pairing.js';
15
+
16
+ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
17
+ const identityPath = process.env.PHAEDO_IDENTITY
18
+ ? resolve(process.cwd(), process.env.PHAEDO_IDENTITY)
19
+ : resolve(BASE_DIR, 'identity.json');
20
+ const pairingPath = process.env.PHAEDO_PAIRING
21
+ ? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
22
+ : resolve(BASE_DIR, 'pairing.json');
23
+
24
+ pair({ identityPath, pairingPath, log: (m) => process.stdout.write(m + '\n') })
25
+ .then(() => process.exit(0))
26
+ .catch((err) => {
27
+ process.stderr.write(`\n✗ Pairing failed: ${err.message}\n`);
28
+ process.exit(1);
29
+ });