fathom-mcp 0.6.4 → 2.0.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/src/index.js DELETED
@@ -1,552 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * fathom-mcp — MCP server for Fathom vault operations.
5
- *
6
- * Dispatches tools to server-client.js (HTTP to fathom-server — search, rooms, workspaces).
7
- * Vault CRUD is handled by the agent's native file tools (Read, Write, Edit, Glob).
8
- */
9
-
10
- import fs from "fs";
11
- import path from "path";
12
- import { fileURLToPath } from "url";
13
-
14
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
- import {
17
- CallToolRequestSchema,
18
- ListToolsRequestSchema,
19
- } from "@modelcontextprotocol/sdk/types.js";
20
-
21
- import { resolveConfig } from "./config.js";
22
- import { createClient } from "./server-client.js";
23
- import { createWSConnection } from "./ws-connection.js";
24
-
25
- const __filename = fileURLToPath(import.meta.url);
26
- const __dirname = path.dirname(__filename);
27
-
28
-
29
- const config = resolveConfig();
30
- const client = createClient(config);
31
- let wsConn = null;
32
-
33
- // --- Tool definitions --------------------------------------------------------
34
-
35
- const WORKSPACE_PROP = {
36
- type: "string",
37
- description: "Workspace name (e.g. 'fathom', 'navier-stokes'). If omitted, uses default workspace.",
38
- };
39
-
40
- const tools = [
41
- {
42
- name: "fathom_vault_search",
43
- description:
44
- "Keyword search (BM25) across vault files — start here for most searches. Fast, " +
45
- "exact-match oriented. Best for specific terms, file names, or known phrases. " +
46
- "If results miss conceptually related content, follow up with fathom_vault_vsearch.",
47
- inputSchema: {
48
- type: "object",
49
- properties: {
50
- query: { type: "string", description: "Search query (keywords)" },
51
- workspace: WORKSPACE_PROP,
52
- limit: { type: "integer", description: "Max results to return (default: from settings, typically 10)" },
53
- },
54
- required: ["query"],
55
- },
56
- },
57
- {
58
- name: "fathom_vault_vsearch",
59
- description:
60
- "Semantic/vector search across vault files — use when keyword search misses or when " +
61
- "exploring ideas by meaning rather than exact terms. Finds conceptually similar content " +
62
- "even without keyword overlap. Slower than fathom_vault_search. " +
63
- "For the most thorough results, use fathom_vault_query instead.",
64
- inputSchema: {
65
- type: "object",
66
- properties: {
67
- query: { type: "string", description: "Search query (natural language, conceptual)" },
68
- workspace: WORKSPACE_PROP,
69
- limit: { type: "integer", description: "Max results to return (default: from settings, typically 10)" },
70
- },
71
- required: ["query"],
72
- },
73
- },
74
- {
75
- name: "fathom_vault_query",
76
- description:
77
- "Hybrid search combining BM25 keyword matching, vector similarity, and reranking — " +
78
- "the most thorough search mode. Use when completeness matters more than speed " +
79
- "(e.g. 'find everything related to X'). Slowest of the three search tools. " +
80
- "For quick lookups, start with fathom_vault_search instead.",
81
- inputSchema: {
82
- type: "object",
83
- properties: {
84
- query: { type: "string", description: "Search query (keywords or natural language)" },
85
- workspace: WORKSPACE_PROP,
86
- limit: { type: "integer", description: "Max results to return (default: from settings, typically 10)" },
87
- },
88
- required: ["query"],
89
- },
90
- },
91
- {
92
- name: "fathom_room_post",
93
- description:
94
- "Post a message to a shared room. Rooms are created implicitly on first post. " +
95
- "Use this for ambient, multilateral communication — unlike fathom_send (point-to-point DM), " +
96
- "room messages are visible to all participants. Responding is optional — use `<...>` for active silence. " +
97
- "Supports @workspace mentions (e.g. @fathom, @navier-stokes) — mentioned workspaces receive " +
98
- "notifications in their mentions:{workspace} virtual room via fathom_room_list. Use @all to notify every workspace except sender.",
99
- inputSchema: {
100
- type: "object",
101
- properties: {
102
- room: { type: "string", description: "Room name, e.g. 'general', 'navier-stokes'. Created on first post." },
103
- message: { type: "string", description: "Message to post. Use @workspace to mention and notify specific workspaces (e.g. '@fathom check this'), or @all for everyone." },
104
- },
105
- required: ["room", "message"],
106
- },
107
- },
108
- {
109
- name: "fathom_room_read",
110
- description:
111
- "Read recent messages from a shared room. Returns messages within a time window anchored " +
112
- "to the latest message. Default: 60 minutes before the latest message. Use start to look " +
113
- "further back. Example: minutes=15, start=120 returns 15 minutes of conversation starting " +
114
- "2 hours before the latest message. Response includes window metadata with has_older flag " +
115
- "for pseudo-pagination. All rooms are persistent — messages are never deleted on read. " +
116
- "Automatically marks the room as read unless mark_read=false.",
117
- inputSchema: {
118
- type: "object",
119
- properties: {
120
- room: { type: "string", description: "Room name to read from" },
121
- minutes: { type: "number", description: "Window duration in minutes. Default: 60." },
122
- start: { type: "number", description: "Offset in minutes from the latest message. Default: 0 (window ends at latest message). Set to 120 to look back starting 2 hours before the latest message." },
123
- mark_read: { type: "boolean", description: "Set to false to peek without marking the room as read. Default: true." },
124
- },
125
- required: ["room"],
126
- },
127
- },
128
- {
129
- name: "fathom_room_list",
130
- description:
131
- "List all rooms with activity summary — message count, last activity time, last sender, " +
132
- "description, and per-room unread_count for this workspace. Use to discover active rooms " +
133
- "and see which have new messages. DM rooms (dm:a+b) are filtered by workspace param — " +
134
- "only participants see them. Mention rooms (mentions:{workspace}) visible only to the target workspace.",
135
- inputSchema: {
136
- type: "object",
137
- properties: {},
138
- required: [],
139
- },
140
- },
141
- {
142
- name: "fathom_room_describe",
143
- description:
144
- "Set or update the description/topic for a room. Descriptions help participants " +
145
- "understand what a room is for. Pass an empty string to clear.",
146
- inputSchema: {
147
- type: "object",
148
- properties: {
149
- room: { type: "string", description: "Room name to set description for" },
150
- description: { type: "string", description: "Room description/topic — what this room is about" },
151
- },
152
- required: ["room", "description"],
153
- },
154
- },
155
- {
156
- name: "fathom_workspaces",
157
- description:
158
- "List all configured workspaces — use this to discover valid workspace names before " +
159
- "calling fathom_send. Returns each workspace's name, running status, model, and role.",
160
- inputSchema: {
161
- type: "object",
162
- properties: {},
163
- required: [],
164
- },
165
- },
166
- {
167
- name: "fathom_send",
168
- description:
169
- "Send a message to another workspace's agent instance — stored in a shared dm:a+b room " +
170
- "visible to both participants. Use fathom_workspaces first to discover valid targets. " +
171
- "DMs are persistent and appear in both participants' room lists via fathom_room_list.",
172
- inputSchema: {
173
- type: "object",
174
- properties: {
175
- workspace: { type: "string", description: "Target workspace name — run fathom_workspaces to see available options" },
176
- message: { type: "string", description: "Message to send to the target workspace's agent instance" },
177
- },
178
- required: ["workspace", "message"],
179
- },
180
- },
181
- {
182
- name: "fathom_routine_list",
183
- description:
184
- "List all ping routines for a workspace with their status, intervals, " +
185
- "enabled state, and next fire time. Use this to see what routines exist " +
186
- "before updating or deleting them.",
187
- inputSchema: {
188
- type: "object",
189
- properties: {
190
- workspace: WORKSPACE_PROP,
191
- },
192
- required: [],
193
- },
194
- },
195
- {
196
- name: "fathom_routine_fire",
197
- description:
198
- "Fire a ping routine immediately, regardless of its schedule. Non-blocking — " +
199
- "returns immediately while the routine fires in the background. The routine's " +
200
- "next scheduled fire time is not affected.",
201
- inputSchema: {
202
- type: "object",
203
- properties: {
204
- routine_id: { type: "string", description: "The routine ID to fire." },
205
- workspace: WORKSPACE_PROP,
206
- },
207
- required: ["routine_id"],
208
- },
209
- },
210
- {
211
- name: "fathom_speak",
212
- description:
213
- "Generate speech using Kokoro TTS (am_echo 70% + bf_alice 30%). " +
214
- "Always returns path to generated WAV file. Set play=true to also play aloud. " +
215
- "Use file param to read a text file instead of inline text.",
216
- inputSchema: {
217
- type: "object",
218
- properties: {
219
- text: { type: "string", description: "Text to speak. Ignored if file is provided." },
220
- file: { type: "string", description: "Absolute path to a text file to read aloud. Overrides text param." },
221
- speed: { type: "number", description: "Speech speed multiplier. Default: 1.0" },
222
- play: { type: "boolean", description: "Also play audio aloud during generation. Default: false." },
223
- },
224
- required: [],
225
- },
226
- },
227
- {
228
- name: "fathom_send_voice",
229
- description:
230
- "Send a voice message to Myra via the app. Generates speech using Kokoro TTS " +
231
- "(am_echo 70% + bf_alice 30%) and delivers it as a playable voice bubble in the chat. " +
232
- "Use this to reply conversationally with voice when Myra sends a voice message.",
233
- inputSchema: {
234
- type: "object",
235
- properties: {
236
- text: { type: "string", description: "Text to speak and send as voice message." },
237
- speed: { type: "number", description: "Speech speed multiplier. Default: 1.0" },
238
- },
239
- required: ["text"],
240
- },
241
- },
242
- {
243
- name: "fathom_key_rotate",
244
- description:
245
- "Rotate this agent's API key. Revokes the current per-agent key and issues a new one. " +
246
- "Updates the local agents.json config and reconnects the WebSocket. " +
247
- "Only works with per-agent keys — admin keys use the dashboard.",
248
- inputSchema: {
249
- type: "object",
250
- properties: {},
251
- required: [],
252
- },
253
- },
254
- ];
255
-
256
- // --- Policy evaluation tool (permission-prompt-tool for stream-json) ---------
257
-
258
- const policyTools = [
259
- {
260
- name: "policy_evaluate",
261
- description:
262
- "Evaluate a permission request for a tool call. Called automatically by Claude " +
263
- "via --permission-prompt-tool when a tool is not in the static allow list. " +
264
- "Returns {behavior: 'allow'} or {behavior: 'deny', message: '...'}.",
265
- inputSchema: {
266
- type: "object",
267
- properties: {
268
- tool_name: { type: "string", description: "The tool being requested (e.g. 'Bash', 'Edit')" },
269
- input: { type: "object", description: "The tool's input arguments" },
270
- },
271
- required: ["tool_name", "input"],
272
- },
273
- },
274
- {
275
- name: "permission_respond",
276
- description:
277
- "Respond to a pending permission request from another workspace. " +
278
- "Called by the policy-gate workspace to approve or deny an escalated permission. " +
279
- "Returns {ok: true} on success, {error: '...'} if request not found or already resolved.",
280
- inputSchema: {
281
- type: "object",
282
- properties: {
283
- request_id: { type: "string", description: "The permission request ID" },
284
- allow: { type: "boolean", description: "true to approve, false to deny" },
285
- reason: { type: "string", description: "Brief explanation for the decision" },
286
- },
287
- required: ["request_id", "allow"],
288
- },
289
- },
290
- ];
291
-
292
- // --- Primary-agent-only tools (reserved for future use) ----------------------
293
-
294
-
295
- // --- Policy evaluation -------------------------------------------------------
296
-
297
- /**
298
- * Evaluate a permission request from Claude's --permission-prompt-tool.
299
- *
300
- * Decision logic:
301
- * 1. Hard deny: auto-reject dangerous operations
302
- * 2. Escalate: everything else goes to dashboard for human approval
303
- * (Pre-approval is handled by settings.local.json allow list, not here)
304
- */
305
- async function evaluatePermission(toolName, input) {
306
-
307
- // --- Hard deny list ---
308
- if (toolName === "Bash") {
309
- const cmd = (input.command || "").trim();
310
- const denyPatterns = [
311
- /\brm\s+-rf\s+[\/~]/, // rm -rf broad paths
312
- /\bsudo\b/, // privilege escalation
313
- /\bsu\s/, // switch user
314
- /\bgit\s+push\s+.*--force\s.*(main|master)/, // force push to main
315
- /\b(\.ssh|\.gnupg)\b/, // sensitive directories
316
- ];
317
- for (const p of denyPatterns) {
318
- if (p.test(cmd)) {
319
- return { behavior: "deny", message: `Blocked by policy: ${cmd.slice(0, 100)}` };
320
- }
321
- }
322
- }
323
-
324
- // --- Ambiguous: escalate to dashboard ---
325
- try {
326
- const serverUrl = config.server || "http://localhost:4243";
327
- const apiKey = config.apiKey || "";
328
- const resp = await fetch(`${serverUrl}/api/permissions/request`, {
329
- method: "POST",
330
- headers: {
331
- "Content-Type": "application/json",
332
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
333
- },
334
- body: JSON.stringify({
335
- workspace: config.workspace,
336
- tool_name: toolName,
337
- tool_input: input,
338
- }),
339
- });
340
- if (resp.ok) {
341
- const data = await resp.json();
342
- if (data.allow) {
343
- return { behavior: "allow", updatedInput: input };
344
- }
345
- return { behavior: "deny", message: data.reason || "Denied by human" };
346
- }
347
- } catch (err) {
348
- console.error(`[policy] Dashboard escalation failed: ${err.message}`);
349
- }
350
-
351
- // Default deny if escalation fails
352
- return { behavior: "deny", message: "Could not reach dashboard for approval" };
353
- }
354
-
355
-
356
- // --- Server setup & dispatch -------------------------------------------------
357
-
358
- const server = new Server(
359
- { name: "fathom-mcp", version: "0.1.0" },
360
- { capabilities: { tools: {} } },
361
- );
362
-
363
- server.setRequestHandler(ListToolsRequestSchema, async () => {
364
- const allTools = [...tools, ...policyTools];
365
- return { tools: allTools };
366
- });
367
-
368
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
369
- const { name, arguments: args } = request.params;
370
- let result;
371
-
372
- switch (name) {
373
- // --- Search tools (server-routed) ---
374
- case "fathom_vault_search":
375
- result = await client.search(args.query, { mode: "bm25", limit: args.limit, ws: args.workspace });
376
- break;
377
- case "fathom_vault_vsearch":
378
- result = await client.vsearch(args.query, { limit: args.limit, ws: args.workspace });
379
- break;
380
- case "fathom_vault_query":
381
- result = await client.hybridSearch(args.query, { limit: args.limit, ws: args.workspace });
382
- break;
383
- case "fathom_room_post":
384
- result = await client.roomPost(args.room, args.message, config.workspace);
385
- break;
386
- case "fathom_room_read":
387
- result = await client.roomRead(
388
- args.room, args.minutes, args.start,
389
- args.mark_read !== false ? config.workspace : undefined,
390
- args.mark_read,
391
- );
392
- break;
393
- case "fathom_room_list":
394
- result = await client.roomList(config.workspace);
395
- break;
396
- case "fathom_room_describe":
397
- result = await client.roomDescribe(args.room, args.description);
398
- break;
399
- case "fathom_workspaces":
400
- result = await client.listWorkspaces();
401
- break;
402
- case "fathom_send":
403
- result = await client.sendToWorkspace(args.workspace, args.message, config.workspace);
404
- break;
405
- case "fathom_routine_list":
406
- result = await client.listRoutines(args.workspace || config.workspace);
407
- break;
408
- case "fathom_routine_fire":
409
- result = await client.fireRoutine(args.routine_id, args.workspace || config.workspace);
410
- break;
411
- // --- TTS ---
412
- case "fathom_speak": {
413
- if (!args.text && !args.file) {
414
- result = { error: "Either text or file parameter is required." };
415
- break;
416
- }
417
- result = await client.speak({
418
- text: args.text,
419
- file: args.file,
420
- speed: args.speed,
421
- play: args.play,
422
- });
423
- break;
424
- }
425
- case "fathom_send_voice": {
426
- if (!args.text) {
427
- result = { error: "text parameter is required." };
428
- break;
429
- }
430
- result = await client.voiceReply({
431
- text: args.text,
432
- speed: args.speed,
433
- workspace: config.workspace,
434
- });
435
- break;
436
- }
437
-
438
- // --- Key rotation ---
439
- case "fathom_key_rotate": {
440
- const rotateResult = await client.rotateKey();
441
- if (rotateResult.error) {
442
- result = { error: `Key rotation failed: ${rotateResult.error}` };
443
- break;
444
- }
445
-
446
- // Update .fathom.json with new key
447
- try {
448
- const { findConfigFile } = await import("./config.js");
449
- const found = findConfigFile();
450
- if (found) {
451
- const updated = { ...found.config, apiKey: rotateResult.api_key };
452
- const { writeFileSync } = await import("fs");
453
- writeFileSync(found.path, JSON.stringify(updated, null, 2) + "\n");
454
- }
455
- } catch {
456
- // .fathom.json update failed — still return the new key
457
- }
458
-
459
- // Reconnect WebSocket with new key
460
- if (wsConn) {
461
- wsConn.close();
462
- config.apiKey = rotateResult.api_key;
463
- wsConn = createWSConnection(config);
464
- }
465
-
466
- result = {
467
- ok: true,
468
- workspace: rotateResult.workspace,
469
- message: "Key rotated successfully. .fathom.json updated, WebSocket reconnected.",
470
- };
471
- break;
472
- }
473
-
474
- // --- Policy evaluation (permission-prompt-tool for stream-json agents) ---
475
- case "policy_evaluate": {
476
- result = await evaluatePermission(args.tool_name, args.input || {});
477
- break;
478
- }
479
- case "permission_respond": {
480
- const serverUrl = config.server || "http://localhost:4243";
481
- const apiKey = config.apiKey || "";
482
- const resp = await fetch(
483
- `${serverUrl}/api/permissions/${encodeURIComponent(args.request_id)}/respond`,
484
- {
485
- method: "POST",
486
- headers: {
487
- "Content-Type": "application/json",
488
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
489
- },
490
- body: JSON.stringify({
491
- allow: args.allow,
492
- reason: args.reason || "policy-gate decision",
493
- workspace: config.workspace,
494
- }),
495
- }
496
- );
497
- if (resp.ok) {
498
- result = await resp.json();
499
- } else {
500
- const errBody = await resp.text();
501
- result = { error: `Server returned ${resp.status}: ${errBody}` };
502
- }
503
- break;
504
- }
505
-
506
- default:
507
- result = { error: `Unknown tool: ${name}` };
508
- }
509
-
510
- // Image tool returns a special content block
511
- if (result?._image) {
512
- return {
513
- content: [{ type: "image", data: result.data, mimeType: result.mimeType }],
514
- };
515
- }
516
-
517
- return {
518
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
519
- isError: !!result?.error,
520
- };
521
- });
522
-
523
- async function main() {
524
- // Auto-register workspace with server (fire-and-forget)
525
- if (config.server && config.workspace) {
526
- client.registerWorkspace(config.workspace, config._projectDir, {
527
- vault: config._rawVault,
528
- agents: config.agents,
529
- type: config.vaultMode,
530
- }).catch(() => {});
531
- }
532
-
533
- // WebSocket push channel — receives server-pushed messages
534
- if (config.server && config.workspace && config.apiKey) {
535
- wsConn = createWSConnection(config);
536
- }
537
-
538
- // Heartbeat — report liveness to server every 30s (kept for backwards compat)
539
- if (config.server && config.workspace) {
540
- const beat = () =>
541
- client
542
- .heartbeat(config.workspace, config.agents?.[0], config.vaultMode)
543
- .catch(() => {});
544
- beat(); // immediate
545
- setInterval(beat, 30_000);
546
- }
547
-
548
- const transport = new StdioServerTransport();
549
- await server.connect(transport);
550
- }
551
-
552
- main().catch(console.error);