@jtalk22/slack-mcp 1.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/LICENSE +21 -0
- package/README.md +295 -0
- package/docs/API.md +286 -0
- package/docs/SETUP.md +134 -0
- package/docs/TROUBLESHOOTING.md +216 -0
- package/docs/WEB-API.md +277 -0
- package/docs/images/demo-channel-messages.png +0 -0
- package/docs/images/demo-channels.png +0 -0
- package/docs/images/demo-main.png +0 -0
- package/docs/images/demo-messages.png +0 -0
- package/docs/images/demo-sidebar.png +0 -0
- package/lib/handlers.js +421 -0
- package/lib/slack-client.js +119 -0
- package/lib/token-store.js +184 -0
- package/lib/tools.js +191 -0
- package/package.json +70 -0
- package/public/demo.html +920 -0
- package/public/index.html +258 -0
- package/scripts/capture-screenshots.js +96 -0
- package/scripts/publish-public.sh +37 -0
- package/scripts/sync-from-onedrive.sh +33 -0
- package/scripts/sync-to-onedrive.sh +31 -0
- package/scripts/token-cli.js +157 -0
- package/src/server.js +118 -0
- package/src/web-server.js +256 -0
package/lib/handlers.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Handlers
|
|
3
|
+
*
|
|
4
|
+
* Implementation of all MCP tool handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { writeFileSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
|
|
11
|
+
import { slackAPI, resolveUser, formatTimestamp, sleep } from "./slack-client.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Health check handler
|
|
15
|
+
*/
|
|
16
|
+
export async function handleHealthCheck() {
|
|
17
|
+
const creds = loadTokens();
|
|
18
|
+
if (!creds) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{
|
|
21
|
+
type: "text",
|
|
22
|
+
text: "NO CREDENTIALS\n\nOptions:\n1. Open Slack in Chrome, then use slack_refresh_tokens\n2. Run: ~/slack-mcp-server/scripts/refresh-tokens.sh"
|
|
23
|
+
}],
|
|
24
|
+
isError: true
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await slackAPI("auth.test", {});
|
|
30
|
+
return {
|
|
31
|
+
content: [{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
status: "OK",
|
|
35
|
+
user: result.user,
|
|
36
|
+
user_id: result.user_id,
|
|
37
|
+
team: result.team,
|
|
38
|
+
team_id: result.team_id,
|
|
39
|
+
token_source: creds.source,
|
|
40
|
+
token_updated: creds.updatedAt || "unknown"
|
|
41
|
+
}, null, 2)
|
|
42
|
+
}]
|
|
43
|
+
};
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: `AUTH FAILED: ${e.message}` }],
|
|
47
|
+
isError: true
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refresh tokens handler
|
|
54
|
+
*/
|
|
55
|
+
export async function handleRefreshTokens() {
|
|
56
|
+
const chromeTokens = extractFromChrome();
|
|
57
|
+
if (chromeTokens) {
|
|
58
|
+
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
59
|
+
try {
|
|
60
|
+
const result = await slackAPI("auth.test", {}, { retryOnAuthFail: false });
|
|
61
|
+
return {
|
|
62
|
+
content: [{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: JSON.stringify({
|
|
65
|
+
status: "SUCCESS",
|
|
66
|
+
message: "Tokens refreshed from Chrome!",
|
|
67
|
+
user: result.user,
|
|
68
|
+
team: result.team
|
|
69
|
+
}, null, 2)
|
|
70
|
+
}]
|
|
71
|
+
};
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: `Extracted but invalid: ${e.message}` }],
|
|
75
|
+
isError: true
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
content: [{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: "Could not extract from Chrome.\n\nMake sure:\n1. Chrome is running\n2. Slack tab is open (app.slack.com)\n3. You're logged into Slack"
|
|
83
|
+
}],
|
|
84
|
+
isError: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List conversations handler
|
|
90
|
+
*/
|
|
91
|
+
export async function handleListConversations(args) {
|
|
92
|
+
const types = args.types || "im,mpim";
|
|
93
|
+
const wantsDMs = types.includes("im") || types.includes("mpim");
|
|
94
|
+
|
|
95
|
+
const result = await slackAPI("conversations.list", {
|
|
96
|
+
types: types,
|
|
97
|
+
limit: args.limit || 100,
|
|
98
|
+
exclude_archived: true
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const conversations = await Promise.all((result.channels || []).map(async (c) => {
|
|
102
|
+
let displayName = c.name;
|
|
103
|
+
if (c.is_im && c.user) {
|
|
104
|
+
displayName = await resolveUser(c.user);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id: c.id,
|
|
108
|
+
name: displayName,
|
|
109
|
+
type: c.is_im ? "dm" : c.is_mpim ? "group_dm" : c.is_private ? "private_channel" : "public_channel",
|
|
110
|
+
user_id: c.user
|
|
111
|
+
};
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// xoxc tokens often don't return IMs via conversations.list
|
|
115
|
+
// So we manually add DMs by opening them with known users
|
|
116
|
+
if (wantsDMs) {
|
|
117
|
+
try {
|
|
118
|
+
const usersResult = await slackAPI("users.list", { limit: 100 });
|
|
119
|
+
for (const user of (usersResult.members || [])) {
|
|
120
|
+
if (user.is_bot || user.id === "USLACKBOT" || user.deleted) continue;
|
|
121
|
+
|
|
122
|
+
// Try to open DM with each user to get channel ID
|
|
123
|
+
try {
|
|
124
|
+
const dmResult = await slackAPI("conversations.open", { users: user.id });
|
|
125
|
+
if (dmResult.channel && dmResult.channel.id) {
|
|
126
|
+
const channelId = dmResult.channel.id;
|
|
127
|
+
// Only add if not already in list
|
|
128
|
+
if (!conversations.find(c => c.id === channelId)) {
|
|
129
|
+
conversations.push({
|
|
130
|
+
id: channelId,
|
|
131
|
+
name: user.real_name || user.name,
|
|
132
|
+
type: "dm",
|
|
133
|
+
user_id: user.id
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Skip users we can't DM
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
// If users.list fails, continue with what we have
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: JSON.stringify({ count: conversations.length, conversations }, null, 2)
|
|
150
|
+
}]
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Conversations history handler
|
|
156
|
+
*/
|
|
157
|
+
export async function handleConversationsHistory(args) {
|
|
158
|
+
const resolveUsers = args.resolve_users !== false;
|
|
159
|
+
const result = await slackAPI("conversations.history", {
|
|
160
|
+
channel: args.channel_id,
|
|
161
|
+
limit: args.limit || 50,
|
|
162
|
+
oldest: args.oldest,
|
|
163
|
+
latest: args.latest,
|
|
164
|
+
inclusive: true
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const messages = await Promise.all((result.messages || []).map(async (msg) => {
|
|
168
|
+
const userName = resolveUsers ? await resolveUser(msg.user) : msg.user;
|
|
169
|
+
return {
|
|
170
|
+
ts: msg.ts,
|
|
171
|
+
user: userName,
|
|
172
|
+
user_id: msg.user,
|
|
173
|
+
text: msg.text || "",
|
|
174
|
+
datetime: formatTimestamp(msg.ts),
|
|
175
|
+
has_thread: !!msg.thread_ts && msg.reply_count > 0,
|
|
176
|
+
reply_count: msg.reply_count
|
|
177
|
+
};
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
content: [{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: JSON.stringify({
|
|
184
|
+
channel: args.channel_id,
|
|
185
|
+
message_count: messages.length,
|
|
186
|
+
has_more: result.has_more,
|
|
187
|
+
messages
|
|
188
|
+
}, null, 2)
|
|
189
|
+
}]
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Full conversation export handler
|
|
195
|
+
*/
|
|
196
|
+
export async function handleGetFullConversation(args) {
|
|
197
|
+
const maxMessages = Math.min(args.max_messages || 2000, 10000);
|
|
198
|
+
const includeThreads = args.include_threads !== false;
|
|
199
|
+
const allMessages = [];
|
|
200
|
+
let cursor;
|
|
201
|
+
let hasMore = true;
|
|
202
|
+
|
|
203
|
+
// Fetch all messages with pagination
|
|
204
|
+
while (hasMore && allMessages.length < maxMessages) {
|
|
205
|
+
const result = await slackAPI("conversations.history", {
|
|
206
|
+
channel: args.channel_id,
|
|
207
|
+
limit: Math.min(100, maxMessages - allMessages.length),
|
|
208
|
+
oldest: args.oldest,
|
|
209
|
+
latest: args.latest,
|
|
210
|
+
cursor,
|
|
211
|
+
inclusive: true
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
for (const msg of result.messages || []) {
|
|
215
|
+
const userName = await resolveUser(msg.user);
|
|
216
|
+
const message = {
|
|
217
|
+
ts: msg.ts,
|
|
218
|
+
user: userName,
|
|
219
|
+
user_id: msg.user,
|
|
220
|
+
text: msg.text || "",
|
|
221
|
+
datetime: formatTimestamp(msg.ts),
|
|
222
|
+
replies: []
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Fetch thread replies if present
|
|
226
|
+
if (includeThreads && msg.reply_count > 0) {
|
|
227
|
+
try {
|
|
228
|
+
const threadResult = await slackAPI("conversations.replies", {
|
|
229
|
+
channel: args.channel_id,
|
|
230
|
+
ts: msg.ts
|
|
231
|
+
});
|
|
232
|
+
// Skip first message (parent)
|
|
233
|
+
for (const reply of (threadResult.messages || []).slice(1)) {
|
|
234
|
+
const replyUserName = await resolveUser(reply.user);
|
|
235
|
+
message.replies.push({
|
|
236
|
+
ts: reply.ts,
|
|
237
|
+
user: replyUserName,
|
|
238
|
+
text: reply.text || "",
|
|
239
|
+
datetime: formatTimestamp(reply.ts)
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
await sleep(50); // Rate limit
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// Skip thread on error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
allMessages.push(message);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
hasMore = result.has_more && result.response_metadata?.next_cursor;
|
|
252
|
+
cursor = result.response_metadata?.next_cursor;
|
|
253
|
+
if (hasMore) await sleep(100);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Sort chronologically
|
|
257
|
+
allMessages.sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
|
|
258
|
+
|
|
259
|
+
const output = {
|
|
260
|
+
channel: args.channel_id,
|
|
261
|
+
exported_at: new Date().toISOString(),
|
|
262
|
+
total_messages: allMessages.length,
|
|
263
|
+
date_range: {
|
|
264
|
+
oldest: args.oldest ? formatTimestamp(args.oldest) : "beginning",
|
|
265
|
+
latest: args.latest ? formatTimestamp(args.latest) : "now"
|
|
266
|
+
},
|
|
267
|
+
messages: allMessages
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Save to file if requested
|
|
271
|
+
if (args.output_file) {
|
|
272
|
+
const outputPath = args.output_file.startsWith('/')
|
|
273
|
+
? args.output_file
|
|
274
|
+
: join(homedir(), args.output_file);
|
|
275
|
+
writeFileSync(outputPath, JSON.stringify(output, null, 2));
|
|
276
|
+
output.saved_to = outputPath;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Search messages handler
|
|
284
|
+
*/
|
|
285
|
+
export async function handleSearchMessages(args) {
|
|
286
|
+
const result = await slackAPI("search.messages", {
|
|
287
|
+
query: args.query,
|
|
288
|
+
count: args.count || 20,
|
|
289
|
+
sort: "timestamp",
|
|
290
|
+
sort_dir: "desc"
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const matches = await Promise.all((result.messages?.matches || []).map(async (m) => ({
|
|
294
|
+
ts: m.ts,
|
|
295
|
+
channel: m.channel?.name || m.channel?.id,
|
|
296
|
+
channel_id: m.channel?.id,
|
|
297
|
+
user: await resolveUser(m.user),
|
|
298
|
+
text: m.text,
|
|
299
|
+
datetime: formatTimestamp(m.ts),
|
|
300
|
+
permalink: m.permalink
|
|
301
|
+
})));
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
content: [{
|
|
305
|
+
type: "text",
|
|
306
|
+
text: JSON.stringify({
|
|
307
|
+
query: args.query,
|
|
308
|
+
total: result.messages?.total || 0,
|
|
309
|
+
matches
|
|
310
|
+
}, null, 2)
|
|
311
|
+
}]
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* User info handler
|
|
317
|
+
*/
|
|
318
|
+
export async function handleUsersInfo(args) {
|
|
319
|
+
const result = await slackAPI("users.info", { user: args.user_id });
|
|
320
|
+
const user = result.user;
|
|
321
|
+
return {
|
|
322
|
+
content: [{
|
|
323
|
+
type: "text",
|
|
324
|
+
text: JSON.stringify({
|
|
325
|
+
id: user.id,
|
|
326
|
+
name: user.name,
|
|
327
|
+
real_name: user.real_name,
|
|
328
|
+
display_name: user.profile?.display_name,
|
|
329
|
+
email: user.profile?.email,
|
|
330
|
+
title: user.profile?.title,
|
|
331
|
+
status_text: user.profile?.status_text,
|
|
332
|
+
status_emoji: user.profile?.status_emoji,
|
|
333
|
+
timezone: user.tz,
|
|
334
|
+
is_bot: user.is_bot,
|
|
335
|
+
is_admin: user.is_admin
|
|
336
|
+
}, null, 2)
|
|
337
|
+
}]
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Send message handler
|
|
343
|
+
*/
|
|
344
|
+
export async function handleSendMessage(args) {
|
|
345
|
+
const result = await slackAPI("chat.postMessage", {
|
|
346
|
+
channel: args.channel_id,
|
|
347
|
+
text: args.text,
|
|
348
|
+
thread_ts: args.thread_ts
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
content: [{
|
|
353
|
+
type: "text",
|
|
354
|
+
text: JSON.stringify({
|
|
355
|
+
status: "sent",
|
|
356
|
+
channel: result.channel,
|
|
357
|
+
ts: result.ts,
|
|
358
|
+
thread_ts: args.thread_ts,
|
|
359
|
+
message: result.message?.text
|
|
360
|
+
}, null, 2)
|
|
361
|
+
}]
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Get thread handler
|
|
367
|
+
*/
|
|
368
|
+
export async function handleGetThread(args) {
|
|
369
|
+
const result = await slackAPI("conversations.replies", {
|
|
370
|
+
channel: args.channel_id,
|
|
371
|
+
ts: args.thread_ts
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const messages = await Promise.all((result.messages || []).map(async (msg) => ({
|
|
375
|
+
ts: msg.ts,
|
|
376
|
+
user: await resolveUser(msg.user),
|
|
377
|
+
user_id: msg.user,
|
|
378
|
+
text: msg.text || "",
|
|
379
|
+
datetime: formatTimestamp(msg.ts),
|
|
380
|
+
is_parent: msg.ts === args.thread_ts
|
|
381
|
+
})));
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
content: [{
|
|
385
|
+
type: "text",
|
|
386
|
+
text: JSON.stringify({
|
|
387
|
+
channel: args.channel_id,
|
|
388
|
+
thread_ts: args.thread_ts,
|
|
389
|
+
message_count: messages.length,
|
|
390
|
+
messages
|
|
391
|
+
}, null, 2)
|
|
392
|
+
}]
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* List users handler
|
|
398
|
+
*/
|
|
399
|
+
export async function handleListUsers(args) {
|
|
400
|
+
const result = await slackAPI("users.list", {
|
|
401
|
+
limit: args.limit || 100
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const users = (result.members || [])
|
|
405
|
+
.filter(u => !u.deleted && !u.is_bot)
|
|
406
|
+
.map(u => ({
|
|
407
|
+
id: u.id,
|
|
408
|
+
name: u.name,
|
|
409
|
+
real_name: u.real_name,
|
|
410
|
+
display_name: u.profile?.display_name,
|
|
411
|
+
email: u.profile?.email,
|
|
412
|
+
is_admin: u.is_admin
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
content: [{
|
|
417
|
+
type: "text",
|
|
418
|
+
text: JSON.stringify({ count: users.length, users }, null, 2)
|
|
419
|
+
}]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack API Client
|
|
3
|
+
*
|
|
4
|
+
* Handles all Slack API communication with:
|
|
5
|
+
* - Automatic token refresh on auth failure
|
|
6
|
+
* - User name caching
|
|
7
|
+
* - Rate limiting
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
|
|
11
|
+
|
|
12
|
+
// User cache to avoid repeated API calls
|
|
13
|
+
const userCache = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Make an authenticated Slack API call
|
|
17
|
+
*/
|
|
18
|
+
export async function slackAPI(method, params = {}, options = {}) {
|
|
19
|
+
const { retryOnAuthFail = true, retryCount = 0, maxRetries = 3, logger = console } = options;
|
|
20
|
+
|
|
21
|
+
const creds = loadTokens(false, logger);
|
|
22
|
+
if (!creds) {
|
|
23
|
+
throw new Error("No credentials available. Run refresh-tokens.sh or open Slack in Chrome.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetch(`https://slack.com/api/${method}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"Authorization": `Bearer ${creds.token}`,
|
|
30
|
+
"Cookie": `d=${creds.cookie}`,
|
|
31
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify(params),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const data = await response.json();
|
|
37
|
+
|
|
38
|
+
if (!data.ok) {
|
|
39
|
+
// Handle rate limiting with exponential backoff
|
|
40
|
+
if (data.error === "ratelimited" && retryCount < maxRetries) {
|
|
41
|
+
const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
|
|
42
|
+
const backoff = Math.min(retryAfter * 1000, 30000) * (retryCount + 1);
|
|
43
|
+
logger.error(`Rate limited on ${method}, waiting ${backoff}ms before retry ${retryCount + 1}/${maxRetries}`);
|
|
44
|
+
await sleep(backoff);
|
|
45
|
+
return slackAPI(method, params, { ...options, retryCount: retryCount + 1 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle auth errors with auto-retry
|
|
49
|
+
if ((data.error === "invalid_auth" || data.error === "token_expired") && retryOnAuthFail) {
|
|
50
|
+
logger.error("Token expired, attempting Chrome auto-extraction...");
|
|
51
|
+
const chromeTokens = extractFromChrome();
|
|
52
|
+
if (chromeTokens) {
|
|
53
|
+
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
54
|
+
// Retry the request
|
|
55
|
+
return slackAPI(method, params, { ...options, retryOnAuthFail: false });
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`${data.error} - Tokens expired. Open Slack in Chrome and use slack_refresh_tokens.`);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(data.error || "Slack API error");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve user ID to real name (with caching)
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveUser(userId, options = {}) {
|
|
69
|
+
if (!userId) return "unknown";
|
|
70
|
+
if (userCache.has(userId)) return userCache.get(userId);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await slackAPI("users.info", { user: userId }, options);
|
|
74
|
+
const name = result.user?.real_name || result.user?.name || userId;
|
|
75
|
+
userCache.set(userId, name);
|
|
76
|
+
return name;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
userCache.set(userId, userId);
|
|
79
|
+
return userId;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clear the user cache
|
|
85
|
+
*/
|
|
86
|
+
export function clearUserCache() {
|
|
87
|
+
userCache.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get user cache stats
|
|
92
|
+
*/
|
|
93
|
+
export function getUserCacheStats() {
|
|
94
|
+
return {
|
|
95
|
+
size: userCache.size,
|
|
96
|
+
entries: Array.from(userCache.entries())
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Format a Slack timestamp to ISO string
|
|
102
|
+
*/
|
|
103
|
+
export function formatTimestamp(ts) {
|
|
104
|
+
return new Date(parseFloat(ts) * 1000).toISOString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert ISO date to Slack timestamp
|
|
109
|
+
*/
|
|
110
|
+
export function toSlackTimestamp(isoDate) {
|
|
111
|
+
return (new Date(isoDate).getTime() / 1000).toString();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sleep for rate limiting
|
|
116
|
+
*/
|
|
117
|
+
export function sleep(ms) {
|
|
118
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
119
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Multi-layer token persistence:
|
|
5
|
+
* 1. Environment variables (highest priority)
|
|
6
|
+
* 2. Token file (~/.slack-mcp-tokens.json)
|
|
7
|
+
* 3. macOS Keychain (most secure)
|
|
8
|
+
* 4. Chrome auto-extraction (fallback)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
|
|
16
|
+
const TOKEN_FILE = join(homedir(), ".slack-mcp-tokens.json");
|
|
17
|
+
const KEYCHAIN_SERVICE = "slack-mcp-server";
|
|
18
|
+
|
|
19
|
+
// ============ Keychain Storage ============
|
|
20
|
+
|
|
21
|
+
export function getFromKeychain(key) {
|
|
22
|
+
try {
|
|
23
|
+
const result = execSync(
|
|
24
|
+
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w 2>/dev/null`,
|
|
25
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
26
|
+
);
|
|
27
|
+
return result.trim();
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveToKeychain(key, value) {
|
|
34
|
+
try {
|
|
35
|
+
// Delete existing entry
|
|
36
|
+
try {
|
|
37
|
+
execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" 2>/dev/null`, { stdio: 'pipe' });
|
|
38
|
+
} catch (e) { /* ignore */ }
|
|
39
|
+
|
|
40
|
+
// Add new entry
|
|
41
|
+
execSync(`security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w "${value}"`, { stdio: 'pipe' });
|
|
42
|
+
return true;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============ File Storage ============
|
|
49
|
+
|
|
50
|
+
export function getFromFile() {
|
|
51
|
+
if (!existsSync(TOKEN_FILE)) return null;
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
|
|
54
|
+
return {
|
|
55
|
+
token: data.SLACK_TOKEN,
|
|
56
|
+
cookie: data.SLACK_COOKIE,
|
|
57
|
+
updatedAt: data.updated_at
|
|
58
|
+
};
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function saveToFile(token, cookie) {
|
|
65
|
+
const data = {
|
|
66
|
+
SLACK_TOKEN: token,
|
|
67
|
+
SLACK_COOKIE: cookie,
|
|
68
|
+
updated_at: new Date().toISOString()
|
|
69
|
+
};
|
|
70
|
+
writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
|
|
71
|
+
try {
|
|
72
|
+
execSync(`chmod 600 "${TOKEN_FILE}"`);
|
|
73
|
+
} catch (e) { /* ignore on non-unix */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============ Chrome Extraction ============
|
|
77
|
+
|
|
78
|
+
export function extractFromChrome() {
|
|
79
|
+
try {
|
|
80
|
+
// Extract cookie
|
|
81
|
+
const cookieScript = `
|
|
82
|
+
tell application "Google Chrome"
|
|
83
|
+
repeat with w in windows
|
|
84
|
+
repeat with t in tabs of w
|
|
85
|
+
if URL of t contains "slack.com" then
|
|
86
|
+
return execute t javascript "document.cookie.split('; ').find(c => c.startsWith('d='))?.split('=')[1] || ''"
|
|
87
|
+
end if
|
|
88
|
+
end repeat
|
|
89
|
+
end repeat
|
|
90
|
+
return ""
|
|
91
|
+
end tell
|
|
92
|
+
`;
|
|
93
|
+
const cookie = execSync(`osascript -e '${cookieScript.replace(/'/g, "'\"'\"'")}'`, {
|
|
94
|
+
encoding: 'utf-8', timeout: 5000
|
|
95
|
+
}).trim();
|
|
96
|
+
|
|
97
|
+
if (!cookie || !cookie.startsWith('xoxd-')) return null;
|
|
98
|
+
|
|
99
|
+
// Extract token
|
|
100
|
+
const tokenScript = `
|
|
101
|
+
tell application "Google Chrome"
|
|
102
|
+
repeat with w in windows
|
|
103
|
+
repeat with t in tabs of w
|
|
104
|
+
if URL of t contains "slack.com" then
|
|
105
|
+
return execute t javascript "try{JSON.parse(localStorage.localConfig_v2).teams[Object.keys(JSON.parse(localStorage.localConfig_v2).teams)[0]].token}catch(e){''}"
|
|
106
|
+
end if
|
|
107
|
+
end repeat
|
|
108
|
+
end repeat
|
|
109
|
+
return ""
|
|
110
|
+
end tell
|
|
111
|
+
`;
|
|
112
|
+
const token = execSync(`osascript -e '${tokenScript.replace(/'/g, "'\"'\"'")}'`, {
|
|
113
|
+
encoding: 'utf-8', timeout: 5000
|
|
114
|
+
}).trim();
|
|
115
|
+
|
|
116
|
+
if (!token || !token.startsWith('xoxc-')) return null;
|
|
117
|
+
|
|
118
|
+
return { token, cookie };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============ Main Token Loader ============
|
|
125
|
+
|
|
126
|
+
export function loadTokens(forceRefresh = false, logger = console) {
|
|
127
|
+
// Priority 1: Environment variables
|
|
128
|
+
if (!forceRefresh && process.env.SLACK_TOKEN && process.env.SLACK_COOKIE) {
|
|
129
|
+
return {
|
|
130
|
+
token: process.env.SLACK_TOKEN,
|
|
131
|
+
cookie: process.env.SLACK_COOKIE,
|
|
132
|
+
source: "environment"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Priority 2: Token file
|
|
137
|
+
if (!forceRefresh) {
|
|
138
|
+
const fileTokens = getFromFile();
|
|
139
|
+
if (fileTokens?.token && fileTokens?.cookie) {
|
|
140
|
+
return {
|
|
141
|
+
token: fileTokens.token,
|
|
142
|
+
cookie: fileTokens.cookie,
|
|
143
|
+
source: "file",
|
|
144
|
+
updatedAt: fileTokens.updatedAt
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Priority 3: Keychain
|
|
150
|
+
if (!forceRefresh) {
|
|
151
|
+
const keychainToken = getFromKeychain("token");
|
|
152
|
+
const keychainCookie = getFromKeychain("cookie");
|
|
153
|
+
if (keychainToken && keychainCookie) {
|
|
154
|
+
return {
|
|
155
|
+
token: keychainToken,
|
|
156
|
+
cookie: keychainCookie,
|
|
157
|
+
source: "keychain"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Priority 4: Chrome auto-extract
|
|
163
|
+
logger.error("Attempting Chrome auto-extraction...");
|
|
164
|
+
const chromeTokens = extractFromChrome();
|
|
165
|
+
if (chromeTokens) {
|
|
166
|
+
logger.error("Successfully extracted tokens from Chrome!");
|
|
167
|
+
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
168
|
+
return {
|
|
169
|
+
token: chromeTokens.token,
|
|
170
|
+
cookie: chromeTokens.cookie,
|
|
171
|
+
source: "chrome-auto"
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function saveTokens(token, cookie) {
|
|
179
|
+
saveToFile(token, cookie);
|
|
180
|
+
saveToKeychain("token", token);
|
|
181
|
+
saveToKeychain("cookie", cookie);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { TOKEN_FILE, KEYCHAIN_SERVICE };
|