whats-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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * whats-mcp — Overview & smart search tools (2 tools).
3
+ *
4
+ * whatsup — full daily overview, watchlist-prioritized, unanswered threads
5
+ * find_messages — smart semantic search with keyword expansion
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const { phoneToJid, isGroupJid, okResult, errResult, formatMessage } = require("../helpers");
11
+
12
+ // ── Topic expansion map (French + English) ───────────────────────────────────
13
+ //
14
+ // For each topic, lists keywords to expand the user's query into.
15
+ // "ia" → also searches for "llm", "gpt", "machine learning", etc.
16
+ // Partial matches are intentional (e.g. "opportunit" catches opportunité/opportunités).
17
+
18
+ const TOPIC_EXPANSIONS = {
19
+ ia: [
20
+ "ia", "intelligence artificielle", "ai", "machine learning", "llm", "gpt",
21
+ "chatgpt", "neural", "deep learning", "ml", "nlp", "modele", "modèle",
22
+ "mistral", "gemini", "openai", "anthropic", "claude", "transformer",
23
+ "rag", "embedding", "dataset", "data science",
24
+ ],
25
+ stage: [
26
+ "stage", "internship", "alternance", "apprentissage", "stagiaire",
27
+ ],
28
+ offre: [
29
+ "offre", "opportunit", "recrutement", "embauche", "poste", "job",
30
+ "emploi", "cdi", "cdd", "freelance", "mission", "contrat",
31
+ ],
32
+ badminton: [
33
+ "badminton", "binet bad", "tournoi bad", "match bad", "entraîn",
34
+ "raquette", "volant", "terrain bad",
35
+ ],
36
+ sport: [
37
+ "sport", "match", "tournoi", "training", "gym", "running", "course",
38
+ "séance", "terrain",
39
+ ],
40
+ reunion: [
41
+ "réunion", "reunion", "meeting", "présentiel", "visio", "conf",
42
+ "appel", "call", "zoom", "rdv", "rendez-vous", "rencontre",
43
+ ],
44
+ urgence: [
45
+ "urgent", "urgence", "asap", "rapidement", "help", "aide",
46
+ "besoin", "au plus vite", "dès que",
47
+ ],
48
+ evenement: [
49
+ "event", "événement", "soirée", "sortie", "fête", "party",
50
+ "voyage", "trip", "hackathon", "datathon", "conférence",
51
+ "séminaire", "workshop",
52
+ ],
53
+ action: [
54
+ "action à", "peux-tu", "pourras-tu", "peux tu", "merci de",
55
+ "il faut", "n'oublie pas", "to do", "todo", "rappel",
56
+ "reminder", "deadline", "échéance", "date limite", "à faire",
57
+ "pense à",
58
+ ],
59
+ logement: [
60
+ "logement", "appart", "appartement", "coloc", "colocation",
61
+ "loyer", "chambre", "résidence", "hébergement", "housing", "rent",
62
+ ],
63
+ bourse: [
64
+ "bourse", "scholarship", "financement", "aide financière",
65
+ "subvention", "grant", "fellowship",
66
+ ],
67
+ annonce: [
68
+ "annonce", "annoncé", "communiqué", "info", "rappel",
69
+ "important", "attention", "note",
70
+ ],
71
+ };
72
+
73
+ /**
74
+ * Expand a user query to related keywords using the topic map.
75
+ * Returns [original_query, ...expanded_keywords] (deduped, lowercase).
76
+ */
77
+ function _expandQuery(query) {
78
+ const lower = query.toLowerCase().trim();
79
+ const keywords = new Set([lower]);
80
+
81
+ for (const [topic, expansions] of Object.entries(TOPIC_EXPANSIONS)) {
82
+ const matched =
83
+ lower.includes(topic) ||
84
+ expansions.some((e) => lower.includes(e));
85
+ if (matched) {
86
+ for (const e of expansions) keywords.add(e);
87
+ }
88
+ }
89
+
90
+ return Array.from(keywords);
91
+ }
92
+
93
+ /**
94
+ * Collect all JIDs from all watchlists (store + config fallback).
95
+ */
96
+ function _allWatchlistJids(store, config) {
97
+ const storeWLs = store.listWatchlists();
98
+ const configWLs = config?.watchlists || {};
99
+ const merged = { ...configWLs, ...storeWLs };
100
+ const jidSet = new Set();
101
+ for (const jids of Object.values(merged)) {
102
+ for (const jid of jids) jidSet.add(phoneToJid(jid));
103
+ }
104
+ return { jidSet, merged };
105
+ }
106
+
107
+ module.exports = [
108
+ // 1. whatsup — Daily overview
109
+ {
110
+ definition: {
111
+ name: "whatsup",
112
+ description:
113
+ "DAILY WHATSAPP OVERVIEW — CALL THIS AUTOMATICALLY when user asks:" +
114
+ " 'what's up', 'quoi de neuf', 'résume ma journée WhatsApp', 'qu'est-ce que j'ai manqué'," +
115
+ " 'donne-moi un résumé', 'c\\'est quoi les news', 'update WhatsApp', 'mon WhatsApp'," +
116
+ " 'koi de neuf', 'les actus WA', 'donne moi l\\'overview', 'briefing whatsapp'," +
117
+ " or any similar request about today's WhatsApp activity." +
118
+ " Returns a complete structured overview from midnight today to now:" +
119
+ " (1) Watchlist chats first (your prioritized groups) with all today's messages;" +
120
+ " (2) Other active chats with today's messages;" +
121
+ " (3) Needs-reply — chats where the last message is incoming, waiting for your response." +
122
+ " No parameters required for default use — call with empty args {}.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ since: {
127
+ type: "integer",
128
+ description: "Start Unix timestamp. Default: midnight today.",
129
+ },
130
+ until: {
131
+ type: "integer",
132
+ description: "End Unix timestamp. Default: now.",
133
+ },
134
+ watchlists: {
135
+ type: "array",
136
+ items: { type: "string" },
137
+ description: "Only show these watchlists (default: all).",
138
+ },
139
+ limit_per_chat: {
140
+ type: "integer",
141
+ description: "Max messages per chat (default: 50, max: 200).",
142
+ },
143
+ },
144
+ },
145
+ },
146
+ handler: async ({ since, until, watchlists: wlFilter, limit_per_chat }, { store, config }) => {
147
+ const now = Math.floor(Date.now() / 1000);
148
+
149
+ // Default period: since midnight today
150
+ const todayStart = new Date();
151
+ todayStart.setHours(0, 0, 0, 0);
152
+ const effectiveSince = since || Math.floor(todayStart.getTime() / 1000);
153
+ const effectiveUntil = until || now;
154
+ const lim = Math.min(limit_per_chat || 50, 200);
155
+
156
+ // Build JID → watchlist names mapping
157
+ const storeWLs = store.listWatchlists();
158
+ const configWLs = config?.watchlists || {};
159
+ const allWLs = { ...configWLs, ...storeWLs };
160
+
161
+ const activeWLs =
162
+ wlFilter && wlFilter.length > 0
163
+ ? Object.fromEntries(Object.entries(allWLs).filter(([n]) => wlFilter.includes(n)))
164
+ : allWLs;
165
+
166
+ const jidToWatchlists = new Map();
167
+ for (const [wlName, jids] of Object.entries(activeWLs)) {
168
+ for (const rawJid of jids) {
169
+ const jid = phoneToJid(rawJid);
170
+ if (!jidToWatchlists.has(jid)) jidToWatchlists.set(jid, []);
171
+ jidToWatchlists.get(jid).push(wlName);
172
+ }
173
+ }
174
+ const watchlistJidSet = new Set(jidToWatchlists.keys());
175
+
176
+ const filterOpts = {
177
+ since: effectiveSince,
178
+ until: effectiveUntil,
179
+ excludeTypes: ["protocol", "reaction"],
180
+ };
181
+
182
+ const watchlistChats = [];
183
+ const otherChats = [];
184
+ const needsReplyChats = [];
185
+
186
+ // All JIDs to scan (messages + watchlist JIDs so watched chats always appear)
187
+ const allJids = new Set(store.messages.keys());
188
+ for (const jid of watchlistJidSet) allJids.add(jid);
189
+
190
+ for (const jid of allJids) {
191
+ if (jid === "status@broadcast") continue;
192
+
193
+ const messages = store.getMessages(jid, lim, undefined, filterOpts);
194
+ const formatted = messages.map(formatMessage).filter(Boolean);
195
+ if (formatted.length === 0) continue;
196
+
197
+ const chat = store.getChat(jid);
198
+ const contact = store.getContact(jid);
199
+ const chatName =
200
+ chat?.name || chat?.subject || contact?.name || contact?.notify || jid;
201
+
202
+ // "needs reply": last non-protocol message is not from me
203
+ const recent = store.getMessages(jid, 3, undefined, { excludeTypes: ["protocol", "reaction"] });
204
+ const recentFormatted = recent.map(formatMessage).filter(Boolean);
205
+ const lastMsg = recentFormatted[0]; // newest first
206
+ const needsReply = !!lastMsg && !lastMsg.from_me;
207
+
208
+ const chatData = {
209
+ jid,
210
+ name: chatName,
211
+ is_group: isGroupJid(jid),
212
+ unread: chat?.unreadCount || 0,
213
+ message_count: formatted.length,
214
+ needs_reply: needsReply,
215
+ last_message_time: formatted[0]?.timestamp || null,
216
+ messages: formatted,
217
+ };
218
+
219
+ if (watchlistJidSet.has(jid)) {
220
+ chatData.watchlists = jidToWatchlists.get(jid);
221
+ watchlistChats.push(chatData);
222
+ } else {
223
+ otherChats.push(chatData);
224
+ }
225
+
226
+ if (needsReply) {
227
+ needsReplyChats.push({
228
+ jid,
229
+ name: chatName,
230
+ is_group: isGroupJid(jid),
231
+ in_watchlist: watchlistJidSet.has(jid),
232
+ last_message: lastMsg,
233
+ });
234
+ }
235
+ }
236
+
237
+ // Sort by latest activity
238
+ const byTime = (a, b) => (b.last_message_time || 0) - (a.last_message_time || 0);
239
+ watchlistChats.sort(byTime);
240
+ otherChats.sort(byTime);
241
+ // needs_reply: watchlist first, then by last activity
242
+ needsReplyChats.sort((a, b) => {
243
+ if (a.in_watchlist !== b.in_watchlist) return b.in_watchlist ? 1 : -1;
244
+ return (b.last_message?.timestamp || 0) - (a.last_message?.timestamp || 0);
245
+ });
246
+
247
+ const totalMessages =
248
+ watchlistChats.reduce((s, c) => s + c.message_count, 0) +
249
+ otherChats.reduce((s, c) => s + c.message_count, 0);
250
+
251
+ return okResult({
252
+ date: new Date().toLocaleDateString("fr-FR"),
253
+ period: {
254
+ since: effectiveSince,
255
+ until: effectiveUntil,
256
+ from: new Date(effectiveSince * 1000).toLocaleTimeString("fr-FR", {
257
+ hour: "2-digit",
258
+ minute: "2-digit",
259
+ }),
260
+ to: new Date(effectiveUntil * 1000).toLocaleTimeString("fr-FR", {
261
+ hour: "2-digit",
262
+ minute: "2-digit",
263
+ }),
264
+ },
265
+ summary: {
266
+ total_active_chats: watchlistChats.length + otherChats.length,
267
+ watchlist_chats: watchlistChats.length,
268
+ other_chats: otherChats.length,
269
+ total_messages: totalMessages,
270
+ needs_reply_count: needsReplyChats.length,
271
+ },
272
+ watchlist_chats: watchlistChats,
273
+ other_chats: otherChats,
274
+ needs_reply: needsReplyChats,
275
+ });
276
+ },
277
+ },
278
+
279
+ // 2. find_messages — Smart semantic search
280
+ {
281
+ definition: {
282
+ name: "find_messages",
283
+ description:
284
+ "SMART SEMANTIC MESSAGE SEARCH — CALL THIS when user asks about specific topics:" +
285
+ " 'y a-t-il des messages sur l\\'IA', 'des offres de stage', 'des attentes pour moi'," +
286
+ " 'des actions à faire', 'des events à venir', 'des invitations', 'des urgences'," +
287
+ " 'what about jobs', 'any urgent messages', 'des réunions prévues', 'infos sur X'," +
288
+ " 'des messages importants', 'quoi de neuf sur l\\'IA', 'y a-t-il des stages'." +
289
+ " Performs intelligent multi-keyword search with automatic topic expansion:" +
290
+ " 'ia' also searches for 'machine learning', 'LLM', 'GPT', 'intelligence artificielle', etc." +
291
+ " 'offre' also expands to 'stage', 'emploi', 'recrutement', 'opportunité', etc." +
292
+ " Results are ALWAYS prioritized: watchlist chats first, then all other chats." +
293
+ " Groups results by chat. Covers entire message history by default.",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {
297
+ query: {
298
+ type: "string",
299
+ description:
300
+ "Topic or question to search for (French or English)." +
301
+ " Examples: 'IA', 'stage', 'offres emploi', 'urgence', 'events', 'actions à faire'.",
302
+ },
303
+ since: {
304
+ type: "integer",
305
+ description: "Optional: only include messages after this Unix timestamp.",
306
+ },
307
+ until: {
308
+ type: "integer",
309
+ description: "Optional: only include messages before this Unix timestamp.",
310
+ },
311
+ limit: {
312
+ type: "integer",
313
+ description: "Max total results (default: 80, max: 300).",
314
+ },
315
+ watchlist_only: {
316
+ type: "boolean",
317
+ description: "If true, restrict search to watchlist chats only.",
318
+ },
319
+ },
320
+ required: ["query"],
321
+ },
322
+ },
323
+ handler: async ({ query, since, until, limit, watchlist_only }, { store, config }) => {
324
+ if (!query || !query.trim()) {
325
+ return errResult("Parameter 'query' is required.");
326
+ }
327
+
328
+ const capped = Math.min(limit || 80, 300);
329
+ const opts = {
330
+ since: since || undefined,
331
+ until: until || undefined,
332
+ };
333
+
334
+ // Build watchlist set
335
+ const { jidSet: watchlistJidSet } = _allWatchlistJids(store, config);
336
+
337
+ // Expand query into related keywords
338
+ const keywords = _expandQuery(query);
339
+
340
+ // Phase 1: TF-IDF analytics search with expanded query (handles tokenization internally)
341
+ const expandedQuery = keywords.join(" ");
342
+ const analyticsResults = store.analyticsSearch(expandedQuery, null, capped, opts);
343
+
344
+ // Phase 2: plain text fallback for original query (phrase match catches more)
345
+ const textResults = store.searchMessages(query, null, Math.floor(capped / 2), opts);
346
+
347
+ // Merge: analytics first (higher quality), then add text-only misses
348
+ const seenIds = new Set(analyticsResults.map((r) => r.id));
349
+ const allResults = [...analyticsResults];
350
+ for (const r of textResults) {
351
+ if (!seenIds.has(r.id)) {
352
+ allResults.push({ ...r, score: 0.3, matched_terms: [query] });
353
+ seenIds.add(r.id);
354
+ }
355
+ }
356
+
357
+ // Filter by watchlist if requested
358
+ let filtered = watchlist_only
359
+ ? allResults.filter((r) => watchlistJidSet.has(r.from))
360
+ : allResults;
361
+
362
+ // Sort: watchlist first → score desc → recency desc
363
+ filtered.sort((a, b) => {
364
+ const aWL = watchlistJidSet.has(a.from) ? 1 : 0;
365
+ const bWL = watchlistJidSet.has(b.from) ? 1 : 0;
366
+ if (aWL !== bWL) return bWL - aWL;
367
+ if (b.score !== a.score) return b.score - a.score;
368
+ return (b.timestamp || 0) - (a.timestamp || 0);
369
+ });
370
+
371
+ filtered = filtered.slice(0, capped);
372
+
373
+ // Group by chat JID
374
+ const byChat = new Map();
375
+ for (const r of filtered) {
376
+ const chatJid = r.from; // formatMessage sets from = key.remoteJid = chat JID
377
+ if (!byChat.has(chatJid)) {
378
+ const chat = store.getChat(chatJid);
379
+ const contact = store.getContact(chatJid);
380
+ byChat.set(chatJid, {
381
+ jid: chatJid,
382
+ name: chat?.name || chat?.subject || contact?.name || contact?.notify || chatJid,
383
+ is_group: isGroupJid(chatJid),
384
+ in_watchlist: watchlistJidSet.has(chatJid),
385
+ messages: [],
386
+ });
387
+ }
388
+ byChat.get(chatJid).messages.push({
389
+ id: r.id,
390
+ timestamp: r.timestamp,
391
+ from_me: r.from_me,
392
+ participant: r.participant,
393
+ push_name: r.push_name,
394
+ type: r.type,
395
+ text: r.text,
396
+ matched_keywords: r.matched_terms || [query],
397
+ });
398
+ }
399
+
400
+ // Sort chats: watchlist first → most messages first
401
+ const chatResults = Array.from(byChat.values()).sort((a, b) => {
402
+ if (a.in_watchlist !== b.in_watchlist) return b.in_watchlist ? 1 : -1;
403
+ return b.messages.length - a.messages.length;
404
+ });
405
+
406
+ return okResult({
407
+ query,
408
+ expanded_keywords: keywords.length > 1 ? keywords.slice(1) : [],
409
+ total_messages: filtered.length,
410
+ total_chats: chatResults.length,
411
+ watchlist_matches: chatResults.filter((c) => c.in_watchlist).length,
412
+ chats: chatResults,
413
+ });
414
+ },
415
+ },
416
+ ];
@@ -0,0 +1,155 @@
1
+ /**
2
+ * whats-mcp — Profile & Privacy tools (4 tools).
3
+ *
4
+ * update_display_name, update_about, update_profile_picture, manage_privacy
5
+ */
6
+
7
+ "use strict";
8
+
9
+ const { resolveMedia, okResult, errResult } = require("../helpers");
10
+
11
+ module.exports = [
12
+ // 1. update_display_name
13
+ {
14
+ definition: {
15
+ name: "update_display_name",
16
+ description: "Change your WhatsApp display name.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ name: { type: "string", description: "New display name (max 25 characters)." },
21
+ },
22
+ required: ["name"],
23
+ },
24
+ },
25
+ handler: async ({ name }, { sock }) => {
26
+ if (!name || name.length > 25) {
27
+ return errResult("Name must be between 1 and 25 characters.");
28
+ }
29
+ await sock.updateProfileName(name);
30
+ return okResult({ status: "updated", name });
31
+ },
32
+ },
33
+
34
+ // 2. update_about
35
+ {
36
+ definition: {
37
+ name: "update_about",
38
+ description: "Change your WhatsApp 'About' status text.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ text: { type: "string", description: "New about text (max 139 characters). Empty string to clear." },
43
+ },
44
+ required: ["text"],
45
+ },
46
+ },
47
+ handler: async ({ text }, { sock }) => {
48
+ await sock.updateProfileStatus(text || "");
49
+ return okResult({ status: "updated", about: text });
50
+ },
51
+ },
52
+
53
+ // 3. update_profile_picture
54
+ {
55
+ definition: {
56
+ name: "update_profile_picture",
57
+ description: "Change your WhatsApp profile picture.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ source: { type: "string", description: "Image source: URL, base64, or local file path. Use 'remove' to delete the picture." },
62
+ },
63
+ required: ["source"],
64
+ },
65
+ },
66
+ handler: async ({ source }, { sock }) => {
67
+ if (source === "remove") {
68
+ await sock.removeProfilePicture(sock.user.id);
69
+ return okResult({ status: "removed" });
70
+ }
71
+ const media = resolveMedia(source);
72
+ let imgBuf;
73
+ if (Buffer.isBuffer(media)) {
74
+ imgBuf = media;
75
+ } else if (media.url) {
76
+ const resp = await fetch(media.url);
77
+ imgBuf = Buffer.from(await resp.arrayBuffer());
78
+ } else {
79
+ imgBuf = media;
80
+ }
81
+ await sock.updateProfilePicture(sock.user.id, imgBuf);
82
+ return okResult({ status: "updated" });
83
+ },
84
+ },
85
+
86
+ // 4. manage_privacy
87
+ {
88
+ definition: {
89
+ name: "manage_privacy",
90
+ description:
91
+ "Get or update WhatsApp privacy settings." +
92
+ " Use action='get' to retrieve current settings." +
93
+ " Use action='set' with a setting name and value to update.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ action: {
98
+ type: "string",
99
+ enum: ["get", "set"],
100
+ description: "Action: 'get' to retrieve all privacy settings, 'set' to update one.",
101
+ },
102
+ setting: {
103
+ type: "string",
104
+ enum: [
105
+ "last_seen", "online", "profile_picture", "about",
106
+ "read_receipts", "groups_add", "default_disappearing",
107
+ ],
108
+ description: "Privacy setting to update (required for 'set').",
109
+ },
110
+ value: {
111
+ type: "string",
112
+ enum: ["all", "contacts", "contact_blacklist", "none", "match_last_seen"],
113
+ description:
114
+ "New value for the setting (required for 'set')." +
115
+ " 'all' = everyone, 'contacts' = contacts only, 'none' = nobody." +
116
+ " 'contact_blacklist' = contacts except... 'match_last_seen' = match last seen setting.",
117
+ },
118
+ },
119
+ required: ["action"],
120
+ },
121
+ },
122
+ handler: async ({ action, setting, value }, { sock }) => {
123
+ if (action === "get") {
124
+ const settings = await sock.fetchPrivacySettings(true);
125
+ return okResult({ privacy: settings });
126
+ }
127
+
128
+ if (action === "set") {
129
+ if (!setting || !value) {
130
+ return errResult("Both 'setting' and 'value' are required for 'set' action.");
131
+ }
132
+
133
+ const apiMap = {
134
+ last_seen: () => sock.updateLastSeenPrivacy(value),
135
+ online: () => sock.updateOnlinePrivacy(value),
136
+ profile_picture: () => sock.updateProfilePicturePrivacy(value),
137
+ about: () => sock.updateStatusPrivacy(value),
138
+ read_receipts: () => sock.updateReadReceiptsPrivacy(value),
139
+ groups_add: () => sock.updateGroupsAddPrivacy(value),
140
+ default_disappearing: () => sock.updateDefaultDisappearingMode(
141
+ value === "all" ? 0 : value === "contacts" ? 86400 : 604800,
142
+ ),
143
+ };
144
+
145
+ const fn = apiMap[setting];
146
+ if (!fn) return errResult(`Unknown setting: ${setting}.`);
147
+
148
+ await fn();
149
+ return okResult({ status: "updated", setting, value });
150
+ }
151
+
152
+ return errResult(`Unknown action: ${action}`);
153
+ },
154
+ },
155
+ ];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * whats-mcp — Tool Registry.
3
+ *
4
+ * Collects all tool definitions from category modules and provides:
5
+ * - listTools() → array of MCP tool definitions
6
+ * - callTool(name, args, context) → MCP CallTool result
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const messagingTools = require("./messaging");
12
+ const chatsTools = require("./chats");
13
+ const contactsTools = require("./contacts");
14
+ const groupsTools = require("./groups");
15
+ const profileTools = require("./profile");
16
+ const channelsTools = require("./channels");
17
+ const labelsTools = require("./labels");
18
+ const analyticsTools = require("./analytics");
19
+ const utilsTools = require("./utils");
20
+ const digestTools = require("./digest");
21
+ const tagsTools = require("./tags");
22
+ const watchlistsTools = require("./watchlists");
23
+ const overviewTools = require("./overview");
24
+
25
+ // ── Collect all tools ────────────────────────────────────────────────────────
26
+
27
+ const ALL_TOOLS = [
28
+ ...messagingTools,
29
+ ...chatsTools,
30
+ ...contactsTools,
31
+ ...groupsTools,
32
+ ...profileTools,
33
+ ...channelsTools,
34
+ ...labelsTools,
35
+ ...analyticsTools,
36
+ ...utilsTools,
37
+ ...digestTools,
38
+ ...tagsTools,
39
+ ...watchlistsTools,
40
+ ...overviewTools,
41
+ ];
42
+
43
+ // Build lookup maps
44
+ const _definitions = ALL_TOOLS.map((t) => t.definition);
45
+ const _handlers = new Map();
46
+ for (const t of ALL_TOOLS) {
47
+ if (_handlers.has(t.definition.name)) {
48
+ throw new Error(`Duplicate tool name: ${t.definition.name}`);
49
+ }
50
+ _handlers.set(t.definition.name, t.handler);
51
+ }
52
+
53
+ // ── Public API ───────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Return all MCP tool definitions for ListTools.
57
+ */
58
+ function listTools() {
59
+ return _definitions;
60
+ }
61
+
62
+ /**
63
+ * Dispatch a CallTool request.
64
+ *
65
+ * @param {string} name - Tool name
66
+ * @param {object} args - Tool arguments (from MCP request)
67
+ * @param {object} ctx - Context: { sock, store, connectionInfo, toolDefs }
68
+ * @returns {Promise<object>} MCP CallTool result ({ content: [...] })
69
+ */
70
+ async function callTool(name, args, ctx) {
71
+ const handler = _handlers.get(name);
72
+ if (!handler) {
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
75
+ isError: true,
76
+ };
77
+ }
78
+
79
+ try {
80
+ // Provide toolDefs in context for the guide tool
81
+ ctx.toolDefs = _definitions;
82
+ return await handler(args || {}, ctx);
83
+ } catch (err) {
84
+ // Structured error response
85
+ const errorMessage = err.data
86
+ ? `${err.message} — ${JSON.stringify(err.data)}`
87
+ : err.message || String(err);
88
+
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: JSON.stringify({
94
+ error: errorMessage,
95
+ tool: name,
96
+ ...(err.statusCode ? { status_code: err.statusCode } : {}),
97
+ }),
98
+ },
99
+ ],
100
+ isError: true,
101
+ };
102
+ }
103
+ }
104
+
105
+ module.exports = { listTools, callTool, ALL_TOOLS };