fathom-mcp 0.4.13 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.4.13",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.26.0",
26
- "js-yaml": "^4.1.0"
26
+ "js-yaml": "^4.1.0",
27
+ "ws": "^8.18.0"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@eslint/js": "^9.39.3",
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
 
21
21
  import { resolveConfig } from "./config.js";
22
22
  import { createClient } from "./server-client.js";
23
+ import { createWSConnection } from "./ws-connection.js";
23
24
  import {
24
25
  handleVaultWrite,
25
26
  handleVaultAppend,
@@ -32,6 +33,7 @@ import {
32
33
 
33
34
  const config = resolveConfig();
34
35
  const client = createClient(config);
36
+ let wsConn = null;
35
37
 
36
38
  // --- Tool definitions --------------------------------------------------------
37
39
 
@@ -511,6 +513,36 @@ const telegramTools = [
511
513
  required: ["contact", "message"],
512
514
  },
513
515
  },
516
+ {
517
+ name: "fathom_telegram_image",
518
+ description:
519
+ "Read a Telegram message's attached image and return it as base64 so Claude can perceive it. " +
520
+ "Use after fathom_telegram_read shows a message has media: true. " +
521
+ "Supports jpg, jpeg, png, gif, webp. Max 5MB.",
522
+ inputSchema: {
523
+ type: "object",
524
+ properties: {
525
+ message_id: { type: "number", description: "The message ID from fathom_telegram_read results" },
526
+ },
527
+ required: ["message_id"],
528
+ },
529
+ },
530
+ {
531
+ name: "fathom_telegram_send_image",
532
+ description:
533
+ "Send an image to a Telegram contact via the persistent Telethon client. " +
534
+ "Provide an absolute file path to a local image. Optionally include a caption. " +
535
+ "Contact can be a name, @username, or chat_id number.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ contact: { type: "string", description: "Contact name, @username, or chat_id" },
540
+ file_path: { type: "string", description: "Absolute path to the image file to send" },
541
+ caption: { type: "string", description: "Optional caption text for the image (max 1024 chars)" },
542
+ },
543
+ required: ["contact", "file_path"],
544
+ },
545
+ },
514
546
  ];
515
547
 
516
548
  // --- Server setup & dispatch -------------------------------------------------
@@ -757,6 +789,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
757
789
  }
758
790
  break;
759
791
  }
792
+ case "fathom_telegram_image": {
793
+ const msgId = args.message_id;
794
+ if (!msgId) {
795
+ result = { error: "message_id is required" };
796
+ } else {
797
+ // Check local WebSocket cache first (avoids HTTP round-trip for pushed images)
798
+ const cached = wsConn?.getCachedImage(msgId);
799
+ if (cached) {
800
+ result = { _image: true, data: cached.data, mimeType: cached.mimeType };
801
+ } else {
802
+ result = await client.telegramImage(msgId);
803
+ if (result?.data && result?.mimeType) {
804
+ result = { _image: true, data: result.data, mimeType: result.mimeType };
805
+ }
806
+ }
807
+ }
808
+ break;
809
+ }
810
+ case "fathom_telegram_send_image": {
811
+ const imgContactArg = args.contact;
812
+ if (!imgContactArg) { result = { error: "contact is required" }; break; }
813
+ if (!args.file_path) { result = { error: "file_path is required" }; break; }
814
+
815
+ const imgContacts = await client.telegramContacts(config.workspace);
816
+ const imgList = imgContacts?.contacts || [];
817
+ let imgChatId = parseInt(imgContactArg, 10);
818
+ if (isNaN(imgChatId)) {
819
+ const lower = imgContactArg.toLowerCase().replace(/^@/, "");
820
+ const match = imgList.find(c =>
821
+ (c.username || "").toLowerCase() === lower ||
822
+ (c.first_name || "").toLowerCase() === lower ||
823
+ (c.first_name || "").toLowerCase().includes(lower)
824
+ );
825
+ imgChatId = match ? match.chat_id : null;
826
+ }
827
+ if (!imgChatId) {
828
+ result = { error: `Contact not found: ${imgContactArg}. Use fathom_telegram_contacts to list known contacts.` };
829
+ } else {
830
+ result = await client.telegramSendImage(imgChatId, args.file_path, args.caption);
831
+ }
832
+ break;
833
+ }
760
834
  default:
761
835
  result = { error: `Unknown tool: ${name}` };
762
836
  }
@@ -842,7 +916,12 @@ async function main() {
842
916
  // Startup sync for synced mode (fire-and-forget)
843
917
  startupSync().catch(() => {});
844
918
 
845
- // Heartbeat report liveness to server every 30s
919
+ // WebSocket push channel receives server-pushed messages
920
+ if (config.server && config.workspace && config.apiKey) {
921
+ wsConn = createWSConnection(config);
922
+ }
923
+
924
+ // Heartbeat — report liveness to server every 30s (kept for backwards compat)
846
925
  if (config.server && config.workspace) {
847
926
  const beat = () =>
848
927
  client
@@ -265,6 +265,16 @@ export function createClient(config) {
265
265
  });
266
266
  }
