fathom-mcp 0.5.19 → 0.5.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Single source of truth for all agent definitions. The CLI uses this
5
5
  * for list/start/stop/restart/add/remove/config commands.
6
+ *
7
+ * All agents use stream-json transport — lifecycle delegated to fathom-server.
6
8
  */
7
9
 
8
10
  import fs from "fs";
9
11
  import path from "path";
10
- import { execSync, execFileSync } from "child_process";
12
+ import { execSync } from "child_process";
11
13
 
12
14
  const CONFIG_DIR = process.env.FATHOM_CONFIG_DIR || path.join(process.env.HOME || "/tmp", ".config", "fathom-mcp");
13
15
  const AGENTS_FILE = path.join(CONFIG_DIR, "agents.json");
@@ -65,12 +67,17 @@ export function removeAgent(name) {
65
67
  export function isAgentRunning(name, entry) {
66
68
  if (entry.ssh) return "ssh";
67
69
 
68
- const session = `${name}_fathom-session`;
70
+ // All agents: check via server API
69
71
  try {
70
- execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, {
71
- stdio: "pipe",
72
+ const serverUrl = entry.server || "http://localhost:4243";
73
+ const apiKey = entry.apiKey || "";
74
+ const url = `${serverUrl}/api/activation/session?workspace=${encodeURIComponent(name)}`;
75
+ const resp = execSync(`curl -sf -H "Authorization: Bearer ${apiKey}" "${url}"`, {
76
+ encoding: "utf-8",
77
+ timeout: 5000,
72
78
  });
73
- return "running";
79
+ const data = JSON.parse(resp);
80
+ return data.running ? "running" : "stopped";
74
81
  } catch {
75
82
  return "stopped";
76
83
  }
@@ -78,174 +85,54 @@ export function isAgentRunning(name, entry) {
78
85
 
79
86
  // ── Start / Stop ────────────────────────────────────────────────────────────
80
87
 
81
- /**
82
- * Default command per agent type + execution mode.
83
- */
84
- export function defaultCommand(agentType) {
85
- const cmds = {
86
- "claude-code": "claude --continue",
87
- codex: "codex",
88
- gemini: "gemini",
89
- opencode: "opencode",
90
- };
91
- return cmds[agentType] || cmds["claude-code"];
92
- }
93
-
94
- /**
95
- * Check if a Claude Code project has any previous conversations to --continue from.
96
- * Claude stores conversations as .jsonl files in ~/.claude/projects/<encoded-path>/.
97
- *
98
- * The encoded directory name uses the *resolved* path that Claude sees at runtime,
99
- * which may differ from agents.json projectDir (e.g., container bind-mounts).
100
- * We resolve the real path and also check the literal path as a fallback.
101
- */
102
- function hasPreviousConversation(projectDir) {
103
- const claudeProjectsDir = path.join(process.env.HOME || "/tmp", ".claude", "projects");
104
-
105
- // Try both the literal path and the real (resolved symlink/bind-mount) path
106
- const candidates = new Set([projectDir]);
107
- try {
108
- candidates.add(fs.realpathSync(projectDir));
109
- } catch {
110
- // projectDir might not exist on this machine (e.g., host path checked from container)
111
- }
112
-
113
- for (const dir of candidates) {
114
- const encoded = dir.replace(/\//g, "-");
115
- const convDir = path.join(claudeProjectsDir, encoded);
116
- try {
117
- const files = fs.readdirSync(convDir);
118
- if (files.some((f) => f.endsWith(".jsonl"))) return true;
119
- } catch {
120
- // continue to next candidate
121
- }
122
- }
123
-
124
- return false;
125
- }
126
-
127
88
  export function startAgent(name, entry) {
128
- let command = entry.command || defaultCommand(entry.agentType || "claude-code");
129
-
130
- // --continue crashes if there's no previous conversation. Strip it for new agents.
131
- if (command.includes("--continue") && !hasPreviousConversation(entry.projectDir)) {
132
- command = command.replace(/\s*--continue\b/g, "");
133
- }
134
-
135
- const session = `${name}_fathom-session`;
136
-
137
- // Build env
138
- const env = {
139
- ...process.env,
140
- PATH: `${process.env.HOME}/.local/bin:${process.env.HOME}/.claude/local/bin:${process.env.PATH}`,
141
- ...(entry.env || {}),
142
- };
143
- // Remove CLAUDECODE to avoid nested session detection
144
- delete env.CLAUDECODE;
145
-
146
- if (entry.ssh) {
147
- return startAgentSSH(name, entry, command, env);
148
- }
149
-
150
- return startAgentTmux(name, entry, command, session, env);
151
- }
152
-
153
- function startAgentTmux(name, entry, command, session, env) {
154
- // Check if already running
89
+ // All agents: delegate lifecycle to fathom-server
90
+ const serverUrl = entry.server || "http://localhost:4243";
91
+ const apiKey = entry.apiKey || "";
155
92
  try {
156
- execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, {
157
- stdio: "pipe",
93
+ const url = `${serverUrl}/api/activation/session/start?workspace=${encodeURIComponent(name)}`;
94
+ const resp = execSync(`curl -sf -X POST -H "Authorization: Bearer ${apiKey}" "${url}"`, {
95
+ encoding: "utf-8",
96
+ timeout: 30000,
158
97
  });
159
- // Save pane ID in case it wasn't saved
160
- savePaneId(name, session);
161
- return { ok: true, message: `Session already running: ${session}`, alreadyRunning: true };
162
- } catch {
163
- // Not running — continue
164
- }
165
-
166
- try {
167
- execSync(
168
- `tmux new-session -d -s ${JSON.stringify(session)} -c ${JSON.stringify(entry.projectDir)} ${command}`,
169
- { stdio: "pipe", env },
170
- );
98
+ return { ok: true, message: `Delegated to server subprocess manager: ${resp.trim()}` };
171
99
  } catch (e) {
172
- return { ok: false, message: `Failed to start tmux session: ${e.message}` };
173
- }
174
-
175
- // Brief wait for session to stabilize, then save pane ID
176
- try {
177
- execSync("sleep 1", { stdio: "pipe" });
178
- } catch {
179
- // ignore
180
- }
181
- savePaneId(name, session);
182
-
183
- return { ok: true, message: `Started: ${session}` };
184
- }
185
-
186
- function savePaneId(name, session) {
187
- try {
188
- const paneId = execSync(
189
- `tmux list-panes -t ${JSON.stringify(session)} -F '#{pane_id}' 2>/dev/null | head -1`,
190
- { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
191
- ).trim();
192
- if (paneId) {
193
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
194
- fs.writeFileSync(path.join(CONFIG_DIR, `${name}-pane-id`), paneId + "\n");
195
- }
196
- } catch {
197
- // Non-critical
198
- }
199
- }
200
-
201
- function startAgentSSH(name, entry, command, env) {
202
- const { host, user, key } = entry.ssh;
203
- const sshArgs = [];
204
- if (key) sshArgs.push("-i", key);
205
- const target = user ? `${user}@${host}` : host;
206
- const remoteCmd = `cd ${JSON.stringify(entry.projectDir)} && ${command}`;
207
-
208
- try {
209
- execFileSync("ssh", [...sshArgs, target, remoteCmd], {
210
- stdio: "inherit",
211
- env,
212
- });
213
- return { ok: true, message: `SSH agent started on ${target}` };
214
- } catch (e) {
215
- return { ok: false, message: `SSH start failed: ${e.message}` };
100
+ return { ok: false, message: `Server delegation failed: ${e.message}` };
216
101
  }
217
102
  }
218
103
 
219
104
  export function stopAgent(name, entry) {
220
- const session = `${name}_fathom-session`;
221
- const messages = [];
222
- let stopped = false;
223
-
224
105
  if (entry.ssh) {
225
106
  return { ok: false, message: "Cannot stop SSH agents remotely — connect to the host directly." };
226
107
  }
227
108
 
228
- // Kill tmux session
109
+ // All agents: delegate to server
229
110
  try {
230
- execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, { stdio: "pipe" });
231
- execSync(`tmux kill-session -t ${JSON.stringify(session)}`, { stdio: "pipe" });
232
- // Remove pane ID file
233
- try {
234
- fs.unlinkSync(path.join(CONFIG_DIR, `${name}-pane-id`));
235
- } catch {
236
- // ignore
237
- }
238
- messages.push(`Killed tmux session: ${session}`);
239
- stopped = true;
240
- } catch {
241
- // No tmux session
242
- }
243
-
244
- if (!stopped) {
245
- return { ok: false, message: `No running session found for: ${name}` };
111
+ const serverUrl = entry.server || "http://localhost:4243";
112
+ const apiKey = entry.apiKey || "";
113
+ const url = `${serverUrl}/api/activation/session/stop?workspace=${encodeURIComponent(name)}`;
114
+ const resp = execSync(`curl -sf -X POST -H "Authorization: Bearer ${apiKey}" "${url}"`, {
115
+ encoding: "utf-8",
116
+ timeout: 15000,
117
+ });
118
+ return { ok: true, message: `Server stopped subprocess: ${resp.trim()}` };
119
+ } catch (e) {
120
+ return { ok: false, message: `Server stop failed: ${e.message}` };
246
121
  }
122
+ }
247
123
 
248
- return { ok: true, message: messages.join("\n") };
124
+ /**
125
+ * Default command per agent type (vestigial — used by CLI `add` flow).
126
+ * Stream-json agents don't use this at runtime; the server manages lifecycle.
127
+ */
128
+ export function defaultCommand(agentType) {
129
+ const cmds = {
130
+ "claude-code": "claude",
131
+ codex: "codex",
132
+ gemini: "gemini",
133
+ opencode: "opencode",
134
+ };
135
+ return cmds[agentType] || cmds["claude-code"];
249
136
  }
250
137
 
251
138
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -258,7 +145,7 @@ export function buildEntryFromConfig(projectDir, fathomConfig) {
258
145
  return {
259
146
  projectDir,
260
147
  agentType,
261
- command: defaultCommand(agentType),
148
+ command: "",
262
149
  server: fathomConfig.server || "http://localhost:4243",
263
150
  apiKey: fathomConfig.apiKey || "",
264
151
  vault: fathomConfig.vault || "vault",
package/src/index.js CHANGED
@@ -483,26 +483,143 @@ const telegramTools = [
483
483
  },
484
484
  ];
485
485
 
486
- // --- Primary-agent-only tools (policy gate) ----------------------------------
486
+ // --- Policy evaluation tool (permission-prompt-tool for stream-json) ---------
487
487
 
488
- const primaryAgentTools = [
488
+ const policyTools = [
489
489
  {
490
- name: "fathom_session_inject",
490
+ name: "policy_evaluate",
491
491
  description:
492
- "Inject a keystroke into a workspace's tmux session. Primary agent only. " +
493
- "Used by the policy gate to respond to permission prompts. " +
494
- "Keys must be a single digit 1-9 (to select a numbered option) or a named key (Enter, Escape).",
492
+ "Evaluate a permission request for a tool call. Called automatically by Claude " +
493
+ "via --permission-prompt-tool when a tool is not in the static allow list. " +
494
+ "Returns {behavior: 'allow'} or {behavior: 'deny', message: '...'}.",
495
495
  inputSchema: {
496
496
  type: "object",
497
497
  properties: {
498
- workspace: { type: "string", description: "Target workspace name (e.g. 'navier-stokes')" },
499
- keys: { type: "string", description: "Keys to send — single digit 1-9 or named key (Enter, Escape)" },
498
+ tool_name: { type: "string", description: "The tool being requested (e.g. 'Bash', 'Edit')" },
499
+ input: { type: "object", description: "The tool's input arguments" },
500
500
  },
501
- required: ["workspace", "keys"],
501
+ required: ["tool_name", "input"],
502
502
  },
503
503
  },
504
504
  ];
505
505
 
506
+ // --- Primary-agent-only tools (reserved for future use) ----------------------
507
+
508
+ const primaryAgentTools = [];
509
+
510
+ // --- Policy evaluation -------------------------------------------------------
511
+
512
+ /**
513
+ * Evaluate a permission request from Claude's --permission-prompt-tool.
514
+ *
515
+ * Decision logic:
516
+ * 1. Allow list: auto-approve safe tools/commands
517
+ * 2. Hard deny: auto-reject dangerous operations
518
+ * 3. Ambiguous: escalate to dashboard for human approval
519
+ */
520
+ async function evaluatePermission(toolName, input) {
521
+ const projectDir = config.vault ? path.dirname(config.vault) : process.cwd();
522
+
523
+ // --- Allow list (auto-approve) ---
524
+ const alwaysAllowed = [
525
+ "mcp__fathom-vault__", "mcp__fathom__", "mcp__memento__",
526
+ "Read", "Glob", "Grep", "Agent",
527
+ ];
528
+ for (const prefix of alwaysAllowed) {
529
+ if (toolName === prefix || toolName.startsWith(prefix)) {
530
+ return { behavior: "allow" };
531
+ }
532
+ }
533
+
534
+ // Edit/Write — allow if path within workspace
535
+ if (toolName === "Edit" || toolName === "Write") {
536
+ const filePath = input.file_path || input.path || "";
537
+ if (filePath.startsWith(projectDir)) {
538
+ return { behavior: "allow" };
539
+ }
540
+ // Paths in home .claude dir are ok
541
+ const homeDir = process.env.HOME || "/tmp";
542
+ if (filePath.startsWith(path.join(homeDir, ".claude"))) {
543
+ return { behavior: "allow" };
544
+ }
545
+ }
546
+
547
+ // Bash — check against safe command patterns
548
+ if (toolName === "Bash") {
549
+ const cmd = (input.command || "").trim();
550
+ const safePatterns = [
551
+ /^(npm|npx|node|bun|deno|pnpm|yarn)\s/,
552
+ /^git\s+(status|log|diff|branch|show|remote|fetch|stash)/,
553
+ /^(ls|pwd|which|whoami|date|uname|cat|head|tail|wc)\b/,
554
+ /^(cd|echo|printf)\s/,
555
+ /^(curl|wget)\s/,
556
+ /^(python|python3|pip)\s/,
557
+ /^(cargo|rustc|go)\s/,
558
+ /^(make|cmake)\b/,
559
+ /^(docker|podman)\s+(ps|images|logs|inspect|compose\s+(ps|logs))/,
560
+ /^(tmux|screen)\s+(list|ls|has-session)/,
561
+ /^mkdir\s/,
562
+ /^cp\s/,
563
+ /^mv\s/,
564
+ /^touch\s/,
565
+ /^chmod\s/,
566
+ /^test\s/,
567
+ /^\[/,
568
+ ];
569
+ if (safePatterns.some(p => p.test(cmd))) {
570
+ return { behavior: "allow" };
571
+ }
572
+ }
573
+
574
+ // --- Hard deny list ---
575
+ if (toolName === "Bash") {
576
+ const cmd = (input.command || "").trim();
577
+ const denyPatterns = [
578
+ /\brm\s+-rf\s+[\/~]/, // rm -rf broad paths
579
+ /\bsudo\b/, // privilege escalation
580
+ /\bsu\s/, // switch user
581
+ /\bgit\s+push\s+.*--force\s.*(main|master)/, // force push to main
582
+ /\b(\.ssh|\.gnupg)\b/, // sensitive directories
583
+ ];
584
+ for (const p of denyPatterns) {
585
+ if (p.test(cmd)) {
586
+ return { behavior: "deny", message: `Blocked by policy: ${cmd.slice(0, 100)}` };
587
+ }
588
+ }
589
+ }
590
+
591
+ // --- Ambiguous: escalate to dashboard ---
592
+ try {
593
+ const serverUrl = config.server || "http://localhost:4243";
594
+ const apiKey = config.apiKey || "";
595
+ const resp = await fetch(`${serverUrl}/api/permissions/request`, {
596
+ method: "POST",
597
+ headers: {
598
+ "Content-Type": "application/json",
599
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
600
+ },
601
+ body: JSON.stringify({
602
+ workspace: config.workspace,
603
+ tool_name: toolName,
604
+ tool_input: input,
605
+ }),
606
+ });
607
+ if (resp.ok) {
608
+ const data = await resp.json();
609
+ if (data.allow) {
610
+ return { behavior: "allow" };
611
+ }
612
+ return { behavior: "deny", message: data.reason || "Denied by human" };
613
+ }
614
+ } catch (err) {
615
+ console.error(`[policy] Dashboard escalation failed: ${err.message}`);
616
+ }
617
+
618
+ // Default deny if escalation fails
619
+ return { behavior: "deny", message: "Could not reach dashboard for approval" };
620
+ }
621
+
622
+
506
623
  // --- Server setup & dispatch -------------------------------------------------
507
624
 
508
625
  const server = new Server(
@@ -521,7 +638,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
521
638
  } catch {
522
639
  // If settings unavailable, hide telegram tools
523
640
  }
524
- const allTools = [...tools, ...(showTelegram ? [...telegramTools, ...primaryAgentTools] : [])];
641
+ const allTools = [...tools, ...policyTools, ...(showTelegram ? [...telegramTools, ...primaryAgentTools] : [])];
525
642
  return { tools: allTools };
526
643
  });
527
644
 
@@ -817,13 +934,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
817
934
  }
818
935
  break;
819
936
  }
820
- // --- Session injection (policy gate) ---
821
- case "fathom_session_inject": {
822
- if (!args.workspace) { result = { error: "workspace is required" }; break; }
823
- if (!args.keys) { result = { error: "keys is required" }; break; }
824
- result = await client.injectKeys(args.workspace, args.keys);
937
+ // --- Policy evaluation (permission-prompt-tool for stream-json agents) ---
938
+ case "policy_evaluate": {
939
+ result = await evaluatePermission(args.tool_name, args.input || {});
825
940
  break;
826
941
  }
942
+
827
943
  case "fathom_telegram_send_voice": {
828
944
  const voiceContactArg = args.contact;
829
945
  if (!voiceContactArg) { result = { error: "contact is required" }; break; }
@@ -309,14 +309,6 @@ export function createClient(config) {
309
309
  });
310
310
  }
311
311
 
312
- // --- Session injection (policy gate) ----------------------------------------
313
-
314
- async function injectKeys(targetWorkspace, keys) {
315
- return request("POST", `/api/session/${encodeURIComponent(targetWorkspace)}/inject`, {
316
- body: { keys },
317
- });
318
- }
319
-
320
312
  // --- Settings --------------------------------------------------------------
321
313
 
322
314
  async function getSettings() {
@@ -381,7 +373,6 @@ export function createClient(config) {
381
373
  telegramSendVoice,
382
374
  telegramStatus,
383
375
  speak,
384
- injectKeys,
385
376
  getSettings,
386
377
  getApiKey,
387
378
  rotateKey,
@@ -2,15 +2,15 @@
2
2
  * WebSocket push channel — receives server-pushed messages and handles them locally.
3
3
  *
4
4
  * Connects to fathom-server's /ws/agent/{workspace} endpoint. Receives:
5
- * - inject / ping_fire → tmux send-keys into local pane
6
5
  * - image → cache base64 data to .fathom/telegram-cache/
7
6
  * - ping → respond with pong
8
7
  *
8
+ * Stream-json agents handle inject/ping_fire via subprocess stdin — the server
9
+ * writes directly, so no tmux injection is needed here.
10
+ *
9
11
  * Auto-reconnects with exponential backoff (1s → 60s cap).
10
- * HTTP heartbeat still runs separately for backwards compat with old servers.
11
12
  */
12
13
 
13
- import { execSync } from "child_process";
14
14
  import fs from "fs";
15
15
  import os from "os";
16
16
  import path from "path";
@@ -96,8 +96,8 @@ export function createWSConnection(config) {
96
96
 
97
97
  case "inject":
98
98
  case "ping_fire":
99
- console.error(`[ws] received ${msg.type} (${(msg.text || "").length} chars)`);
100
- injectMessage(msg.text || "");
99
+ // Stream-json agents handle injection via subprocess stdin on the server side
100
+ console.error(`[ws] received ${msg.type} (${(msg.text || "").length} chars) — handled by server subprocess`);
101
101
  break;
102
102
 
103
103
  case "image":
@@ -160,50 +160,6 @@ export function createWSConnection(config) {
160
160
  reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
161
161
  }
162
162
 
163
- // ── Message injection ───────────────────────────────────────────────────────
164
-
165
- function injectMessage(text) {
166
- if (!text) return;
167
- injectToTmux(text);
168
- }
169
-
170
- function injectToTmux(text) {
171
- if (!text) return;
172
- const pane = resolvePaneTarget();
173
-
174
- try {
175
- // Send the text literally, pause for tmux to render, then press Enter
176
- execSync(`tmux send-keys -t ${shellEscape(pane)} -l ${shellEscape(text)}`, {
177
- timeout: 5000,
178
- stdio: "ignore",
179
- });
180
- execSync("sleep 2", { timeout: 5000 });
181
- execSync(`tmux send-keys -t ${shellEscape(pane)} Enter`, {
182
- timeout: 5000,
183
- stdio: "ignore",
184
- });
185
- } catch (err) {
186
- console.error(`[ws] tmux inject failed for ${pane}: ${err.message}`);
187
- }
188
- }
189
-
190
- function resolvePaneTarget() {
191
- // Check for explicit pane ID file
192
- const paneIdFile = path.join(os.homedir(), ".config", "fathom", `${workspace}-pane-id`);
193
- try {
194
- const paneId = fs.readFileSync(paneIdFile, "utf-8").trim();
195
- if (paneId) return paneId;
196
- } catch {
197
- // Fall through to default
198
- }
199
- return `${workspace}_fathom-session`;
200
- }
201
-
202
- function shellEscape(s) {
203
- // Escape for shell — wrap in single quotes, escape internal single quotes
204
- return "'" + s.replace(/'/g, "'\\''") + "'";
205
- }
206
-
207
163
  // ── Image cache ─────────────────────────────────────────────────────────────
208
164
 
209
165
  function cacheImage(msg) {