@jtalk22/slack-mcp 1.0.4 → 1.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.
- package/README.md +276 -193
- package/lib/handlers.js +182 -30
- package/lib/slack-client.js +168 -21
- package/lib/token-store.js +84 -10
- package/lib/tools.js +14 -2
- package/package.json +1 -1
- package/public/demo.html +715 -611
- package/public/index.html +128 -16
- package/scripts/verify-v106.js +159 -0
- package/scripts/verify-web.js +221 -0
- package/src/server.js +35 -2
- package/src/web-server.js +51 -9
package/lib/handlers.js
CHANGED
|
@@ -4,11 +4,107 @@
|
|
|
4
4
|
* Implementation of all MCP tool handlers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { writeFileSync } from "fs";
|
|
8
|
-
import { homedir } from "os";
|
|
7
|
+
import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync } from "fs";
|
|
8
|
+
import { homedir, platform } from "os";
|
|
9
9
|
import { join } from "path";
|
|
10
|
-
import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
|
|
11
|
-
import { slackAPI, resolveUser, formatTimestamp, sleep } from "./slack-client.js";
|
|
10
|
+
import { loadTokens, saveTokens, extractFromChrome, isAutoRefreshAvailable } from "./token-store.js";
|
|
11
|
+
import { slackAPI, resolveUser, formatTimestamp, sleep, checkTokenHealth, getUserCacheStats } from "./slack-client.js";
|
|
12
|
+
|
|
13
|
+
// ============ Utilities ============
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Robust boolean parser for LLM input unpredictability
|
|
17
|
+
* Handles: true, "true", "True", "1", 1, "yes", etc.
|
|
18
|
+
*/
|
|
19
|
+
function parseBool(val) {
|
|
20
|
+
if (typeof val === 'boolean') return val;
|
|
21
|
+
if (typeof val === 'number') return val !== 0;
|
|
22
|
+
if (typeof val === 'string') {
|
|
23
|
+
return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Atomic write to prevent file corruption from concurrent writes
|
|
30
|
+
*/
|
|
31
|
+
function atomicWriteSync(filePath, content) {
|
|
32
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(tempPath, content);
|
|
35
|
+
if (platform() === 'darwin' || platform() === 'linux') {
|
|
36
|
+
try { require('child_process').execSync(`chmod 600 "${tempPath}"`); } catch {}
|
|
37
|
+
}
|
|
38
|
+
renameSync(tempPath, filePath);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
try { unlinkSync(tempPath); } catch {}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ DM Cache ============
|
|
46
|
+
const DM_CACHE_FILE = join(homedir(), ".slack-mcp-dm-cache.json");
|
|
47
|
+
const DM_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
48
|
+
|
|
49
|
+
function loadDMCache() {
|
|
50
|
+
if (!existsSync(DM_CACHE_FILE)) return { dms: {}, updated: 0 };
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(DM_CACHE_FILE, "utf-8"));
|
|
53
|
+
// Check if cache is stale
|
|
54
|
+
if (Date.now() - (data.updated || 0) > DM_CACHE_TTL) {
|
|
55
|
+
return { dms: {}, updated: 0 };
|
|
56
|
+
}
|
|
57
|
+
return data;
|
|
58
|
+
} catch {
|
|
59
|
+
return { dms: {}, updated: 0 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveDMCache(dms) {
|
|
64
|
+
try {
|
|
65
|
+
const data = { dms, updated: Date.now() };
|
|
66
|
+
atomicWriteSync(DM_CACHE_FILE, JSON.stringify(data, null, 2));
|
|
67
|
+
} catch {
|
|
68
|
+
// Ignore write errors - cache is optional
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Token status handler - detailed token health info
|
|
74
|
+
*/
|
|
75
|
+
export async function handleTokenStatus() {
|
|
76
|
+
const health = await checkTokenHealth({ error: () => {} });
|
|
77
|
+
const cacheStats = getUserCacheStats();
|
|
78
|
+
const dmCache = loadDMCache();
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify({
|
|
84
|
+
token: {
|
|
85
|
+
status: health.healthy ? "healthy" : health.reason === 'no_tokens' ? "missing" : "warning",
|
|
86
|
+
age_hours: health.age_hours,
|
|
87
|
+
source: health.source,
|
|
88
|
+
updated_at: health.updated_at,
|
|
89
|
+
message: health.message
|
|
90
|
+
},
|
|
91
|
+
auto_refresh: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
interval: "4 hours",
|
|
94
|
+
last_attempt: health.refreshed ? "just now" : "on-demand",
|
|
95
|
+
requires: "Slack tab open in Chrome"
|
|
96
|
+
},
|
|
97
|
+
cache: {
|
|
98
|
+
users: cacheStats,
|
|
99
|
+
dms: {
|
|
100
|
+
count: Object.keys(dmCache.dms || {}).length,
|
|
101
|
+
age_hours: dmCache.updated ? Math.round((Date.now() - dmCache.updated) / (60 * 60 * 1000) * 10) / 10 : null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, null, 2)
|
|
105
|
+
}]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
12
108
|
|
|
13
109
|
/**
|
|
14
110
|
* Health check handler
|
|
@@ -53,6 +149,17 @@ export async function handleHealthCheck() {
|
|
|
53
149
|
* Refresh tokens handler
|
|
54
150
|
*/
|
|
55
151
|
export async function handleRefreshTokens() {
|
|
152
|
+
// Check platform support
|
|
153
|
+
if (!isAutoRefreshAvailable()) {
|
|
154
|
+
return {
|
|
155
|
+
content: [{
|
|
156
|
+
type: "text",
|
|
157
|
+
text: "Auto-refresh is only available on macOS.\n\nOn other platforms, manually update ~/.slack-mcp-tokens.json with:\n{\n \"SLACK_TOKEN\": \"xoxc-...\",\n \"SLACK_COOKIE\": \"xoxd-...\"\n}"
|
|
158
|
+
}],
|
|
159
|
+
isError: true
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
56
163
|
const chromeTokens = extractFromChrome();
|
|
57
164
|
if (chromeTokens) {
|
|
58
165
|
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
@@ -86,11 +193,12 @@ export async function handleRefreshTokens() {
|
|
|
86
193
|
}
|
|
87
194
|
|
|
88
195
|
/**
|
|
89
|
-
* List conversations handler
|
|
196
|
+
* List conversations handler (with lazy DM discovery)
|
|
90
197
|
*/
|
|
91
198
|
export async function handleListConversations(args) {
|
|
92
199
|
const types = args.types || "im,mpim";
|
|
93
200
|
const wantsDMs = types.includes("im") || types.includes("mpim");
|
|
201
|
+
const discoverDMs = parseBool(args.discover_dms); // Robust boolean parsing
|
|
94
202
|
|
|
95
203
|
const result = await slackAPI("conversations.list", {
|
|
96
204
|
types: types,
|
|
@@ -111,29 +219,48 @@ export async function handleListConversations(args) {
|
|
|
111
219
|
};
|
|
112
220
|
}));
|
|
113
221
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
222
|
+
// Load cached DMs first (fast path)
|
|
223
|
+
const dmCache = loadDMCache();
|
|
224
|
+
for (const [channelId, data] of Object.entries(dmCache.dms || {})) {
|
|
225
|
+
if (!conversations.find(c => c.id === channelId)) {
|
|
226
|
+
conversations.push(data);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Only do expensive full DM discovery if explicitly requested
|
|
231
|
+
// This avoids hitting rate limits on large workspaces
|
|
232
|
+
if (wantsDMs && discoverDMs) {
|
|
233
|
+
const newDMs = { ...dmCache.dms };
|
|
234
|
+
let discoveredCount = 0;
|
|
235
|
+
|
|
117
236
|
try {
|
|
118
|
-
const usersResult = await slackAPI("users.list", { limit:
|
|
237
|
+
const usersResult = await slackAPI("users.list", { limit: 200 });
|
|
119
238
|
for (const user of (usersResult.members || [])) {
|
|
120
239
|
if (user.is_bot || user.id === "USLACKBOT" || user.deleted) continue;
|
|
121
240
|
|
|
122
|
-
//
|
|
241
|
+
// Skip if we already have this user's DM
|
|
242
|
+
const existingDM = conversations.find(c => c.user_id === user.id && c.type === "dm");
|
|
243
|
+
if (existingDM) continue;
|
|
244
|
+
|
|
245
|
+
// Try to open DM with this user
|
|
123
246
|
try {
|
|
124
247
|
const dmResult = await slackAPI("conversations.open", { users: user.id });
|
|
125
|
-
if (dmResult.channel
|
|
248
|
+
if (dmResult.channel?.id) {
|
|
126
249
|
const channelId = dmResult.channel.id;
|
|
127
|
-
// Only add if not already in list
|
|
128
250
|
if (!conversations.find(c => c.id === channelId)) {
|
|
129
|
-
|
|
251
|
+
const dmData = {
|
|
130
252
|
id: channelId,
|
|
131
253
|
name: user.real_name || user.name,
|
|
132
254
|
type: "dm",
|
|
133
255
|
user_id: user.id
|
|
134
|
-
}
|
|
256
|
+
};
|
|
257
|
+
conversations.push(dmData);
|
|
258
|
+
newDMs[channelId] = dmData;
|
|
259
|
+
discoveredCount++;
|
|
135
260
|
}
|
|
136
261
|
}
|
|
262
|
+
// Rate limit protection
|
|
263
|
+
await sleep(50);
|
|
137
264
|
} catch (e) {
|
|
138
265
|
// Skip users we can't DM
|
|
139
266
|
}
|
|
@@ -141,12 +268,22 @@ export async function handleListConversations(args) {
|
|
|
141
268
|
} catch (e) {
|
|
142
269
|
// If users.list fails, continue with what we have
|
|
143
270
|
}
|
|
271
|
+
|
|
272
|
+
// Save updated cache
|
|
273
|
+
if (discoveredCount > 0) {
|
|
274
|
+
saveDMCache(newDMs);
|
|
275
|
+
}
|
|
144
276
|
}
|
|
145
277
|
|
|
146
278
|
return {
|
|
147
279
|
content: [{
|
|
148
280
|
type: "text",
|
|
149
|
-
text: JSON.stringify({
|
|
281
|
+
text: JSON.stringify({
|
|
282
|
+
count: conversations.length,
|
|
283
|
+
conversations,
|
|
284
|
+
cached_dms: Object.keys(dmCache.dms || {}).length,
|
|
285
|
+
hint: !discoverDMs && wantsDMs ? "Use discover_dms:true for full DM discovery (slower)" : undefined
|
|
286
|
+
}, null, 2)
|
|
150
287
|
}]
|
|
151
288
|
};
|
|
152
289
|
}
|
|
@@ -394,28 +531,43 @@ export async function handleGetThread(args) {
|
|
|
394
531
|
}
|
|
395
532
|
|
|
396
533
|
/**
|
|
397
|
-
* List users handler
|
|
534
|
+
* List users handler (with pagination support)
|
|
398
535
|
*/
|
|
399
536
|
export async function handleListUsers(args) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
537
|
+
const maxUsers = args.limit || 500;
|
|
538
|
+
const allUsers = [];
|
|
539
|
+
let cursor;
|
|
540
|
+
|
|
541
|
+
do {
|
|
542
|
+
const result = await slackAPI("users.list", {
|
|
543
|
+
limit: Math.min(200, maxUsers - allUsers.length),
|
|
544
|
+
cursor
|
|
545
|
+
});
|
|
403
546
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
547
|
+
const users = (result.members || [])
|
|
548
|
+
.filter(u => !u.deleted && !u.is_bot)
|
|
549
|
+
.map(u => ({
|
|
550
|
+
id: u.id,
|
|
551
|
+
name: u.name,
|
|
552
|
+
real_name: u.real_name,
|
|
553
|
+
display_name: u.profile?.display_name,
|
|
554
|
+
email: u.profile?.email,
|
|
555
|
+
is_admin: u.is_admin
|
|
556
|
+
}));
|
|
557
|
+
|
|
558
|
+
allUsers.push(...users);
|
|
559
|
+
cursor = result.response_metadata?.next_cursor;
|
|
560
|
+
|
|
561
|
+
// Small delay between pagination requests
|
|
562
|
+
if (cursor && allUsers.length < maxUsers) {
|
|
563
|
+
await sleep(100);
|
|
564
|
+
}
|
|
565
|
+
} while (cursor && allUsers.length < maxUsers);
|
|
414
566
|
|
|
415
567
|
return {
|
|
416
568
|
content: [{
|
|
417
569
|
type: "text",
|
|
418
|
-
text: JSON.stringify({ count:
|
|
570
|
+
text: JSON.stringify({ count: allUsers.length, users: allUsers }, null, 2)
|
|
419
571
|
}]
|
|
420
572
|
};
|
|
421
573
|
}
|
package/lib/slack-client.js
CHANGED
|
@@ -3,17 +3,138 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles all Slack API communication with:
|
|
5
5
|
* - Automatic token refresh on auth failure
|
|
6
|
-
* - User name caching
|
|
6
|
+
* - User name caching with LRU + TTL
|
|
7
7
|
* - Rate limiting
|
|
8
|
+
* - Network error retry with exponential backoff
|
|
9
|
+
* - Proactive token health checking
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
|
|
11
13
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
+
// ============ Configuration ============
|
|
15
|
+
|
|
16
|
+
const TOKEN_WARNING_AGE = 6 * 60 * 60 * 1000; // 6 hours
|
|
17
|
+
const TOKEN_CRITICAL_AGE = 10 * 60 * 60 * 1000; // 10 hours
|
|
18
|
+
const REFRESH_COOLDOWN = 60 * 60 * 1000; // 1 hour between refresh attempts
|
|
19
|
+
const USER_CACHE_MAX_SIZE = 500;
|
|
20
|
+
const USER_CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
21
|
+
|
|
22
|
+
const RETRYABLE_NETWORK_ERRORS = [
|
|
23
|
+
'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',
|
|
24
|
+
'ECONNREFUSED', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
let lastRefreshAttempt = 0;
|
|
28
|
+
|
|
29
|
+
// ============ LRU Cache with TTL ============
|
|
30
|
+
|
|
31
|
+
class LRUCache {
|
|
32
|
+
constructor(maxSize = 500, ttlMs = 60 * 60 * 1000) {
|
|
33
|
+
this.maxSize = maxSize;
|
|
34
|
+
this.ttlMs = ttlMs;
|
|
35
|
+
this.cache = new Map();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(key) {
|
|
39
|
+
const entry = this.cache.get(key);
|
|
40
|
+
if (!entry) return null;
|
|
41
|
+
if (Date.now() > entry.expiry) {
|
|
42
|
+
this.cache.delete(key);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Move to end (most recently used)
|
|
46
|
+
this.cache.delete(key);
|
|
47
|
+
this.cache.set(key, entry);
|
|
48
|
+
return entry.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
set(key, value) {
|
|
52
|
+
// Delete if exists (to update position)
|
|
53
|
+
this.cache.delete(key);
|
|
54
|
+
// Evict oldest if at capacity
|
|
55
|
+
if (this.cache.size >= this.maxSize) {
|
|
56
|
+
const firstKey = this.cache.keys().next().value;
|
|
57
|
+
this.cache.delete(firstKey);
|
|
58
|
+
}
|
|
59
|
+
this.cache.set(key, {
|
|
60
|
+
value,
|
|
61
|
+
expiry: Date.now() + this.ttlMs
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
has(key) {
|
|
66
|
+
return this.get(key) !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get size() { return this.cache.size; }
|
|
70
|
+
|
|
71
|
+
clear() { this.cache.clear(); }
|
|
72
|
+
|
|
73
|
+
stats() {
|
|
74
|
+
return { size: this.cache.size, maxSize: this.maxSize, ttlMs: this.ttlMs };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// User cache with LRU + TTL
|
|
79
|
+
const userCache = new LRUCache(USER_CACHE_MAX_SIZE, USER_CACHE_TTL);
|
|
80
|
+
|
|
81
|
+
// ============ Token Health ============
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check token health and attempt proactive refresh if needed
|
|
85
|
+
*/
|
|
86
|
+
export async function checkTokenHealth(logger = console) {
|
|
87
|
+
const silentLogger = { error: () => {}, warn: () => {}, log: () => {} };
|
|
88
|
+
const creds = loadTokens(false, silentLogger);
|
|
89
|
+
|
|
90
|
+
if (!creds) {
|
|
91
|
+
return { healthy: false, reason: 'no_tokens', message: 'No credentials found' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tokenAge = creds.updatedAt
|
|
95
|
+
? Date.now() - new Date(creds.updatedAt).getTime()
|
|
96
|
+
: Infinity;
|
|
97
|
+
const ageHours = Math.round(tokenAge / (60 * 60 * 1000) * 10) / 10;
|
|
98
|
+
|
|
99
|
+
// Attempt proactive refresh if token is getting old
|
|
100
|
+
if (tokenAge > TOKEN_WARNING_AGE && Date.now() - lastRefreshAttempt > REFRESH_COOLDOWN) {
|
|
101
|
+
lastRefreshAttempt = Date.now();
|
|
102
|
+
logger.error?.(`Token is ${ageHours}h old, attempting proactive refresh...`);
|
|
103
|
+
|
|
104
|
+
const newTokens = extractFromChrome();
|
|
105
|
+
if (newTokens) {
|
|
106
|
+
saveTokens(newTokens.token, newTokens.cookie);
|
|
107
|
+
logger.error?.('Proactively refreshed tokens from Chrome');
|
|
108
|
+
return {
|
|
109
|
+
healthy: true,
|
|
110
|
+
refreshed: true,
|
|
111
|
+
age_hours: 0,
|
|
112
|
+
source: 'chrome-auto',
|
|
113
|
+
message: 'Tokens refreshed successfully'
|
|
114
|
+
};
|
|
115
|
+
} else {
|
|
116
|
+
logger.error?.('Could not refresh from Chrome (is Slack tab open?)');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
healthy: tokenAge < TOKEN_CRITICAL_AGE,
|
|
122
|
+
age_hours: ageHours,
|
|
123
|
+
warning: tokenAge > TOKEN_WARNING_AGE,
|
|
124
|
+
critical: tokenAge > TOKEN_CRITICAL_AGE,
|
|
125
|
+
source: creds.source,
|
|
126
|
+
updated_at: creds.updatedAt,
|
|
127
|
+
message: tokenAge > TOKEN_CRITICAL_AGE
|
|
128
|
+
? 'Token may expire soon - open Slack in Chrome'
|
|
129
|
+
: tokenAge > TOKEN_WARNING_AGE
|
|
130
|
+
? 'Token is getting old - will auto-refresh if Slack tab is open'
|
|
131
|
+
: 'Token is healthy'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
14
134
|
|
|
15
135
|
/**
|
|
16
136
|
* Make an authenticated Slack API call
|
|
137
|
+
* Features: auth retry, rate limit handling, network error retry
|
|
17
138
|
*/
|
|
18
139
|
export async function slackAPI(method, params = {}, options = {}) {
|
|
19
140
|
const { retryOnAuthFail = true, retryCount = 0, maxRetries = 3, logger = console } = options;
|
|
@@ -23,15 +144,40 @@ export async function slackAPI(method, params = {}, options = {}) {
|
|
|
23
144
|
throw new Error("No credentials available. Run refresh-tokens.sh or open Slack in Chrome.");
|
|
24
145
|
}
|
|
25
146
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
147
|
+
// Proactive token health check (non-blocking, only on first attempt)
|
|
148
|
+
if (retryCount === 0) {
|
|
149
|
+
checkTokenHealth({ error: () => {} }).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let response;
|
|
153
|
+
try {
|
|
154
|
+
response = await fetch(`https://slack.com/api/${method}`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"Authorization": `Bearer ${creds.token}`,
|
|
158
|
+
"Cookie": `d=${creds.cookie}`,
|
|
159
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify(params),
|
|
162
|
+
});
|
|
163
|
+
} catch (networkError) {
|
|
164
|
+
// Retry on network errors with exponential backoff + jitter
|
|
165
|
+
if (retryCount < maxRetries) {
|
|
166
|
+
const isRetryable = RETRYABLE_NETWORK_ERRORS.some(e =>
|
|
167
|
+
networkError.message?.includes(e) ||
|
|
168
|
+
networkError.code === e ||
|
|
169
|
+
networkError.cause?.code === e
|
|
170
|
+
);
|
|
171
|
+
if (isRetryable || networkError.message?.includes('fetch')) {
|
|
172
|
+
const backoff = Math.min(1000 * Math.pow(2, retryCount), 10000);
|
|
173
|
+
const jitter = Math.random() * 1000;
|
|
174
|
+
logger.error?.(`Network error on ${method}: ${networkError.message}, retry ${retryCount + 1}/${maxRetries} in ${Math.round(backoff + jitter)}ms`);
|
|
175
|
+
await sleep(backoff + jitter);
|
|
176
|
+
return slackAPI(method, params, { ...options, retryCount: retryCount + 1 });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw networkError;
|
|
180
|
+
}
|
|
35
181
|
|
|
36
182
|
const data = await response.json();
|
|
37
183
|
|
|
@@ -40,14 +186,15 @@ export async function slackAPI(method, params = {}, options = {}) {
|
|
|
40
186
|
if (data.error === "ratelimited" && retryCount < maxRetries) {
|
|
41
187
|
const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
|
|
42
188
|
const backoff = Math.min(retryAfter * 1000, 30000) * (retryCount + 1);
|
|
43
|
-
|
|
44
|
-
|
|
189
|
+
const jitter = Math.random() * 1000;
|
|
190
|
+
logger.error?.(`Rate limited on ${method}, waiting ${Math.round(backoff + jitter)}ms before retry ${retryCount + 1}/${maxRetries}`);
|
|
191
|
+
await sleep(backoff + jitter);
|
|
45
192
|
return slackAPI(method, params, { ...options, retryCount: retryCount + 1 });
|
|
46
193
|
}
|
|
47
194
|
|
|
48
195
|
// Handle auth errors with auto-retry
|
|
49
196
|
if ((data.error === "invalid_auth" || data.error === "token_expired") && retryOnAuthFail) {
|
|
50
|
-
logger.error("Token expired, attempting Chrome auto-extraction...");
|
|
197
|
+
logger.error?.("Token expired, attempting Chrome auto-extraction...");
|
|
51
198
|
const chromeTokens = extractFromChrome();
|
|
52
199
|
if (chromeTokens) {
|
|
53
200
|
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
@@ -63,11 +210,13 @@ export async function slackAPI(method, params = {}, options = {}) {
|
|
|
63
210
|
}
|
|
64
211
|
|
|
65
212
|
/**
|
|
66
|
-
* Resolve user ID to real name (with caching)
|
|
213
|
+
* Resolve user ID to real name (with LRU caching)
|
|
67
214
|
*/
|
|
68
215
|
export async function resolveUser(userId, options = {}) {
|
|
69
216
|
if (!userId) return "unknown";
|
|
70
|
-
|
|
217
|
+
|
|
218
|
+
const cached = userCache.get(userId);
|
|
219
|
+
if (cached) return cached;
|
|
71
220
|
|
|
72
221
|
try {
|
|
73
222
|
const result = await slackAPI("users.info", { user: userId }, options);
|
|
@@ -75,6 +224,7 @@ export async function resolveUser(userId, options = {}) {
|
|
|
75
224
|
userCache.set(userId, name);
|
|
76
225
|
return name;
|
|
77
226
|
} catch (e) {
|
|
227
|
+
// Cache the ID itself to avoid repeated failed lookups
|
|
78
228
|
userCache.set(userId, userId);
|
|
79
229
|
return userId;
|
|
80
230
|
}
|
|
@@ -91,10 +241,7 @@ export function clearUserCache() {
|
|
|
91
241
|
* Get user cache stats
|
|
92
242
|
*/
|
|
93
243
|
export function getUserCacheStats() {
|
|
94
|
-
return
|
|
95
|
-
size: userCache.size,
|
|
96
|
-
entries: Array.from(userCache.entries())
|
|
97
|
-
};
|
|
244
|
+
return userCache.stats();
|
|
98
245
|
}
|
|
99
246
|
|
|
100
247
|
/**
|
package/lib/token-store.js
CHANGED
|
@@ -8,17 +8,24 @@
|
|
|
8
8
|
* 4. Chrome auto-extraction (fallback)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
12
|
-
import { homedir } from "os";
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "fs";
|
|
12
|
+
import { homedir, platform } from "os";
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { execSync } from "child_process";
|
|
15
15
|
|
|
16
16
|
const TOKEN_FILE = join(homedir(), ".slack-mcp-tokens.json");
|
|
17
17
|
const KEYCHAIN_SERVICE = "slack-mcp-server";
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Platform detection
|
|
20
|
+
const IS_MACOS = platform() === 'darwin';
|
|
21
|
+
|
|
22
|
+
// Refresh lock to prevent concurrent extraction attempts
|
|
23
|
+
let refreshInProgress = null;
|
|
24
|
+
|
|
25
|
+
// ============ Keychain Storage (macOS only) ============
|
|
20
26
|
|
|
21
27
|
export function getFromKeychain(key) {
|
|
28
|
+
if (!IS_MACOS) return null; // Keychain is macOS-only
|
|
22
29
|
try {
|
|
23
30
|
const result = execSync(
|
|
24
31
|
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w 2>/dev/null`,
|
|
@@ -31,6 +38,7 @@ export function getFromKeychain(key) {
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export function saveToKeychain(key, value) {
|
|
41
|
+
if (!IS_MACOS) return false; // Keychain is macOS-only
|
|
34
42
|
try {
|
|
35
43
|
// Delete existing entry
|
|
36
44
|
try {
|
|
@@ -61,21 +69,57 @@ export function getFromFile() {
|
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Atomic write to prevent file corruption from concurrent writes
|
|
74
|
+
*/
|
|
75
|
+
function atomicWriteSync(filePath, content) {
|
|
76
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(tempPath, content);
|
|
79
|
+
if (IS_MACOS || platform() === 'linux') {
|
|
80
|
+
try { execSync(`chmod 600 "${tempPath}"`); } catch {}
|
|
81
|
+
}
|
|
82
|
+
renameSync(tempPath, filePath); // Atomic on POSIX systems
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Clean up temp file on error
|
|
85
|
+
try { unlinkSync(tempPath); } catch {}
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
export function saveToFile(token, cookie) {
|
|
65
91
|
const data = {
|
|
66
92
|
SLACK_TOKEN: token,
|
|
67
93
|
SLACK_COOKIE: cookie,
|
|
68
94
|
updated_at: new Date().toISOString()
|
|
69
95
|
};
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
execSync(`chmod 600 "${TOKEN_FILE}"`);
|
|
73
|
-
} catch (e) { /* ignore on non-unix */ }
|
|
96
|
+
atomicWriteSync(TOKEN_FILE, JSON.stringify(data, null, 2));
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
// ============ Chrome Extraction ============
|
|
77
100
|
|
|
78
|
-
|
|
101
|
+
// Multiple localStorage paths Slack might use (for robustness)
|
|
102
|
+
const SLACK_TOKEN_PATHS = [
|
|
103
|
+
// Current known path
|
|
104
|
+
`JSON.parse(localStorage.localConfig_v2).teams[Object.keys(JSON.parse(localStorage.localConfig_v2).teams)[0]].token`,
|
|
105
|
+
// Potential future paths
|
|
106
|
+
`JSON.parse(localStorage.localConfig_v3).teams[Object.keys(JSON.parse(localStorage.localConfig_v3).teams)[0]].token`,
|
|
107
|
+
// Redux store path (older Slack)
|
|
108
|
+
`JSON.parse(localStorage.getItem('reduxPersist:localConfig'))?.teams?.[Object.keys(JSON.parse(localStorage.getItem('reduxPersist:localConfig'))?.teams || {})[0]]?.token`,
|
|
109
|
+
// Direct boot data
|
|
110
|
+
`window.boot_data?.api_token`,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Extract tokens from Chrome (macOS only, uses AppleScript)
|
|
115
|
+
* Returns null on non-macOS platforms
|
|
116
|
+
*/
|
|
117
|
+
function extractFromChromeInternal() {
|
|
118
|
+
if (!IS_MACOS) {
|
|
119
|
+
// AppleScript/osascript is macOS-only
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
79
123
|
try {
|
|
80
124
|
// Extract cookie
|
|
81
125
|
const cookieScript = `
|
|
@@ -96,13 +140,17 @@ export function extractFromChrome() {
|
|
|
96
140
|
|
|
97
141
|
if (!cookie || !cookie.startsWith('xoxd-')) return null;
|
|
98
142
|
|
|
99
|
-
//
|
|
143
|
+
// Try multiple token extraction paths
|
|
144
|
+
const tokenPathsJS = SLACK_TOKEN_PATHS.map((path, i) =>
|
|
145
|
+
`try { var t${i} = ${path}; if (t${i}?.startsWith('xoxc-')) return t${i}; } catch(e) {}`
|
|
146
|
+
).join(' ');
|
|
147
|
+
|
|
100
148
|
const tokenScript = `
|
|
101
149
|
tell application "Google Chrome"
|
|
102
150
|
repeat with w in windows
|
|
103
151
|
repeat with t in tabs of w
|
|
104
152
|
if URL of t contains "slack.com" then
|
|
105
|
-
return execute t javascript "
|
|
153
|
+
return execute t javascript "(function() { ${tokenPathsJS} return ''; })()"
|
|
106
154
|
end if
|
|
107
155
|
end repeat
|
|
108
156
|
end repeat
|
|
@@ -121,6 +169,32 @@ export function extractFromChrome() {
|
|
|
121
169
|
}
|
|
122
170
|
}
|
|
123
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Extract tokens from Chrome with mutex lock
|
|
174
|
+
* Prevents concurrent extraction attempts (race condition fix)
|
|
175
|
+
*/
|
|
176
|
+
export function extractFromChrome() {
|
|
177
|
+
// Simple mutex: if another extraction is running, skip this one
|
|
178
|
+
// This prevents race conditions from background + foreground refresh
|
|
179
|
+
if (refreshInProgress) {
|
|
180
|
+
return null; // Let the in-progress extraction complete
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
refreshInProgress = true;
|
|
185
|
+
return extractFromChromeInternal();
|
|
186
|
+
} finally {
|
|
187
|
+
refreshInProgress = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Check if auto-refresh is available on this platform
|
|
193
|
+
*/
|
|
194
|
+
export function isAutoRefreshAvailable() {
|
|
195
|
+
return IS_MACOS;
|
|
196
|
+
}
|
|
197
|
+
|
|
124
198
|
// ============ Main Token Loader ============
|
|
125
199
|
|
|
126
200
|
export function loadTokens(forceRefresh = false, logger = console) {
|