@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/public/index.html CHANGED
@@ -127,16 +127,73 @@
127
127
  cursor: pointer;
128
128
  }
129
129
  .tabs button.active { background: #4ecdc4; color: #1a1a2e; }
130
+ /* Auth Modal */
131
+ .modal-overlay {
132
+ position: fixed;
133
+ top: 0; left: 0; right: 0; bottom: 0;
134
+ background: rgba(0,0,0,0.85);
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ z-index: 1000;
139
+ }
140
+ .modal-overlay.hidden { display: none; }
141
+ .modal {
142
+ background: #16213e;
143
+ padding: 40px;
144
+ border-radius: 12px;
145
+ max-width: 450px;
146
+ text-align: center;
147
+ border: 2px solid #4ecdc4;
148
+ }
149
+ .modal h2 { color: #4ecdc4; margin-bottom: 20px; }
150
+ .modal p { color: #aaa; margin-bottom: 25px; line-height: 1.6; }
151
+ .modal code {
152
+ background: #0f3460;
153
+ padding: 3px 8px;
154
+ border-radius: 4px;
155
+ color: #4ecdc4;
156
+ }
157
+ .modal input {
158
+ width: 100%;
159
+ padding: 14px;
160
+ border: 2px solid #0f3460;
161
+ border-radius: 6px;
162
+ background: #0f3460;
163
+ color: #fff;
164
+ font-size: 14px;
165
+ margin-bottom: 15px;
166
+ }
167
+ .modal input:focus { border-color: #4ecdc4; outline: none; }
168
+ .modal button {
169
+ width: 100%;
170
+ padding: 14px;
171
+ background: #4ecdc4;
172
+ color: #1a1a2e;
173
+ border: none;
174
+ border-radius: 6px;
175
+ cursor: pointer;
176
+ font-weight: bold;
177
+ font-size: 16px;
178
+ }
179
+ .modal button:hover { background: #45b7aa; }
180
+ .modal .error { color: #ff6b6b; margin-top: 10px; font-size: 14px; }
130
181
  </style>
131
182
  </head>
132
183
  <body>
133
- <div class="container">
134
- <h1>Slack Web API</h1>
135
- <div class="auth-section">
136
- <input type="text" id="apiKey" placeholder="API Key (default: slack-mcp-local)">
137
- <button onclick="connect()">Connect</button>
138
- <span id="status" class="status"></span>
184
+ <!-- Auth Modal -->
185
+ <div id="authModal" class="modal-overlay">
186
+ <div class="modal">
187
+ <h2>Enter API Key</h2>
188
+ <p>Copy the API key from the console where you ran<br><code>npm run web</code></p>
189
+ <input type="text" id="modalApiKey" placeholder="smcp_xxxxxxxxxxxx" autofocus>
190
+ <button onclick="submitApiKey()">Connect</button>
191
+ <div id="modalError" class="error"></div>
139
192
  </div>
193
+ </div>
194
+
195
+ <div class="container">
196
+ <h1>Slack Web API <span id="status" class="status"></span></h1>
140
197
  <div class="grid">
141
198
  <div class="sidebar">
142
199
  <h3>CONVERSATIONS</h3>
@@ -165,33 +222,88 @@
165
222
  </div>
166
223
  </div>
167
224
  <script>
168
- // Default API key matches server default - auto-connects on first visit
169
- const DEFAULT_KEY = 'slack-mcp-local';
170
- let apiKey = localStorage.getItem('slackApiKey') || DEFAULT_KEY;
225
+ let apiKey = null;
171
226
  let currentChannel = null;
172
227
 
173
- document.getElementById('apiKey').value = apiKey;
174
- setTimeout(connect, 100);
228
+ // Initialize: check URL param, then localStorage
229
+ (function init() {
230
+ const params = new URLSearchParams(window.location.search);
231
+ const keyFromUrl = params.get('key');
232
+
233
+ if (keyFromUrl) {
234
+ // Save key from magic link and strip from URL
235
+ apiKey = keyFromUrl;
236
+ localStorage.setItem('slackApiKey', apiKey);
237
+ history.replaceState({}, '', window.location.pathname);
238
+ } else {
239
+ apiKey = localStorage.getItem('slackApiKey');
240
+ }
241
+
242
+ if (apiKey) {
243
+ hideModal();
244
+ connect();
245
+ } else {
246
+ showModal();
247
+ }
248
+ })();
249
+
250
+ function showModal(errorMsg) {
251
+ document.getElementById('authModal').classList.remove('hidden');
252
+ document.getElementById('modalError').textContent = errorMsg || '';
253
+ document.getElementById('modalApiKey').focus();
254
+ }
255
+
256
+ function hideModal() {
257
+ document.getElementById('authModal').classList.add('hidden');
258
+ }
259
+
260
+ function submitApiKey() {
261
+ const input = document.getElementById('modalApiKey').value.trim();
262
+ if (!input) {
263
+ document.getElementById('modalError').textContent = 'Please enter an API key';
264
+ return;
265
+ }
266
+ apiKey = input;
267
+ localStorage.setItem('slackApiKey', apiKey);
268
+ hideModal();
269
+ connect();
270
+ }
271
+
272
+ // Handle Enter key in modal
273
+ document.getElementById('modalApiKey').addEventListener('keypress', (e) => {
274
+ if (e.key === 'Enter') submitApiKey();
275
+ });
276
+
175
277
  async function api(endpoint, options = {}) {
176
278
  const res = await fetch(endpoint, {
177
279
  ...options,
178
280
  headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json', ...options.headers }
179
281
  });
282
+ if (res.status === 401 || res.status === 403) {
283
+ // Auth failed - clear stored key and show modal
284
+ localStorage.removeItem('slackApiKey');
285
+ apiKey = null;
286
+ showModal('Invalid or expired API key. Check console for current key.');
287
+ throw new Error('Authentication failed');
288
+ }
180
289
  if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'API error'); }
181
290
  return res.json();
182
291
  }
292
+
183
293
  async function connect() {
184
- apiKey = document.getElementById('apiKey').value.trim() || DEFAULT_KEY;
185
- localStorage.setItem('slackApiKey', apiKey);
186
294
  const status = document.getElementById('status');
295
+ status.textContent = 'Connecting...';
296
+ status.className = 'status';
187
297
  try {
188
298
  const health = await api('/health');
189
- status.textContent = 'Connected as ' + health.user + ' @ ' + health.team;
299
+ status.textContent = ' ' + health.user + ' @ ' + health.team;
190
300
  status.className = 'status ok';
191
301
  loadConversations('im,mpim');
192
302
  } catch (e) {
193
- status.textContent = 'Error: ' + e.message;
194
- status.className = 'status error';
303
+ if (e.message !== 'Authentication failed') {
304
+ status.textContent = 'Error: ' + e.message;
305
+ status.className = 'status error';
306
+ }
195
307
  }
196
308
  }
197
309
  async function loadConversations(types) {
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * v1.0.6 Verification Script
4
+ *
5
+ * Tests:
6
+ * 1. Atomic write - no .tmp artifacts remain after write
7
+ * 2. Server exits cleanly (unref timer doesn't cause zombie)
8
+ */
9
+
10
+ import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, readdirSync } from "fs";
11
+ import { spawn } from "child_process";
12
+ import { homedir } from "os";
13
+ import { join, dirname } from "path";
14
+ import { fileURLToPath } from "url";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const TEST_DIR = join(homedir(), ".slack-mcp-test");
18
+ const TEST_FILE = join(TEST_DIR, "test-atomic.json");
19
+
20
+ // ============ Test 1: Atomic Write ============
21
+
22
+ function atomicWriteSync(filePath, content) {
23
+ const tempPath = `${filePath}.${process.pid}.tmp`;
24
+ try {
25
+ writeFileSync(tempPath, content);
26
+ renameSync(tempPath, filePath);
27
+ } catch (e) {
28
+ try { unlinkSync(tempPath); } catch {}
29
+ throw e;
30
+ }
31
+ }
32
+
33
+ async function testAtomicWrite() {
34
+ console.log("\n[TEST 1] Atomic Write");
35
+ console.log("─".repeat(40));
36
+
37
+ // Setup - ensure test directory exists
38
+ const { execSync } = await import("child_process");
39
+ try {
40
+ execSync(`mkdir -p "${TEST_DIR}"`);
41
+ } catch {}
42
+
43
+ // Test successful write
44
+ const testData = { test: "data", timestamp: Date.now() };
45
+ atomicWriteSync(TEST_FILE, JSON.stringify(testData, null, 2));
46
+
47
+ // Verify file exists
48
+ if (!existsSync(TEST_FILE)) {
49
+ console.log(" FAIL: File was not created");
50
+ return false;
51
+ }
52
+
53
+ // Verify content
54
+ const readBack = JSON.parse(readFileSync(TEST_FILE, "utf-8"));
55
+ if (readBack.test !== "data") {
56
+ console.log(" FAIL: Content mismatch");
57
+ return false;
58
+ }
59
+
60
+ // Check for .tmp artifacts in test dir
61
+ const files = readdirSync(TEST_DIR);
62
+ const tmpFiles = files.filter(f => f.endsWith(".tmp"));
63
+ if (tmpFiles.length > 0) {
64
+ console.log(` FAIL: Found .tmp artifacts: ${tmpFiles.join(", ")}`);
65
+ return false;
66
+ }
67
+
68
+ // Cleanup
69
+ try { unlinkSync(TEST_FILE); } catch {}
70
+
71
+ console.log(" PASS: Atomic write completed, no .tmp artifacts");
72
+ return true;
73
+ }
74
+
75
+ // ============ Test 2: Server Exit (No Zombie) ============
76
+
77
+ async function testServerExit() {
78
+ console.log("\n[TEST 2] Server Clean Exit (No Zombie)");
79
+ console.log("─".repeat(40));
80
+
81
+ const serverPath = join(__dirname, "../src/server.js");
82
+
83
+ return new Promise((resolve) => {
84
+ const timeout = 5000; // 5 second timeout
85
+ let exitCode = null;
86
+ let timedOut = false;
87
+
88
+ // Spawn server process
89
+ const proc = spawn("node", [serverPath], {
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ env: { ...process.env, SLACK_TOKEN: "test", SLACK_COOKIE: "test" }
92
+ });
93
+
94
+ // Set timeout - if process doesn't exit after stdin closes, it's a zombie
95
+ const timer = setTimeout(() => {
96
+ timedOut = true;
97
+ console.log(" FAIL: Server did not exit within 5s (zombie process detected)");
98
+ proc.kill("SIGKILL");
99
+ resolve(false);
100
+ }, timeout);
101
+
102
+ proc.on("exit", (code) => {
103
+ exitCode = code;
104
+ clearTimeout(timer);
105
+ if (!timedOut) {
106
+ console.log(` PASS: Server exited cleanly (code: ${code})`);
107
+ resolve(true);
108
+ }
109
+ });
110
+
111
+ proc.on("error", (err) => {
112
+ clearTimeout(timer);
113
+ console.log(` INFO: Server spawn error (expected if no SDK): ${err.message}`);
114
+ // This is OK - we're testing exit behavior, not full functionality
115
+ resolve(true);
116
+ });
117
+
118
+ // Close stdin immediately to simulate MCP client disconnect
119
+ proc.stdin.end();
120
+
121
+ // Give it a moment then send SIGTERM
122
+ setTimeout(() => {
123
+ if (exitCode === null && !timedOut) {
124
+ proc.kill("SIGTERM");
125
+ }
126
+ }, 1000);
127
+ });
128
+ }
129
+
130
+ // ============ Main ============
131
+
132
+ async function main() {
133
+ console.log("╔════════════════════════════════════════╗");
134
+ console.log("║ v1.0.6 Verification Tests ║");
135
+ console.log("╚════════════════════════════════════════╝");
136
+
137
+ const results = [];
138
+
139
+ results.push(await testAtomicWrite());
140
+ results.push(await testServerExit());
141
+
142
+ console.log("\n" + "═".repeat(40));
143
+ const passed = results.filter(r => r).length;
144
+ const total = results.length;
145
+
146
+ if (passed === total) {
147
+ console.log(`\n✓ ALL TESTS PASSED (${passed}/${total})`);
148
+ console.log("\nReady to deploy v1.0.6");
149
+ process.exit(0);
150
+ } else {
151
+ console.log(`\n✗ TESTS FAILED (${passed}/${total})`);
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ main().catch(e => {
157
+ console.error("Test error:", e);
158
+ process.exit(1);
159
+ });
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Web UI Verification Script
4
+ *
5
+ * Tests:
6
+ * 1. Server starts and prints Magic Link
7
+ * 2. /demo.html contains "STATIC PREVIEW" banner
8
+ * 3. /?key=... serves the dashboard (index.html)
9
+ * 4. Server shuts down cleanly
10
+ */
11
+
12
+ import { spawn } from "child_process";
13
+ import { dirname, join } from "path";
14
+ import { fileURLToPath } from "url";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const SERVER_PATH = join(__dirname, "../src/web-server.js");
18
+ const PORT = 3456; // Use non-standard port to avoid conflicts
19
+ const TIMEOUT = 15000;
20
+
21
+ let serverProc = null;
22
+
23
+ function log(msg) {
24
+ console.log(` ${msg}`);
25
+ }
26
+
27
+ function cleanup() {
28
+ if (serverProc) {
29
+ serverProc.kill("SIGTERM");
30
+ serverProc = null;
31
+ }
32
+ }
33
+
34
+ process.on("exit", cleanup);
35
+ process.on("SIGINT", () => { cleanup(); process.exit(1); });
36
+ process.on("SIGTERM", () => { cleanup(); process.exit(1); });
37
+
38
+ async function startServer() {
39
+ return new Promise((resolve, reject) => {
40
+ let magicLink = null;
41
+ let apiKey = null;
42
+ let output = "";
43
+
44
+ serverProc = spawn("node", [SERVER_PATH], {
45
+ env: { ...process.env, PORT: String(PORT) },
46
+ stdio: ["pipe", "pipe", "pipe"]
47
+ });
48
+
49
+ const timeout = setTimeout(() => {
50
+ reject(new Error("Server startup timeout - no magic link detected"));
51
+ }, TIMEOUT);
52
+
53
+ serverProc.stderr.on("data", (data) => {
54
+ const text = data.toString();
55
+ output += text;
56
+
57
+ // Look for magic link pattern
58
+ const match = text.match(/Dashboard:\s*(http:\/\/[^\s]+)/);
59
+ if (match) {
60
+ magicLink = match[1];
61
+ // Extract key from URL
62
+ const keyMatch = magicLink.match(/[?&]key=([^&\s]+)/);
63
+ if (keyMatch) {
64
+ apiKey = keyMatch[1];
65
+ }
66
+ }
67
+
68
+ // Server is ready when we see the full banner
69
+ if (output.includes("Dashboard:") && output.includes("API Key:")) {
70
+ clearTimeout(timeout);
71
+ resolve({ magicLink, apiKey });
72
+ }
73
+ });
74
+
75
+ serverProc.on("error", (err) => {
76
+ clearTimeout(timeout);
77
+ reject(err);
78
+ });
79
+
80
+ serverProc.on("exit", (code) => {
81
+ if (code !== null && code !== 0) {
82
+ clearTimeout(timeout);
83
+ reject(new Error(`Server exited with code ${code}`));
84
+ }
85
+ });
86
+ });
87
+ }
88
+
89
+ async function testDemoPage() {
90
+ const url = `http://localhost:${PORT}/demo.html`;
91
+ const res = await fetch(url);
92
+
93
+ if (!res.ok) {
94
+ throw new Error(`Failed to fetch demo.html: ${res.status}`);
95
+ }
96
+
97
+ const html = await res.text();
98
+
99
+ if (!html.includes("STATIC PREVIEW")) {
100
+ throw new Error("demo.html missing 'STATIC PREVIEW' banner");
101
+ }
102
+
103
+ if (!html.includes("Who is Alex?")) {
104
+ throw new Error("demo.html missing anonymized 'Who is Alex?' scenario");
105
+ }
106
+
107
+ return true;
108
+ }
109
+
110
+ async function testDashboard(apiKey) {
111
+ const url = `http://localhost:${PORT}/?key=${apiKey}`;
112
+ const res = await fetch(url);
113
+
114
+ if (!res.ok) {
115
+ throw new Error(`Failed to fetch dashboard: ${res.status}`);
116
+ }
117
+
118
+ const html = await res.text();
119
+
120
+ // Should serve index.html (the dashboard)
121
+ if (!html.includes("Slack Web API")) {
122
+ throw new Error("Dashboard page missing 'Slack Web API' title");
123
+ }
124
+
125
+ if (!html.includes("authModal")) {
126
+ throw new Error("Dashboard missing auth modal");
127
+ }
128
+
129
+ return true;
130
+ }
131
+
132
+ async function testApiWithKey(apiKey) {
133
+ // Test that API rejects bad key
134
+ const badRes = await fetch(`http://localhost:${PORT}/health`, {
135
+ headers: { "Authorization": "Bearer bad-key" }
136
+ });
137
+
138
+ if (badRes.status !== 401) {
139
+ throw new Error(`Expected 401 for bad key, got ${badRes.status}`);
140
+ }
141
+
142
+ return true;
143
+ }
144
+
145
+ async function main() {
146
+ console.log("╔════════════════════════════════════════╗");
147
+ console.log("║ Web UI Verification Tests ║");
148
+ console.log("╚════════════════════════════════════════╝");
149
+
150
+ const results = [];
151
+
152
+ try {
153
+ // Test 1: Server starts with magic link
154
+ console.log("\n[TEST 1] Server Startup & Magic Link");
155
+ console.log("─".repeat(40));
156
+
157
+ const { magicLink, apiKey } = await startServer();
158
+
159
+ if (!magicLink) {
160
+ throw new Error("No magic link found");
161
+ }
162
+ if (!apiKey) {
163
+ throw new Error("No API key found in magic link");
164
+ }
165
+
166
+ log(`Magic Link: ${magicLink}`);
167
+ log(`API Key: ${apiKey.substring(0, 20)}...`);
168
+ log("PASS: Server started with magic link");
169
+ results.push(true);
170
+
171
+ // Test 2: Demo page
172
+ console.log("\n[TEST 2] Demo Page (/demo.html)");
173
+ console.log("─".repeat(40));
174
+
175
+ await testDemoPage();
176
+ log("PASS: Demo page serves correctly with STATIC PREVIEW banner");
177
+ results.push(true);
178
+
179
+ // Test 3: Dashboard
180
+ console.log("\n[TEST 3] Dashboard (/?key=...)");
181
+ console.log("─".repeat(40));
182
+
183
+ await testDashboard(apiKey);
184
+ log("PASS: Dashboard serves with auth modal");
185
+ results.push(true);
186
+
187
+ // Test 4: API auth
188
+ console.log("\n[TEST 4] API Authentication");
189
+ console.log("─".repeat(40));
190
+
191
+ await testApiWithKey(apiKey);
192
+ log("PASS: API correctly rejects bad keys");
193
+ results.push(true);
194
+
195
+ } catch (err) {
196
+ console.log(` FAIL: ${err.message}`);
197
+ results.push(false);
198
+ } finally {
199
+ cleanup();
200
+ }
201
+
202
+ // Summary
203
+ console.log("\n" + "═".repeat(40));
204
+ const passed = results.filter(r => r).length;
205
+ const total = results.length;
206
+
207
+ if (passed === total) {
208
+ console.log(`\n✓ ALL TESTS PASSED (${passed}/${total})`);
209
+ console.log("\nReady to bump version to 1.1.0");
210
+ process.exit(0);
211
+ } else {
212
+ console.log(`\n✗ TESTS FAILED (${passed}/${total})`);
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ main().catch(e => {
218
+ console.error("Test error:", e);
219
+ cleanup();
220
+ process.exit(1);
221
+ });
package/src/server.js CHANGED
@@ -5,7 +5,13 @@
5
5
  * A Model Context Protocol server for Slack integration.
6
6
  * Provides read/write access to Slack messages, channels, and users.
7
7
  *
8
- * @version 1.0.0
8
+ * Features:
9
+ * - Automatic token refresh from Chrome
10
+ * - LRU user cache with TTL
11
+ * - Network error retry with exponential backoff
12
+ * - Background token health monitoring
13
+ *
14
+ * @version 1.1.0
9
15
  */
10
16
 
11
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -16,8 +22,10 @@ import {
16
22
  } from "@modelcontextprotocol/sdk/types.js";
17
23
 
18
24
  import { loadTokens } from "../lib/token-store.js";
25
+ import { checkTokenHealth } from "../lib/slack-client.js";
19
26
  import { TOOLS } from "../lib/tools.js";
20
27
  import {
28
+ handleTokenStatus,
21
29
  handleHealthCheck,
22
30
  handleRefreshTokens,
23
31
  handleListConversations,
@@ -30,9 +38,12 @@ import {
30
38
  handleListUsers,
31
39
  } from "../lib/handlers.js";
32
40
 
41
+ // Background refresh interval (4 hours)
42
+ const BACKGROUND_REFRESH_INTERVAL = 4 * 60 * 60 * 1000;
43
+
33
44
  // Package info
34
45
  const SERVER_NAME = "slack-mcp-server";
35
- const SERVER_VERSION = "1.0.0";
46
+ const SERVER_VERSION = "1.1.0";
36
47
 
37
48
  // Initialize server
38
49
  const server = new Server(
@@ -51,6 +62,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
51
62
 
52
63
  try {
53
64
  switch (name) {
65
+ case "slack_token_status":
66
+ return await handleTokenStatus();
67
+
54
68
  case "slack_health_check":
55
69
  return await handleHealthCheck();
56
70
 
@@ -104,8 +118,27 @@ async function main() {
104
118
  console.error("Will attempt Chrome auto-extraction on first API call");
105
119
  } else {
106
120
  console.error(`Credentials loaded from: ${credentials.source}`);
121
+
122
+ // Check token health on startup
123
+ const health = await checkTokenHealth({ error: () => {} });
124
+ if (health.warning) {
125
+ console.error(`Token age: ${health.age_hours}h - ${health.message}`);
126
+ }
107
127
  }
108
128
 
129
+ // Background token health check (every 4 hours)
130
+ // Use unref() so this timer doesn't prevent the process from exiting
131
+ // when the MCP transport closes (prevents zombie processes)
132
+ const backgroundTimer = setInterval(async () => {
133
+ const health = await checkTokenHealth(console);
134
+ if (health.refreshed) {
135
+ console.error("Background: tokens refreshed successfully");
136
+ } else if (health.critical) {
137
+ console.error("Background: tokens critical - open Slack in Chrome");
138
+ }
139
+ }, BACKGROUND_REFRESH_INTERVAL);
140
+ backgroundTimer.unref();
141
+
109
142
  // Start server
110
143
  const transport = new StdioServerTransport();
111
144
  await server.connect(transport);