@jtalk22/slack-mcp 4.1.0 → 4.1.2

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 CHANGED
@@ -24,7 +24,7 @@ Slack's official MCP server requires a registered app, admin approval, and [does
24
24
 
25
25
  This server uses your browser's session tokens instead. If you can see it in Slack, your AI agent can see it too. No app install, no scopes, no admin.
26
26
 
27
- **Stealth Mode:** Session tokens leave zero footprint in your workspace admin panel. No bot user appears, no app install shows up, no audit trail. Your AI agent operates with the same invisibility as your browser tab.
27
+ **Session-token transport:** No bot user appears in the workspace admin panel, no app install shows up, no audit trail entry is created. Your AI agent operates with the same workspace footprint as your browser tab — nothing more, nothing less.
28
28
 
29
29
  ![OAuth vs Chrome DB Decryption](docs/images/diagram-oauth-comparison.svg)
30
30
 
@@ -40,7 +40,7 @@ This server uses your browser's session tokens instead. If you can see it in Sla
40
40
  | Works with Codex CLI | No | **Yes** |
41
41
  | Setup time | ~30 min | **~2 min** |
42
42
  | Tools | Limited | **16** |
43
- | Visible to admins | Yes | **No — Stealth Mode** |
43
+ | Visible to admins | Yes | **No — session-token transport** |
44
44
 
45
45
  ## Quick Start per Client
46
46
 
@@ -230,7 +230,15 @@ On macOS, tokens are auto-extracted from Chrome — `env` block is optional.
230
230
  <details>
231
231
  <summary><strong>Claude Web / Remote MCP</strong></summary>
232
232
 
