@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/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
|
-
|
|
134
|
-
|
|
135
|
-
<div class="
|
|
136
|
-
<
|
|
137
|
-
<
|
|
138
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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 = '
|
|
299
|
+
status.textContent = '● ' + health.user + ' @ ' + health.team;
|
|
190
300
|
status.className = 'status ok';
|
|
191
301
|
loadConversations('im,mpim');
|
|
192
302
|
} catch (e) {
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
*
|
|
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.
|
|
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);
|