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 +3 -2
- package/src/index.js +80 -1
- package/src/server-client.js +12 -0
- package/src/ws-connection.js +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fathom-mcp",
|
|
3
|
-
"version": "0.
|
|
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
|
-
//
|
|
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
|
package/src/server-client.js
CHANGED
|
@@ -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
|
+
}
|