233
- Hosted version with permanent OAuth tokens coming soon. See [mcp.revasserlabs.com](https://mcp.revasserlabs.com) for updates.
233
+ Hosted tiers at [mcp.revasserlabs.com](https://mcp.revasserlabs.com):
234
+
235
+ | Tier | Price | What it owns |
236
+ |------|-------|-------------|
237
+ | Self-host | Free | Local stdio, all 16 tools, MIT licensed |
238
+ | Solo | $19/mo | Managed endpoint + OAuth 2.1 bridge (required for Claude.ai web) + encrypted storage |
239
+ | Team | $49/mo | Solo features + multi-seat routing |
240
+ | Turnkey Team Launch | from $2,500+ | Dedicated instance + 30-day setup support |
241
+ | Managed Reliability | from $800/mo+ | SLA-backed instance + incident response |
234
242
 
235
243
  </details>
236
244
 
@@ -270,6 +278,14 @@ Session tokens (`xoxc-` + `xoxd-`) from your browser. If you can see it in Slack
270
278
 
271
279
  Tokens expire. The server notices before you do — proactive health monitoring, automatic refresh on macOS, warnings when tokens age out. File writes are atomic (temp file → chmod → rename) to prevent corruption. Concurrent refresh attempts are mutex-locked.
272
280
 
281
+ ## What's New in 4.1.2
282
+
283
+ - **LevelDB extraction** — reads tokens directly from Chrome's LevelDB store. No live Slack tab required, no AppleScript flag dependency.
284
+ - **Multi-profile enumeration** — automatically picks the freshest Chrome profile. Override with `SLACK_MCP_CHROME_USER_DATA_DIR`, `SLACK_MCP_CHROME_PROFILE`, or `SLACK_MCP_EXTRACTION_MODE`.
285
+ - **Explicit shutdown handlers** — SIGTERM/SIGINT/SIGHUP/stdin EOF/stdin error all exit cleanly. Zero zombie processes.
286
+
287
+ Full release notes in [docs/INDEX.md](docs/INDEX.md) and on [GitHub releases/latest](https://github.com/jtalk22/slack-mcp-server/releases/latest).
288
+
273
289
  ## Hosted HTTP Mode
274
290
 
275
291
  For remote MCP endpoints (Cloudflare Worker, VPS, etc.):
@@ -326,4 +342,4 @@ Not affiliated with Slack Technologies, Inc. Uses browser session credentials
326
342
 
327
343
  ---
328
344
 
329
- Hosted version with semantic search, AI summaries, and permanent OAuth — coming soon at [mcp.revasserlabs.com](https://mcp.revasserlabs.com)
345
+ Hosted tiers live at [mcp.revasserlabs.com](https://mcp.revasserlabs.com): Solo $19/mo, Team $49/mo, Turnkey Team Launch from $2,500+, Managed Reliability from $800/mo+. Hosted owns the managed MCP endpoint, the OAuth 2.1 bridge into Claude.ai, encrypted credential storage, and the structural absence of the zombie-process class. It does not replace Chrome — the user still pastes `xoxc-`/`xoxd-` at setup.
@@ -9,12 +9,19 @@
9
9
  * - Proactive token health checking
10
10
  */
11
11
 
12
- import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
12
+ import {
13
+ loadTokens,
14
+ saveTokens,
15
+ extractFromChrome,
16
+ getLastExtractionError,
17
+ saveAutoHealTelemetry,
18
+ } from "./token-store.js";
13
19
 
14
20
  // ============ Configuration ============
15
21
 
16
22
  const TOKEN_WARNING_AGE = 10 * 24 * 60 * 60 * 1000; // 10 days
17
23
  const TOKEN_CRITICAL_AGE = 13 * 24 * 60 * 60 * 1000; // 13 days
24
+ const STUCK_THRESHOLD_MS = 24 * 60 * 60 * 1000; // Escalate to 'stuck' after 24h of repeated auto-heal failures
18
25
  const REFRESH_COOLDOWN = 60 * 60 * 1000; // 1 hour between refresh attempts
19
26
  const USER_CACHE_MAX_SIZE = 500;
20
27
  const USER_CACHE_TTL = 60 * 60 * 1000; // 1 hour
@@ -113,14 +120,21 @@ export async function checkTokenHealth(logger = console) {
113
120
  ? Math.round(tokenAge / (60 * 60 * 1000) * 10) / 10
114
121
  : null;
115
122
 
123
+ // Read auto-heal telemetry (only populated when source is "file")
124
+ let lastAutoHealAttempt = creds.lastAutoHealAttempt || null;
125
+ let lastAutoHealError = creds.lastAutoHealError || null;
126
+ let stuckSince = creds.stuckSince || null;
127
+
116
128
  // Attempt proactive refresh if token is getting old
117
129
  if (hasKnownAge && tokenAge > TOKEN_WARNING_AGE && Date.now() - lastRefreshAttempt > REFRESH_COOLDOWN) {
118
130
  lastRefreshAttempt = Date.now();
131
+ const attemptAt = new Date().toISOString();
119
132
  logger.error?.(`Token is ${ageHours}h old, attempting proactive refresh...`);
120
133
 
121
134
  const newTokens = extractFromChrome();
122
135
  if (newTokens) {
123
136
  saveTokens(newTokens.token, newTokens.cookie);
137
+ saveAutoHealTelemetry({ attemptAt, error: null });
124
138
  logger.error?.('Proactively refreshed tokens from Chrome');
125
139
  return {
126
140
  healthy: true,
@@ -129,35 +143,59 @@ export async function checkTokenHealth(logger = console) {
129
143
  age_known: true,
130
144
  age_state: 'fresh',
131
145
  source: 'chrome-auto',
146
+ last_auto_heal_attempt: attemptAt,
147
+ last_auto_heal_error: null,
148
+ stuck_since: null,
132
149
  message: 'Tokens refreshed successfully'
133
150
  };
134
151
  } else {
135
- logger.error?.('Could not refresh from Chrome (is Slack tab open?)');
152
+ const extractionError = getLastExtractionError();
153
+ const errorCode = extractionError?.code || 'chrome_extraction_failed';
154
+ saveAutoHealTelemetry({ attemptAt, error: errorCode });
155
+ lastAutoHealAttempt = attemptAt;
156
+ if (lastAutoHealError !== errorCode) {
157
+ stuckSince = attemptAt;
158
+ }
159
+ lastAutoHealError = errorCode;
160
+ logger.error?.(`Could not refresh from Chrome: ${extractionError?.message || 'unknown error'}`);
136
161
  }
137
162
  }
138
163
 
164
+ const stuckSinceMs = stuckSince ? new Date(stuckSince).getTime() : Number.NaN;
165
+ const isStuck = Number.isFinite(stuckSinceMs)
166
+ && (Date.now() - stuckSinceMs) > STUCK_THRESHOLD_MS
167
+ && !!lastAutoHealError;
168
+
139
169
  return {
140
170
  healthy: !hasKnownAge || tokenAge < TOKEN_CRITICAL_AGE,
141
171
  age_hours: ageHours,
142
172
  age_known: hasKnownAge,
143
- age_state: !hasKnownAge
144
- ? 'unknown'
145
- : tokenAge > TOKEN_CRITICAL_AGE
146
- ? 'critical'
147
- : tokenAge > TOKEN_WARNING_AGE
148
- ? 'warning'
149
- : 'healthy',
173
+ age_state: isStuck
174
+ ? 'stuck'
175
+ : !hasKnownAge
176
+ ? 'unknown'
177
+ : tokenAge > TOKEN_CRITICAL_AGE
178
+ ? 'critical'
179
+ : tokenAge > TOKEN_WARNING_AGE
180
+ ? 'warning'
181
+ : 'healthy',
150
182
  warning: hasKnownAge && tokenAge > TOKEN_WARNING_AGE,
151
183
  critical: hasKnownAge && tokenAge > TOKEN_CRITICAL_AGE,
184
+ stuck: isStuck,
152
185
  source: creds.source,
153
186
  updated_at: creds.updatedAt,
154
- message: !hasKnownAge
155
- ? 'Token age unknown (missing timestamp) - auth can still be valid'
156
- : tokenAge > TOKEN_CRITICAL_AGE
157
- ? 'Token may expire soon - open Slack in Chrome'
158
- : tokenAge > TOKEN_WARNING_AGE
159
- ? 'Token is getting old - will auto-refresh if Slack tab is open'
160
- : 'Token is healthy'
187
+ last_auto_heal_attempt: lastAutoHealAttempt,
188
+ last_auto_heal_error: lastAutoHealError,
189
+ stuck_since: stuckSince,
190
+ message: isStuck
191
+ ? `Auto-heal has been failing since ${stuckSince} (last error: ${lastAutoHealError}). Open Chrome > View > Developer > Allow JavaScript from Apple Events, then run npm run tokens:auto.`
192
+ : !hasKnownAge
193
+ ? 'Token age unknown (missing timestamp) - auth can still be valid'
194
+ : tokenAge > TOKEN_CRITICAL_AGE
195
+ ? 'Token may expire soon - open Slack in Chrome'
196
+ : tokenAge > TOKEN_WARNING_AGE
197
+ ? 'Token is getting old - will auto-refresh if Slack tab is open'
198
+ : 'Token is healthy'
161
199
  };
162
200
  }
163
201
 
@@ -250,13 +288,28 @@ export async function slackAPI(method, params = {}, options = {}) {
250
288
  // Handle auth errors with auto-retry
251
289
  if ((data.error === "invalid_auth" || data.error === "token_expired") && retryOnAuthFail) {
252
290
  logger.error?.("Token expired, attempting Chrome auto-extraction...");
291
+ const attemptAt = new Date().toISOString();
253
292
  const chromeTokens = extractFromChrome();
254
293
  if (chromeTokens) {
255
294
  saveTokens(chromeTokens.token, chromeTokens.cookie);
295
+ saveAutoHealTelemetry({ attemptAt, error: null });
256
296
  // Retry the request
257
297
  return slackAPI(method, params, { ...options, retryOnAuthFail: false });
258
298
  }
259
- throw new Error(`${data.error} - Tokens expired. Open Slack in Chrome and use slack_refresh_tokens.`);
299
+ const extractionError = getLastExtractionError() || {
300
+ code: 'chrome_extraction_failed',
301
+ message: 'Auto-heal attempted but no structured error surfaced.',
302
+ detail: null
303
+ };
304
+ saveAutoHealTelemetry({ attemptAt, error: extractionError.code });
305
+ const err = new Error(
306
+ `Slack auth failed (${data.error}) and auto-heal could not refresh tokens: ${extractionError.message}`
307
+ );
308
+ err.code = 'token_auth_failed';
309
+ err.slack_error = data.error;
310
+ err.extraction_error = extractionError;
311
+ err.next_action = 'Open http://localhost:3000 and click Refresh, OR run `npm run tokens:auto` with Slack open in Chrome, OR check Chrome > View > Developer > Allow JavaScript from Apple Events.';
312
+ throw err;
260
313
  }
261
314
  throw new Error(data.error || "Slack API error");
262
315
  }
@@ -8,7 +8,7 @@
8
8
  * 4. Chrome auto-extraction (fallback)
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, chmodSync, copyFileSync, mkdtempSync } from "fs";
11
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, chmodSync, copyFileSync, mkdtempSync, statSync, readdirSync } from "fs";
12
12
  import { homedir, platform, tmpdir } from "os";
13
13
  import { join } from "path";
14
14
  import { execFileSync } from "child_process";
@@ -20,6 +20,12 @@ const KEYCHAIN_SERVICE = "slack-mcp-server";
20
20
  // Platform detection
21
21
  const IS_MACOS = platform() === 'darwin';
22
22
 
23
+ // Default Chrome user-data dir on macOS
24
+ const DEFAULT_CHROME_BASE = join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
25
+
26
+ // Slack xoxc- token regex: 3 numeric segments then a hex signature
27
+ const XOXC_TOKEN_RE = /xoxc-[0-9]+-[0-9]+-[0-9]+-[a-f0-9]{20,}/g;
28
+
23
29
  // Refresh lock to prevent concurrent extraction attempts
24
30
  let refreshInProgress = null;
25
31
  let lastExtractionError = null;
@@ -65,13 +71,45 @@ export function getFromFile() {
65
71
  return {
66
72
  token: data.SLACK_TOKEN,
67
73
  cookie: data.SLACK_COOKIE,
68
- updatedAt: data.updated_at || data.UPDATED_AT || null
74
+ updatedAt: data.updated_at || data.UPDATED_AT || null,
75
+ lastAutoHealAttempt: data.last_auto_heal_attempt || null,
76
+ lastAutoHealError: data.last_auto_heal_error || null,
77
+ stuckSince: data.stuck_since || null
69
78
  };
70
79
  } catch (e) {
71
80
  return null;
72
81
  }
73
82
  }
74
83
 
84
+ /**
85
+ * Persist auto-heal telemetry into the token file.
86
+ * Best-effort: silent on failure (tokens are more important than metadata).
87
+ * error === null indicates a successful auto-heal; any non-null string is an
88
+ * error code (e.g. "apple_events_javascript_disabled"). When the error code
89
+ * changes, stuck_since is reset; when it stays the same across attempts,
90
+ * stuck_since is preserved so downstream consumers can detect a long-running
91
+ * stuck state.
92
+ */
93
+ export function saveAutoHealTelemetry({ attemptAt, error }) {
94
+ if (!existsSync(TOKEN_FILE)) return;
95
+ try {
96
+ const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
97
+ data.last_auto_heal_attempt = attemptAt;
98
+ if (error) {
99
+ if (data.last_auto_heal_error !== error) {
100
+ data.stuck_since = attemptAt;
101
+ }
102
+ data.last_auto_heal_error = error;
103
+ } else {
104
+ data.last_auto_heal_error = null;
105
+ data.stuck_since = null;
106
+ }
107
+ atomicWriteSync(TOKEN_FILE, JSON.stringify(data, null, 2));
108
+ } catch (e) {
109
+ // Silent: telemetry must never break the auto-heal hot path.
110
+ }
111
+ }
112
+
75
113
  /**
76
114
  * Atomic write to prevent file corruption from concurrent writes
77
115
  */
@@ -109,7 +147,65 @@ const SLACK_TOKEN_PATHS = [
109
147
  `window.boot_data?.api_token`,
110
148
  ];
111
149
 
112
- // Chrome profile directories to search (in priority order)
150
+ // Fallback profile list used when Local State JSON can't be read
151
+ const FALLBACK_CHROME_PROFILES = ['Default', 'Profile 1', 'Profile 2', 'Profile 3', 'Profile 4', 'Profile 5'];
152
+
153
+ // ============ Chrome profile discovery ============
154
+
155
+ /**
156
+ * Resolve the Chrome user-data directory.
157
+ * Override with SLACK_MCP_CHROME_USER_DATA_DIR for non-standard installations
158
+ * (e.g. a portable Chrome, a test profile, or a Chrome Canary layout).
159
+ */
160
+ function getChromeBase() {
161
+ return process.env.SLACK_MCP_CHROME_USER_DATA_DIR || DEFAULT_CHROME_BASE;
162
+ }
163
+
164
+ /**
165
+ * Extraction mode config:
166
+ * "auto" - LevelDB first, AppleScript fallback (default)
167
+ * "leveldb" - On-disk only, never touch AppleScript (CI-safe, headless-safe)
168
+ * "applescript"- Legacy AppleScript-only path
169
+ */
170
+ function getExtractionMode() {
171
+ const mode = (process.env.SLACK_MCP_EXTRACTION_MODE || 'auto').toLowerCase();
172
+ return ['auto', 'leveldb', 'applescript'].includes(mode) ? mode : 'auto';
173
+ }
174
+
175
+ /**
176
+ * Enumerate all Chrome profiles present on this machine, newest cookie DB first.
177
+ * SLACK_MCP_CHROME_PROFILE can pin a single profile (exact directory name).
178
+ * Falls back to the legacy hardcoded list if Local State is unreadable.
179
+ */
180
+ function enumerateChromeProfiles() {
181
+ const envProfile = process.env.SLACK_MCP_CHROME_PROFILE;
182
+ if (envProfile) return [envProfile];
183
+
184
+ const base = getChromeBase();
185
+ const localStatePath = join(base, 'Local State');
186
+
187
+ let profiles = [];
188
+ try {
189
+ const localState = JSON.parse(readFileSync(localStatePath, 'utf-8'));
190
+ profiles = Object.keys(localState.profile?.info_cache || {});
191
+ } catch {
192
+ profiles = [...FALLBACK_CHROME_PROFILES];
193
+ }
194
+
195
+ if (profiles.length === 0) profiles = [...FALLBACK_CHROME_PROFILES];
196
+
197
+ // Rank profiles by cookie-db mtime descending so the freshest Slack session wins.
198
+ const ranked = profiles.map(p => {
199
+ const cookiePath = join(base, p, 'Cookies');
200
+ let mtime = 0;
201
+ try { mtime = statSync(cookiePath).mtimeMs; } catch {}
202
+ return { name: p, mtime };
203
+ });
204
+ ranked.sort((a, b) => b.mtime - a.mtime);
205
+ return ranked.map(x => x.name);
206
+ }
207
+
208
+ // Chrome profile directories to search (legacy helper retained for back-compat)
113
209
  const CHROME_PROFILES = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
114
210
 
115
211
  function normalizeExtractionError(error) {
@@ -155,76 +251,118 @@ function normalizeExtractionError(error) {
155
251
  }
156
252
 
157
253
  /**
158
- * Extract the Slack session cookie from Chrome's encrypted cookie database.
159
- * The `d` cookie is HttpOnly JavaScript cannot access it via document.cookie.
160
- * This reads Chrome's SQLite cookie store and decrypts using the Keychain-stored key.
254
+ * Extract the Slack `d` cookie from a specific Chrome profile's cookie DB.
255
+ * Returns the decrypted xoxd- cookie string or null if this profile has no
256
+ * Slack session or decryption fails.
257
+ *
258
+ * Chrome holds a WAL lock on the live DB; we copy-then-query for safety.
161
259
  */
162
- function extractCookieFromChromeDB() {
163
- const chromeBase = join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
260
+ function extractCookieForProfile(profileDir) {
261
+ const cookiesPath = join(profileDir, 'Cookies');
262
+ if (!existsSync(cookiesPath)) return null;
164
263
 
165
- // Find the first profile with a Slack d cookie
166
- for (const profile of CHROME_PROFILES) {
167
- const cookiesPath = join(chromeBase, profile, 'Cookies');
168
- if (!existsSync(cookiesPath)) continue;
264
+ const tmpDir = mkdtempSync(join(tmpdir(), 'slack-mcp-'));
265
+ const tmpDb = join(tmpDir, 'Cookies');
266
+ try {
267
+ copyFileSync(cookiesPath, tmpDb);
169
268
 
170
- // Copy DB to temp location (Chrome holds a WAL lock on the original)
171
- const tmpDir = mkdtempSync(join(tmpdir(), 'slack-mcp-'));
172
- const tmpDb = join(tmpDir, 'Cookies');
173
- try {
174
- copyFileSync(cookiesPath, tmpDb);
269
+ const queryResult = execFileSync('sqlite3', [
270
+ tmpDb,
271
+ "SELECT hex(encrypted_value) FROM cookies WHERE host_key LIKE '%.slack.com%' AND name = 'd' LIMIT 1;"
272
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
175
273
 
176
- // Query for the encrypted d cookie
177
- const queryResult = execFileSync('sqlite3', [
178
- tmpDb,
179
- "SELECT hex(encrypted_value) FROM cookies WHERE host_key LIKE '%.slack.com%' AND name = 'd' LIMIT 1;"
180
- ], { encoding: 'utf-8', timeout: 5000 }).trim();
274
+ try { unlinkSync(tmpDb); unlinkSync(tmpDir); } catch {}
181
275
 
182
- // Clean up temp files
183
- try { unlinkSync(tmpDb); unlinkSync(tmpDir); } catch {}
276
+ if (!queryResult) return null;
184
277
 
185
- if (!queryResult) continue;
278
+ const encrypted = Buffer.from(queryResult, 'hex');
279
+ if (encrypted.length < 4) return null;
186
280
 
187
- // Convert hex back to buffer
188
- const encrypted = Buffer.from(queryResult, 'hex');
189
- if (encrypted.length < 4) continue;
281
+ // Chrome Safe Storage password (per-machine, stored in macOS Keychain)
282
+ const safeStoragePassword = execFileSync('security', [
283
+ 'find-generic-password', '-s', 'Chrome Safe Storage', '-w'
284
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
190
285
 
191
- // Get Chrome Safe Storage password from Keychain
192
- const safeStoragePassword = execFileSync('security', [
193
- 'find-generic-password', '-s', 'Chrome Safe Storage', '-w'
194
- ], { encoding: 'utf-8', timeout: 5000 }).trim();
286
+ // macOS Chrome cookies: v10 prefix + AES-128-CBC
287
+ const prefix = encrypted.subarray(0, 3).toString('utf-8');
288
+ if (prefix !== 'v10') return null;
195
289
 
196
- // Chrome macOS cookies: v10 prefix + AES-128-CBC
197
- const prefix = encrypted.subarray(0, 3).toString('utf-8');
198
- if (prefix !== 'v10') continue;
290
+ const ciphertext = encrypted.subarray(3);
291
+ const key = pbkdf2Sync(safeStoragePassword, 'saltysalt', 1003, 16, 'sha1');
292
+ const iv = Buffer.alloc(16, ' ');
199
293
 
200
- const ciphertext = encrypted.subarray(3);
294
+ const decipher = createDecipheriv('aes-128-cbc', key, iv);
295
+ let decrypted;
296
+ try {
297
+ decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
298
+ } catch {
299
+ return null;
300
+ }
201
301
 
202
- // Derive key: PBKDF2-SHA1, 1003 iterations, salt 'saltysalt', 16-byte key
203
- const key = pbkdf2Sync(safeStoragePassword, 'saltysalt', 1003, 16, 'sha1');
204
- const iv = Buffer.alloc(16, ' '); // 16 space characters
302
+ const text = decrypted.toString('utf-8');
303
+ const xoxdIndex = text.indexOf('xoxd-');
304
+ if (xoxdIndex < 0) return null;
305
+ return text.substring(xoxdIndex);
306
+ } catch {
307
+ try { unlinkSync(tmpDb); unlinkSync(tmpDir); } catch {}
308
+ return null;
309
+ }
310
+ }
205
311
 
206
- const decipher = createDecipheriv('aes-128-cbc', key, iv);
207
- let decrypted;
208
- try {
209
- decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
210
- } catch {
211
- continue; // Decryption failed for this profile, try next
212
- }
312
+ /**
313
+ * Legacy helper: walk CHROME_PROFILES and return the first cookie found.
314
+ * Retained so existing callers that only want a cookie string keep working.
315
+ */
316
+ function extractCookieFromChromeDB() {
317
+ const base = getChromeBase();
318
+ for (const profile of enumerateChromeProfiles()) {
319
+ const cookie = extractCookieForProfile(join(base, profile));
320
+ if (cookie) return cookie;
321
+ }
322
+ return null;
323
+ }
213
324
 
214
- // Find xoxd- in decrypted data (Chrome prepends internal metadata bytes)
215
- const text = decrypted.toString('utf-8');
216
- const xoxdIndex = text.indexOf('xoxd-');
217
- if (xoxdIndex < 0) continue;
325
+ /**
326
+ * Extract a Slack xoxc- token by reading the on-disk LevelDB for a profile.
327
+ * This is the preferred path:
328
+ * - No AppleScript required
329
+ * - No "Allow JavaScript from Apple Events" Chrome dev flag required
330
+ * - No live Slack tab required — the token just has to have been cached
331
+ * at some point during normal use
332
+ * - Works headlessly, works in CI, works when Chrome is closed
333
+ *
334
+ * We scan .ldb and .log files newest-first so the freshest cached token wins.
335
+ */
336
+ function extractTokenFromLevelDB(profileDir) {
337
+ const ldbDir = join(profileDir, 'Local Storage', 'leveldb');
338
+ if (!existsSync(ldbDir)) return null;
218
339
 
219
- return text.substring(xoxdIndex);
220
- } catch (e) {
221
- // Clean up on error and try next profile
222
- try { unlinkSync(tmpDb); } catch {}
223
- try { unlinkSync(tmpDir); } catch {}
340
+ let files;
341
+ try {
342
+ files = readdirSync(ldbDir)
343
+ .filter(f => /\.(ldb|log)$/.test(f))
344
+ .map(f => {
345
+ const p = join(ldbDir, f);
346
+ let mtime = 0;
347
+ try { mtime = statSync(p).mtimeMs; } catch {}
348
+ return { path: p, mtime };
349
+ })
350
+ .sort((a, b) => b.mtime - a.mtime);
351
+ } catch {
352
+ return null;
353
+ }
354
+
355
+ for (const f of files) {
356
+ try {
357
+ // Binary encoding avoids UTF-8 re-interpretation of snappy-compressed blocks
358
+ const txt = readFileSync(f.path).toString('binary');
359
+ XOXC_TOKEN_RE.lastIndex = 0;
360
+ const matches = txt.match(XOXC_TOKEN_RE);
361
+ if (matches && matches.length) return matches[0];
362
+ } catch {
224
363
  continue;
225
364
  }
226
365
  }
227
-
228
366
  return null;
229
367
  }
230
368
 
@@ -272,11 +410,27 @@ end tell`;
272
410
  /**
273
411
  * Extract tokens from Chrome (macOS only).
274
412
  *
275
- * Token: AppleScript executes JS in Chrome to read localStorage (requires
276
- * "Allow JavaScript from Apple Events" in Chrome > View > Developer).
277
- * Cookie: Reads Chrome's encrypted SQLite cookie database directly. The `d`
278
- * session cookie is HttpOnly and cannot be accessed via document.cookie.
279
- * Decryption uses the Chrome Safe Storage key from macOS Keychain.
413
+ * Two extraction paths:
414
+ *
415
+ * 1. LevelDB (preferred, default "auto" mode tries this first):
416
+ * Cookie: Reads the encrypted SQLite cookie DB and decrypts with the
417
+ * Chrome Safe Storage key from macOS Keychain.
418
+ * Token: Reads the on-disk LevelDB under Local Storage and regex-matches
419
+ * any cached xoxc- token. Works without a live Slack tab, without
420
+ * the AppleScript dev flag, and works when Chrome is closed.
421
+ *
422
+ * 2. AppleScript (legacy fallback, or forced with SLACK_MCP_EXTRACTION_MODE=applescript):
423
+ * Cookie: Same SQLite-backed path.
424
+ * Token: Drives Chrome via AppleScript to run JS against localStorage.
425
+ * Requires Chrome > View > Developer > "Allow JavaScript from
426
+ * Apple Events" AND a live app.slack.com tab. Kept because it
427
+ * grabs the token from whichever workspace is actually active
428
+ * right now, which can differ from what's cached on disk.
429
+ *
430
+ * Environment overrides:
431
+ * SLACK_MCP_CHROME_USER_DATA_DIR - base Chrome dir (default ~/Library/Application Support/Google/Chrome)
432
+ * SLACK_MCP_CHROME_PROFILE - pin a single profile directory name
433
+ * SLACK_MCP_EXTRACTION_MODE - auto | leveldb | applescript
280
434
  */
281
435
  function extractFromChromeInternal() {
282
436
  lastExtractionError = null;
@@ -285,56 +439,85 @@ function extractFromChromeInternal() {
285
439
  lastExtractionError = {
286
440
  code: "unsupported_platform",
287
441
  message: "Chrome auto-extraction is only available on macOS.",
288
- detail: "Use manual token setup on this platform."
442
+ detail: "Use manual token setup on this platform, or set SLACK_TOKEN and SLACK_COOKIE env vars."
289
443
  };
290
444
  return null;
291
445
  }
292
446
 
293
- // Extract cookie from Chrome's encrypted cookie database
294
- let cookie;
295
- try {
296
- cookie = extractCookieFromChromeDB();
297
- } catch (e) {
298
- lastExtractionError = normalizeExtractionError(e);
299
- return null;
300
- }
447
+ const mode = getExtractionMode();
448
+ const base = getChromeBase();
449
+ const profiles = enumerateChromeProfiles();
301
450
 
302
- if (!cookie) {
451
+ if (profiles.length === 0) {
303
452
  lastExtractionError = {
304
- code: "cookie_not_found",
305
- message: "Could not extract Slack session cookie from Chrome.",
306
- detail: "Ensure you are logged into Slack at app.slack.com in Chrome."
453
+ code: "no_chrome_profiles",
454
+ message: "No Chrome profiles found.",
455
+ detail: `Looked under ${base}. Set SLACK_MCP_CHROME_USER_DATA_DIR if Chrome is installed elsewhere.`
307
456
  };
308
457
  return null;
309
458
  }
310
459
 
311
- // Extract token via AppleScript (localStorage)
312
- let token;
313
- try {
314
- token = extractTokenFromChrome();
315
- } catch (e) {
316
- lastExtractionError = normalizeExtractionError(e);
317
- // If we got the cookie but not the token, give a specific error
318
- if (cookie && !token) {
460
+ // --- Path 1: LevelDB (no AppleScript, no live tab needed) ---
461
+ if (mode === 'leveldb' || mode === 'auto') {
462
+ for (const profileName of profiles) {
463
+ const profileDir = join(base, profileName);
464
+ const cookie = extractCookieForProfile(profileDir);
465
+ if (!cookie) continue;
466
+ const token = extractTokenFromLevelDB(profileDir);
467
+ if (!token) continue;
468
+ return { token, cookie, profile: profileName, extraction_mode: 'leveldb' };
469
+ }
470
+
471
+ if (mode === 'leveldb') {
472
+ lastExtractionError = {
473
+ code: "leveldb_no_matching_profile",
474
+ message: "No Chrome profile had both a Slack cookie and a cached xoxc- token on disk.",
475
+ detail: `Profiles checked: ${profiles.join(', ')}. Open Slack in Chrome and sign in once, then retry. SLACK_MCP_CHROME_PROFILE can pin a specific profile.`
476
+ };
477
+ return null;
478
+ }
479
+ // Fall through to AppleScript
480
+ }
481
+
482
+ // --- Path 2: AppleScript + SQLite (legacy, requires live tab + dev flag) ---
483
+ if (mode === 'applescript' || mode === 'auto') {
484
+ let cookieSeen = null;
485
+ for (const profileName of profiles) {
486
+ const profileDir = join(base, profileName);
487
+ const cookie = extractCookieForProfile(profileDir);
488
+ if (!cookie) continue;
489
+ cookieSeen = cookie;
490
+
491
+ let token;
492
+ try {
493
+ token = extractTokenFromChrome();
494
+ } catch (e) {
495
+ lastExtractionError = normalizeExtractionError(e);
496
+ continue;
497
+ }
498
+ if (token) {
499
+ return { token, cookie, profile: profileName, extraction_mode: 'applescript' };
500
+ }
501
+ }
502
+
503
+ if (cookieSeen && !lastExtractionError) {
319
504
  lastExtractionError = {
320
505
  code: "apple_events_javascript_disabled",
321
- message: "Cookie extracted, but token extraction requires a Chrome setting.",
322
- detail: "In Chrome: View > Developer > Allow JavaScript from Apple Events. Then retry."
506
+ message: "Cookie extracted, but AppleScript could not read the Slack token from localStorage.",
507
+ detail: "Enable Chrome > View > Developer > Allow JavaScript from Apple Events, then retry. Or set SLACK_MCP_EXTRACTION_MODE=leveldb to skip AppleScript entirely."
323
508
  };
509
+ return null;
324
510
  }
325
- return null;
326
511
  }
327
512
 
328
- if (!token) {
513
+ if (!lastExtractionError) {
329
514
  lastExtractionError = {
330
- code: "token_not_found",
331
- message: "Could not extract Slack token from Chrome.",
332
- detail: "Ensure a Slack workspace is open in Chrome (not just the landing page). If Chrome blocks AppleScript, enable View > Developer > Allow JavaScript from Apple Events."
515
+ code: "extraction_failed_all_paths",
516
+ message: "Could not extract Slack credentials via LevelDB or AppleScript.",
517
+ detail: `Profiles checked: ${profiles.join(', ')}. Ensure you are logged into Slack at app.slack.com in Chrome at least once.`
333
518
  };
334
- return null;
335
519
  }
336
-
337
- return { token, cookie };
520
+ return null;
338
521
  }
339
522
 
340
523
  /**
@@ -384,7 +567,10 @@ function getStoredTokens() {
384
567
  token: fileTokens.token,
385
568
  cookie: fileTokens.cookie,
386
569
  source: "file",
387
- updatedAt: fileTokens.updatedAt
570
+ updatedAt: fileTokens.updatedAt,
571
+ lastAutoHealAttempt: fileTokens.lastAutoHealAttempt,
572
+ lastAutoHealError: fileTokens.lastAutoHealError,
573
+ stuckSince: fileTokens.stuckSince
388
574
  };
389
575
  }
390
576
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jtalk22/slack-mcp",
3
3
  "mcpName": "io.github.jtalk22/slack-mcp-server",
4
- "version": "4.1.0",
4
+ "version": "4.1.2",
5
5
  "description": "Slack MCP without OAuth — no app registration, no admin approval. Works with Claude Code, Cursor, Copilot (where the official server doesn't). 16 tools, one command.",
6
6
  "type": "module",
7
7
  "main": "src/server.js",
package/public/index.html CHANGED
@@ -255,8 +255,8 @@
255
255
  <div class="container">
256
256
  <h1>Slack Web API <span id="status" class="status"></span></h1>
257
257
  <div style="background:rgba(240,194,70,0.08);border:1px solid rgba(240,194,70,0.2);border-radius:8px;padding:8px 14px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;font-size:13px;color:#d4c48a">
258
- <span>Hosted version coming soon — <strong style="color:#f0c246">permanent OAuth, semantic search, AI summaries</strong></span>
259
- <a href="https://mcp.revasserlabs.com" style="color:#f0c246;font-weight:600;text-decoration:none;white-space:nowrap" target="_blank">Learn more &rarr;</a>
258
+ <span>Hosted tiers live — <strong style="color:#f0c246">managed MCP endpoint, OAuth bridge for Claude.ai, encrypted storage</strong></span>
259
+ <a href="https://mcp.revasserlabs.com" style="color:#f0c246;font-weight:600;text-decoration:none;white-space:nowrap" target="_blank">See tiers &rarr;</a>
260
260
  </div>
261
261
  <div class="grid">
262
262
  <div class="sidebar">
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "url": "https://github.com/jtalk22/slack-mcp-server",
18
18
  "source": "github"
19
19
  },
20
- "version": "4.1.0",
20
+ "version": "4.1.2",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "@jtalk22/slack-mcp",
31
- "version": "4.1.0",
31
+ "version": "4.1.2",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },
package/src/server.js CHANGED
@@ -290,6 +290,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
290
290
  };
291
291
  }
292
292
  } catch (error) {
293
+ if (error?.code === "token_auth_failed") {
294
+ return {
295
+ content: [{
296
+ type: "text",
297
+ text: JSON.stringify({
298
+ status: "error",
299
+ code: "token_auth_failed",
300
+ message: String(error?.message || error),
301
+ slack_error: error.slack_error || null,
302
+ extraction_error: error.extraction_error || null,
303
+ next_action: error.next_action || "Open http://localhost:3000 and click Refresh, OR run `npm run tokens:auto` with Slack open in Chrome, OR check Chrome > View > Developer > Allow JavaScript from Apple Events."
304
+ }, null, 2)
305
+ }],
306
+ isError: true
307
+ };
308
+ }
293
309
  return {
294
310
  content: [{
295
311
  type: "text",
@@ -323,8 +339,9 @@ async function main() {
323
339
  }
324
340
 
325
341
  // Background token health check (every 4 hours)
326
- // Use unref() so this timer doesn't prevent the process from exiting
327
- // when the MCP transport closes (prevents zombie processes)
342
+ // unref() alone doesn't prevent StdioServerTransport from keeping the event
343
+ // loop alive after the MCP client disconnects we add explicit shutdown
344
+ // handlers below to kill zombie processes on stdin EOF and signals.
328
345
  const backgroundTimer = setInterval(async () => {
329
346
  try {
330
347
  const health = await checkTokenHealth(console);
@@ -339,6 +356,23 @@ async function main() {
339
356
  }, BACKGROUND_REFRESH_INTERVAL);
340
357
  backgroundTimer.unref();
341
358
 
359
+ // Explicit shutdown path prevents the zombie-process pileup we were seeing
360
+ // when Claude Code or another MCP client disconnected without signalling.
361
+ // StdioServerTransport doesn't exit the event loop on its own when stdin EOFs.
362
+ let shuttingDown = false;
363
+ const shutdown = (reason) => {
364
+ if (shuttingDown) return;
365
+ shuttingDown = true;
366
+ try { clearInterval(backgroundTimer); } catch {}
367
+ console.error(`slack-mcp-server exiting: ${reason}`);
368
+ process.exit(0);
369
+ };
370
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
371
+ process.on("SIGINT", () => shutdown("SIGINT"));
372
+ process.on("SIGHUP", () => shutdown("SIGHUP"));
373
+ process.stdin.on("end", () => shutdown("stdin end (MCP client disconnected)"));
374
+ process.stdin.on("error", (err) => shutdown(`stdin error: ${err?.message || err}`));
375
+
342
376
  // Start server
343
377
  const transport = new StdioServerTransport();
344
378
  await server.connect(transport);