267
267
 
268
+ async function telegramImage(messageId) {
269
+ return request("GET", `/api/telegram/image/${messageId}`);
270
+ }
271
+
272
+ async function telegramSendImage(chatId, filePath, caption) {
273
+ return request("POST", `/api/telegram/send-image/${chatId}`, {
274
+ body: { file_path: filePath, caption: caption || "" },
275
+ });
276
+ }
277
+
268
278
  async function telegramStatus() {
269
279
  return request("GET", "/api/telegram/status");
270
280
  }
@@ -323,6 +333,8 @@ export function createClient(config) {
323
333
  telegramContacts,
324
334
  telegramRead,
325
335
  telegramSend,
336
+ telegramImage,
337
+ telegramSendImage,
326
338
  telegramStatus,
327
339
  getSettings,
328
340
  getApiKey,
@@ -0,0 +1,250 @@
1
+ /**
2
+ * WebSocket push channel — receives server-pushed messages and handles them locally.
3
+ *
4
+ * Connects to fathom-server's /ws/agent/{workspace} endpoint. Receives:
5
+ * - inject / ping_fire → tmux send-keys into local pane
6
+ * - image → cache base64 data to .fathom/telegram-cache/
7
+ * - ping → respond with pong
8
+ *
9
+ * Auto-reconnects with exponential backoff (1s → 60s cap).
10
+ * HTTP heartbeat still runs separately for backwards compat with old servers.
11
+ */
12
+
13
+ import { execSync } from "child_process";
14
+ import fs from "fs";
15
+ import os from "os";
16
+ import path from "path";
17
+ import WebSocket from "ws";
18
+
19
+ const KEEPALIVE_INTERVAL_MS = 30_000;
20
+ const INITIAL_RECONNECT_MS = 1_000;
21
+ const MAX_RECONNECT_MS = 60_000;
22
+ const IMAGE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
23
+
24
+ /**
25
+ * @param {object} config — resolved config from config.js
26
+ * @returns {{ getCachedImage: (messageId: number) => {data: string, mimeType: string} | null, close: () => void }}
27
+ */
28
+ export function createWSConnection(config) {
29
+ const workspace = config.workspace;
30
+ const agent = config.agents?.[0] || "unknown";
31
+ const vaultMode = config.vaultMode || "local";
32
+
33
+ // Derive WS URL from HTTP server URL
34
+ const serverUrl = config.server || "http://localhost:4243";
35
+ const wsUrl = serverUrl
36
+ .replace(/^http:/, "ws:")
37
+ .replace(/^https:/, "wss:")
38
+ + `/ws/agent/${encodeURIComponent(workspace)}`
39
+ + `?token=${encodeURIComponent(config.apiKey || "")}`;
40
+
41
+ // Image cache directory
42
+ const cacheDir = path.join(os.homedir(), ".fathom", "telegram-cache");
43
+
44
+ let ws = null;
45
+ let reconnectDelay = INITIAL_RECONNECT_MS;
46
+ let keepaliveTimer = null;
47
+ let closed = false;
48
+
49
+ // Clean up old cached images on startup
50
+ cleanupImageCache();
51
+
52
+ connect();
53
+
54
+ function connect() {
55
+ if (closed) return;
56
+
57
+ try {
58
+ ws = new WebSocket(wsUrl);
59
+ } catch {
60
+ scheduleReconnect();
61
+ return;
62
+ }
63
+
64
+ ws.on("open", () => {
65
+ reconnectDelay = INITIAL_RECONNECT_MS;
66
+
67
+ // Send hello handshake
68
+ ws.send(JSON.stringify({
69
+ type: "hello",
70
+ agent,
71
+ vault_mode: vaultMode,
72
+ }));
73
+
74
+ // Start keepalive pong timer
75
+ startKeepalive();
76
+ });
77
+
78
+ ws.on("message", (raw) => {
79
+ let msg;
80
+ try {
81
+ msg = JSON.parse(raw.toString());
82
+ } catch {
83
+ return;
84
+ }
85
+
86
+ switch (msg.type) {
87
+ case "welcome":
88
+ break;
89
+
90
+ case "inject":
91
+ case "ping_fire":
92
+ injectToTmux(msg.text || "");
93
+ break;
94
+
95
+ case "image":
96
+ cacheImage(msg);
97
+ break;
98
+
99
+ case "ping":
100
+ safeSend({ type: "pong" });
101
+ break;
102
+
103
+ case "error":
104
+ // Server rejected us — don't reconnect immediately
105
+ reconnectDelay = MAX_RECONNECT_MS;
106
+ break;
107
+ }
108
+ });
109
+
110
+ ws.on("close", () => {
111
+ stopKeepalive();
112
+ if (!closed) scheduleReconnect();
113
+ });
114
+
115
+ ws.on("error", () => {
116
+ // Error always followed by close event — reconnect handled there
117
+ stopKeepalive();
118
+ });
119
+ }
120
+
121
+ function safeSend(obj) {
122
+ try {
123
+ if (ws && ws.readyState === WebSocket.OPEN) {
124
+ ws.send(JSON.stringify(obj));
125
+ }
126
+ } catch {
127
+ // Swallow — close event will trigger reconnect
128
+ }
129
+ }
130
+
131
+ function startKeepalive() {
132
+ stopKeepalive();
133
+ keepaliveTimer = setInterval(() => {
134
+ safeSend({ type: "pong" });
135
+ }, KEEPALIVE_INTERVAL_MS);
136
+ }
137
+
138
+ function stopKeepalive() {
139
+ if (keepaliveTimer) {
140
+ clearInterval(keepaliveTimer);
141
+ keepaliveTimer = null;
142
+ }
143
+ }
144
+
145
+ function scheduleReconnect() {
146
+ if (closed) return;
147
+ setTimeout(connect, reconnectDelay);
148
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
149
+ }
150
+
151
+ // ── tmux injection ──────────────────────────────────────────────────────────
152
+
153
+ function injectToTmux(text) {
154
+ if (!text) return;
155
+ const pane = resolvePaneTarget();
156
+
157
+ try {
158
+ // Send the text literally, then press Enter
159
+ execSync(`tmux send-keys -t ${shellEscape(pane)} -l ${shellEscape(text)}`, {
160
+ timeout: 5000,
161
+ stdio: "ignore",
162
+ });
163
+ execSync(`tmux send-keys -t ${shellEscape(pane)} Enter`, {
164
+ timeout: 5000,
165
+ stdio: "ignore",
166
+ });
167
+ } catch {
168
+ // tmux not available or pane not found — non-fatal
169
+ }
170
+ }
171
+
172
+ function resolvePaneTarget() {
173
+ // Check for explicit pane ID file
174
+ const paneIdFile = path.join(os.homedir(), ".config", "fathom", `${workspace}-pane-id`);
175
+ try {
176
+ const paneId = fs.readFileSync(paneIdFile, "utf-8").trim();
177
+ if (paneId) return paneId;
178
+ } catch {
179
+ // Fall through to default
180
+ }
181
+ return `${workspace}_fathom-session`;
182
+ }
183
+
184
+ function shellEscape(s) {
185
+ // Escape for shell — wrap in single quotes, escape internal single quotes
186
+ return "'" + s.replace(/'/g, "'\\''") + "'";
187
+ }
188
+
189
+ // ── Image cache ─────────────────────────────────────────────────────────────
190
+
191
+ function cacheImage(msg) {
192
+ if (!msg.message_id || !msg.data) return;
193
+
194
+ try {
195
+ fs.mkdirSync(cacheDir, { recursive: true });
196
+ const ext = (msg.filename || "").split(".").pop() || "jpg";
197
+ const filename = `${msg.message_id}.${ext}`;
198
+ const filePath = path.join(cacheDir, filename);
199
+ fs.writeFileSync(filePath, Buffer.from(msg.data, "base64"));
200
+ } catch {
201
+ // Cache write failure is non-fatal
202
+ }
203
+ }
204
+
205
+ function getCachedImage(messageId) {
206
+ try {
207
+ const dir = fs.readdirSync(cacheDir);
208
+ const match = dir.find(f => f.startsWith(`${messageId}.`));
209
+ if (!match) return null;
210
+
211
+ const filePath = path.join(cacheDir, match);
212
+ const data = fs.readFileSync(filePath);
213
+ const ext = path.extname(match).slice(1).toLowerCase();
214
+ const mimeMap = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
215
+ return {
216
+ data: data.toString("base64"),
217
+ mimeType: mimeMap[ext] || "image/jpeg",
218
+ };
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function cleanupImageCache() {
225
+ try {
226
+ if (!fs.existsSync(cacheDir)) return;
227
+ const now = Date.now();
228
+ for (const file of fs.readdirSync(cacheDir)) {
229
+ const filePath = path.join(cacheDir, file);
230
+ const stat = fs.statSync(filePath);
231
+ if (now - stat.mtimeMs > IMAGE_CACHE_MAX_AGE_MS) {
232
+ fs.unlinkSync(filePath);
233
+ }
234
+ }
235
+ } catch {
236
+ // Cleanup failure is non-fatal
237
+ }
238
+ }
239
+
240
+ function close() {
241
+ closed = true;
242
+ stopKeepalive();
243
+ if (ws) {
244
+ try { ws.close(); } catch { /* ignore */ }
245
+ ws = null;
246
+ }
247
+ }
248
+
249
+ return { getCachedImage, close };
250
+ }