slack-max-api-mcp 1.0.4 → 1.0.6

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.
@@ -1,38 +1,80 @@
1
- #!/usr/bin/env node
2
-
1
+ #!/usr/bin/env node
2
+
3
3
  const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const http = require("node:http");
4
6
  const path = require("node:path");
7
+ const crypto = require("node:crypto");
8
+ const { spawn } = require("node:child_process");
5
9
  const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
6
10
  const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
7
11
  const { z } = require("zod");
8
-
9
- const SERVER_NAME = "slack-max-api-mcp";
10
- const SERVER_VERSION = "2.0.0";
11
-
12
- const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
13
-
12
+
13
+ const SERVER_NAME = "slack-max-api-mcp";
14
+ const SERVER_VERSION = "2.0.0";
15
+
16
+ const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
17
+
14
18
  const CATALOG_PATH =
15
19
  process.env.SLACK_CATALOG_PATH || path.join(process.cwd(), "data", "slack-catalog.json");
16
20
  const METHOD_TOOL_PREFIX = process.env.SLACK_METHOD_TOOL_PREFIX || "slack_method";
17
21
  const ENABLE_METHOD_TOOLS = process.env.SLACK_ENABLE_METHOD_TOOLS !== "false";
18
22
  const MAX_METHOD_TOOLS = Number(process.env.SLACK_MAX_METHOD_TOOLS || 0);
19
23
  const ENV_EXAMPLE_PATH = path.join(process.cwd(), ".env.example");
20
-
24
+ const TOKEN_STORE_PATH =
25
+ process.env.SLACK_TOKEN_STORE_PATH ||
26
+ path.join(os.homedir(), ".slack-max-api-mcp", "tokens.json");
27
+ const CLIENT_CONFIG_PATH =
28
+ process.env.SLACK_CLIENT_CONFIG_PATH ||
29
+ path.join(os.homedir(), ".slack-max-api-mcp", "client.json");
30
+ const ALLOW_ENV_EXAMPLE_FALLBACK = process.env.SLACK_ALLOW_ENV_EXAMPLE_FALLBACK === "true";
31
+ const OAUTH_CALLBACK_HOST = process.env.SLACK_OAUTH_CALLBACK_HOST || "127.0.0.1";
32
+ const OAUTH_CALLBACK_PORT = Number(process.env.SLACK_OAUTH_CALLBACK_PORT || 8787);
33
+ const OAUTH_CALLBACK_PATH = process.env.SLACK_OAUTH_CALLBACK_PATH || "/slack/oauth/callback";
34
+ const OAUTH_TIMEOUT_MS = Number(process.env.SLACK_OAUTH_TIMEOUT_MS || 5 * 60 * 1000);
35
+ const DEFAULT_OAUTH_BOT_SCOPES =
36
+ process.env.SLACK_OAUTH_BOT_SCOPES || "chat:write,channels:read,groups:read,users:read";
37
+ const DEFAULT_OAUTH_USER_SCOPES =
38
+ process.env.SLACK_OAUTH_USER_SCOPES ||
39
+ "search:read,channels:read,groups:read,channels:history,groups:history";
40
+ const RETRYABLE_TOKEN_ERRORS = new Set(["not_allowed_token_type", "missing_scope"]);
41
+ const GATEWAY_API_KEY = process.env.SLACK_GATEWAY_API_KEY || "";
42
+ const GATEWAY_PROFILE = process.env.SLACK_GATEWAY_PROFILE || "";
43
+ const GATEWAY_HOST = process.env.SLACK_GATEWAY_HOST || "127.0.0.1";
44
+ const GATEWAY_PORT = Number(process.env.SLACK_GATEWAY_PORT || 8790);
45
+ const GATEWAY_PUBLIC_BASE_URL =
46
+ process.env.SLACK_GATEWAY_PUBLIC_BASE_URL || `http://${GATEWAY_HOST}:${GATEWAY_PORT}`;
47
+ const GATEWAY_ALLOW_PUBLIC = process.env.SLACK_GATEWAY_ALLOW_PUBLIC === "true";
48
+ const GATEWAY_SHARED_SECRET = process.env.SLACK_GATEWAY_SHARED_SECRET || GATEWAY_API_KEY;
49
+ const GATEWAY_CLIENT_API_KEY =
50
+ process.env.SLACK_GATEWAY_CLIENT_API_KEY || GATEWAY_API_KEY || GATEWAY_SHARED_SECRET;
51
+ const GATEWAY_STATE_TTL_MS = Number(process.env.SLACK_GATEWAY_STATE_TTL_MS || 15 * 60 * 1000);
52
+ const INVITE_TOKEN_DEFAULT_DAYS = Number(process.env.SLACK_INVITE_TOKEN_DEFAULT_DAYS || 7);
53
+ const AUTO_ONBOARD_ENABLED = process.env.SLACK_AUTO_ONBOARD !== "false";
54
+ const AUTO_ONBOARD_GATEWAY =
55
+ process.env.SLACK_AUTO_ONBOARD_GATEWAY || process.env.SLACK_ONBOARD_GATEWAY_URL || "";
56
+ const AUTO_ONBOARD_TOKEN = process.env.SLACK_AUTO_ONBOARD_TOKEN || process.env.SLACK_ONBOARD_TOKEN || "";
57
+ const AUTO_ONBOARD_URL = process.env.SLACK_AUTO_ONBOARD_URL || process.env.SLACK_ONBOARD_URL || "";
58
+ const ONBOARD_PACKAGE_SPEC =
59
+ process.env.SLACK_ONBOARD_PACKAGE_SPEC ||
60
+ process.env.SLACK_ONBOARD_INSTALL_SPEC ||
61
+ "slack-max-api-mcp@latest";
62
+
21
63
  function parseSimpleEnvFile(filePath) {
22
64
  if (!fs.existsSync(filePath)) return {};
23
-
24
- const out = {};
25
- const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
26
- for (const line of lines) {
27
- const trimmed = line.trim();
28
- if (!trimmed || trimmed.startsWith("#")) continue;
29
- const eqIndex = trimmed.indexOf("=");
30
- if (eqIndex <= 0) continue;
31
- const key = trimmed.slice(0, eqIndex).trim();
32
- const value = trimmed.slice(eqIndex + 1).trim();
33
- if (!key) continue;
34
- out[key] = value;
35
- }
65
+
66
+ const out = {};
67
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
68
+ for (const line of lines) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed || trimmed.startsWith("#")) continue;
71
+ const eqIndex = trimmed.indexOf("=");
72
+ if (eqIndex <= 0) continue;
73
+ const key = trimmed.slice(0, eqIndex).trim();
74
+ const value = trimmed.slice(eqIndex + 1).trim();
75
+ if (!key) continue;
76
+ out[key] = value;
77
+ }
36
78
  return out;
37
79
  }
38
80
 
@@ -40,68 +82,335 @@ const ENV_EXAMPLE_VALUES = parseSimpleEnvFile(ENV_EXAMPLE_PATH);
40
82
  const FIXED_BOT_TOKEN = ENV_EXAMPLE_VALUES.SLACK_BOT_TOKEN || "";
41
83
  const FIXED_USER_TOKEN = ENV_EXAMPLE_VALUES.SLACK_USER_TOKEN || "";
42
84
  const FIXED_GENERIC_TOKEN = ENV_EXAMPLE_VALUES.SLACK_TOKEN || "";
43
- const DEFAULT_SLACK_TOKEN =
44
- process.env.SLACK_BOT_TOKEN ||
45
- process.env.SLACK_USER_TOKEN ||
46
- process.env.SLACK_TOKEN ||
47
- FIXED_BOT_TOKEN ||
48
- FIXED_USER_TOKEN ||
49
- FIXED_GENERIC_TOKEN;
50
-
51
- function requireSlackToken(tokenOverride) {
52
- const token = tokenOverride || DEFAULT_SLACK_TOKEN;
53
- if (!token) {
54
- throw new Error(
55
- "Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or fill .env.example."
56
- );
57
- }
58
- return token;
85
+
86
+ function parseScopeList(raw) {
87
+ if (!raw) return [];
88
+ return [...new Set(String(raw).split(",").map((part) => part.trim()).filter(Boolean))];
59
89
  }
60
90
 
61
- function toUrlEncodedBody(params = {}) {
62
- const search = new URLSearchParams();
63
- for (const [key, value] of Object.entries(params)) {
64
- if (value === undefined || value === null) continue;
91
+ function ensureParentDirectory(filePath) {
92
+ const dirPath = path.dirname(filePath);
93
+ fs.mkdirSync(dirPath, { recursive: true });
94
+ }
65
95
 
66
- if (Array.isArray(value) || (typeof value === "object" && !(value instanceof Date))) {
67
- search.append(key, JSON.stringify(value));
68
- continue;
69
- }
96
+ function emptyTokenStore() {
97
+ return { version: 1, default_profile: null, profiles: {} };
98
+ }
70
99
 
71
- search.append(key, String(value));
100
+ function normalizeTokenStore(value) {
101
+ if (!value || typeof value !== "object") return emptyTokenStore();
102
+ const out = { ...emptyTokenStore(), ...value };
103
+ if (!out.profiles || typeof out.profiles !== "object" || Array.isArray(out.profiles)) {
104
+ out.profiles = {};
72
105
  }
73
- return search.toString();
106
+ return out;
107
+ }
108
+
109
+ function loadTokenStore() {
110
+ if (!fs.existsSync(TOKEN_STORE_PATH)) return emptyTokenStore();
111
+
112
+ try {
113
+ const parsed = JSON.parse(fs.readFileSync(TOKEN_STORE_PATH, "utf8"));
114
+ return normalizeTokenStore(parsed);
115
+ } catch {
116
+ return emptyTokenStore();
117
+ }
118
+ }
119
+
120
+ function saveTokenStore(store) {
121
+ ensureParentDirectory(TOKEN_STORE_PATH);
122
+ fs.writeFileSync(TOKEN_STORE_PATH, JSON.stringify(normalizeTokenStore(store), null, 2), "utf8");
123
+ }
124
+
125
+ function emptyClientConfig() {
126
+ return {
127
+ version: 1,
128
+ gateway_url: "",
129
+ gateway_api_key: "",
130
+ profile: "",
131
+ updated_at: "",
132
+ };
133
+ }
134
+
135
+ function normalizeClientConfig(value) {
136
+ if (!value || typeof value !== "object") return emptyClientConfig();
137
+ return { ...emptyClientConfig(), ...value };
74
138
  }
75
139
 
76
- function parseJsonMaybe(value) {
77
- if (typeof value !== "string") return value;
140
+ function loadClientConfig() {
141
+ if (!fs.existsSync(CLIENT_CONFIG_PATH)) return emptyClientConfig();
78
142
  try {
79
- return JSON.parse(value);
143
+ const parsed = JSON.parse(fs.readFileSync(CLIENT_CONFIG_PATH, "utf8"));
144
+ return normalizeClientConfig(parsed);
80
145
  } catch {
81
- return value;
146
+ return emptyClientConfig();
147
+ }
148
+ }
149
+
150
+ function saveClientConfig(config) {
151
+ ensureParentDirectory(CLIENT_CONFIG_PATH);
152
+ fs.writeFileSync(CLIENT_CONFIG_PATH, JSON.stringify(normalizeClientConfig(config), null, 2), "utf8");
153
+ }
154
+
155
+ function getRuntimeGatewayConfig() {
156
+ const config = loadClientConfig();
157
+ return {
158
+ url: (process.env.SLACK_GATEWAY_URL || config.gateway_url || "").replace(/\/+$/, ""),
159
+ apiKey: process.env.SLACK_GATEWAY_API_KEY || config.gateway_api_key || "",
160
+ profile:
161
+ process.env.SLACK_PROFILE ||
162
+ process.env.SLACK_GATEWAY_PROFILE ||
163
+ config.profile ||
164
+ GATEWAY_PROFILE ||
165
+ "",
166
+ };
167
+ }
168
+
169
+ function resolveTokenStoreProfileBySelector(store, selector) {
170
+ const profiles = store?.profiles || {};
171
+ const keys = Object.keys(profiles);
172
+ if (keys.length === 0) return null;
173
+
174
+ const selected = selector || store.default_profile || keys[0];
175
+ if (selected && profiles[selected]) {
176
+ return { key: selected, profile: profiles[selected] };
177
+ }
178
+
179
+ const byName = keys.find((key) => profiles[key]?.profile_name === selected);
180
+ if (byName) return { key: byName, profile: profiles[byName] };
181
+
182
+ const byTeamId = keys.filter((key) => profiles[key]?.team_id === selected);
183
+ if (byTeamId.length === 1) {
184
+ const key = byTeamId[0];
185
+ return { key, profile: profiles[key] };
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ function getPreferredTokenKinds(preferredTokenType) {
192
+ const preferred = (preferredTokenType || process.env.SLACK_DEFAULT_TOKEN_TYPE || "bot").toLowerCase();
193
+ if (preferred === "user") return ["user", "bot", "generic"];
194
+ if (preferred === "generic") return ["generic", "bot", "user"];
195
+ if (preferred === "auto") return ["user", "bot", "generic"];
196
+ return ["bot", "user", "generic"];
197
+ }
198
+
199
+ function appendCandidateTokens(candidates, source, tokenMap, preferredKinds, seen) {
200
+ for (const kind of preferredKinds) {
201
+ const token = tokenMap[kind];
202
+ if (!token || seen.has(token)) continue;
203
+ seen.add(token);
204
+ candidates.push({ token, source, kind });
82
205
  }
83
206
  }
84
207
 
208
+ function getSlackTokenCandidates(tokenOverride, options = {}) {
209
+ if (tokenOverride) {
210
+ return [{ token: tokenOverride, source: "token_override", kind: "override" }];
211
+ }
212
+
213
+ const preferredKinds = getPreferredTokenKinds(options.preferredTokenType);
214
+ const candidates = [];
215
+ const seen = new Set();
216
+
217
+ if (options.includeEnvTokens !== false) {
218
+ appendCandidateTokens(
219
+ candidates,
220
+ "env",
221
+ {
222
+ bot: process.env.SLACK_BOT_TOKEN || "",
223
+ user: process.env.SLACK_USER_TOKEN || "",
224
+ generic: process.env.SLACK_TOKEN || "",
225
+ },
226
+ preferredKinds,
227
+ seen
228
+ );
229
+ }
230
+
231
+ if (options.includeTokenStore !== false) {
232
+ const tokenStore = loadTokenStore();
233
+ const activeProfile = resolveTokenStoreProfileBySelector(
234
+ tokenStore,
235
+ options.profileSelector || process.env.SLACK_PROFILE || GATEWAY_PROFILE
236
+ );
237
+ if (activeProfile) {
238
+ appendCandidateTokens(
239
+ candidates,
240
+ `token_store:${activeProfile.key}`,
241
+ {
242
+ bot: activeProfile.profile?.bot_token || "",
243
+ user: activeProfile.profile?.user_token || "",
244
+ generic: "",
245
+ },
246
+ preferredKinds,
247
+ seen
248
+ );
249
+ }
250
+ }
251
+
252
+ if (ALLOW_ENV_EXAMPLE_FALLBACK) {
253
+ appendCandidateTokens(
254
+ candidates,
255
+ "env_example",
256
+ {
257
+ bot: FIXED_BOT_TOKEN,
258
+ user: FIXED_USER_TOKEN,
259
+ generic: FIXED_GENERIC_TOKEN,
260
+ },
261
+ preferredKinds,
262
+ seen
263
+ );
264
+ }
265
+
266
+ return candidates;
267
+ }
268
+
269
+ function requireSlackTokenCandidate(tokenOverride, options = {}) {
270
+ const candidates = getSlackTokenCandidates(tokenOverride, options);
271
+ if (candidates.length === 0) {
272
+ throw new Error(
273
+ "Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or run `slack-max-api-mcp oauth login`."
274
+ );
275
+ }
276
+ return candidates[0];
277
+ }
278
+
279
+ function toUrlEncodedBody(params = {}) {
280
+ const search = new URLSearchParams();
281
+ for (const [key, value] of Object.entries(params)) {
282
+ if (value === undefined || value === null) continue;
283
+
284
+ if (Array.isArray(value) || (typeof value === "object" && !(value instanceof Date))) {
285
+ search.append(key, JSON.stringify(value));
286
+ continue;
287
+ }
288
+
289
+ search.append(key, String(value));
290
+ }
291
+ return search.toString();
292
+ }
293
+
294
+ function parseJsonMaybe(value) {
295
+ if (typeof value !== "string") return value;
296
+ try {
297
+ return JSON.parse(value);
298
+ } catch {
299
+ return value;
300
+ }
301
+ }
302
+
85
303
  function toRecordObject(value) {
86
304
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
87
305
  return value;
88
306
  }
89
307
 
90
- async function callSlackApi(method, params = {}, tokenOverride) {
91
- const token = requireSlackToken(tokenOverride);
92
- const url = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/${method}`;
308
+ function buildGatewayAuthHeaders(apiKey) {
309
+ const headers = { "Content-Type": "application/json; charset=utf-8" };
310
+ if (apiKey) {
311
+ headers.Authorization = `Bearer ${apiKey}`;
312
+ headers["X-API-Key"] = apiKey;
313
+ }
314
+ return headers;
315
+ }
93
316
 
94
- const response = await fetch(url, {
317
+ async function callSlackApiViaGateway(method, params = {}, tokenOverride, options = {}) {
318
+ const runtimeGateway = getRuntimeGatewayConfig();
319
+ if (!runtimeGateway.url) {
320
+ throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
321
+ }
322
+
323
+ const response = await fetch(`${runtimeGateway.url}/api/slack/call`, {
95
324
  method: "POST",
96
- headers: {
97
- Authorization: `Bearer ${token}`,
98
- "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
99
- },
100
- body: toUrlEncodedBody(params),
325
+ headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
326
+ body: JSON.stringify({
327
+ method,
328
+ params,
329
+ token_override: tokenOverride || undefined,
330
+ profile_selector: options.profileSelector || runtimeGateway.profile || undefined,
331
+ preferred_token_type: options.preferredTokenType || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
332
+ }),
101
333
  });
102
334
 
103
335
  const text = await response.text();
104
- let data;
336
+ let body;
337
+ try {
338
+ body = JSON.parse(text);
339
+ } catch {
340
+ throw new Error(`Gateway returned non-JSON for ${method} (HTTP ${response.status}).`);
341
+ }
342
+
343
+ if (!response.ok) {
344
+ const error = new Error(
345
+ `Gateway HTTP ${response.status} for ${method}: ${body?.error || body?.message || "unknown_error"}`
346
+ );
347
+ error.http_status = response.status;
348
+ error.slack_error = body?.slack_error || body?.error || "gateway_error";
349
+ error.needed = body?.needed;
350
+ error.provided = body?.provided;
351
+ error.token_source = body?.token_source || "gateway";
352
+ throw error;
353
+ }
354
+
355
+ if (!body?.ok) {
356
+ const error = new Error(`Gateway call failed for ${method}: ${body?.error || "unknown_error"}`);
357
+ error.slack_error = body?.slack_error || body?.error || "gateway_error";
358
+ error.needed = body?.needed;
359
+ error.provided = body?.provided;
360
+ error.token_source = body?.token_source || "gateway";
361
+ throw error;
362
+ }
363
+
364
+ return body.data;
365
+ }
366
+
367
+ async function slackHttpViaGateway(input) {
368
+ const runtimeGateway = getRuntimeGatewayConfig();
369
+ if (!runtimeGateway.url) {
370
+ throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
371
+ }
372
+
373
+ const response = await fetch(`${runtimeGateway.url}/api/slack/http`, {
374
+ method: "POST",
375
+ headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
376
+ body: JSON.stringify({
377
+ ...input,
378
+ profile_selector: input.profile_selector || runtimeGateway.profile || undefined,
379
+ preferred_token_type: input.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
380
+ }),
381
+ });
382
+
383
+ const text = await response.text();
384
+ let body;
385
+ try {
386
+ body = JSON.parse(text);
387
+ } catch {
388
+ throw new Error(`Gateway returned non-JSON for HTTP proxy (HTTP ${response.status}).`);
389
+ }
390
+
391
+ if (!response.ok) {
392
+ throw new Error(`Gateway HTTP ${response.status}: ${body?.error || "gateway_error"}`);
393
+ }
394
+ if (!body?.ok) {
395
+ throw new Error(`Gateway HTTP proxy failed: ${body?.error || "gateway_error"}`);
396
+ }
397
+ return body.data;
398
+ }
399
+
400
+ async function callSlackApiWithToken(method, params = {}, token, tokenSource) {
401
+ const url = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/${method}`;
402
+
403
+ const response = await fetch(url, {
404
+ method: "POST",
405
+ headers: {
406
+ Authorization: `Bearer ${token}`,
407
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
408
+ },
409
+ body: toUrlEncodedBody(params),
410
+ });
411
+
412
+ const text = await response.text();
413
+ let data;
105
414
  try {
106
415
  data = JSON.parse(text);
107
416
  } catch {
@@ -109,31 +418,85 @@ async function callSlackApi(method, params = {}, tokenOverride) {
109
418
  }
110
419
 
111
420
  if (!response.ok) {
112
- throw new Error(`Slack API HTTP ${response.status} for ${method}: ${data.error || "unknown_error"}`);
421
+ const error = new Error(
422
+ `Slack API HTTP ${response.status} for ${method}: ${data.error || "unknown_error"}`
423
+ );
424
+ error.http_status = response.status;
425
+ error.slack_error = data.error || "unknown_error";
426
+ error.needed = data.needed;
427
+ error.provided = data.provided;
428
+ error.token_source = tokenSource;
429
+ throw error;
113
430
  }
114
431
 
115
432
  if (!data.ok) {
116
- throw new Error(`Slack method ${method} failed: ${data.error || "unknown_error"}`);
433
+ const extraParts = [];
434
+ if (data.needed) extraParts.push(`needed=${data.needed}`);
435
+ if (data.provided) extraParts.push(`provided=${data.provided}`);
436
+ if (tokenSource) extraParts.push(`token_source=${tokenSource}`);
437
+
438
+ const extra = extraParts.length ? ` (${extraParts.join(", ")})` : "";
439
+ const error = new Error(`Slack method ${method} failed: ${data.error || "unknown_error"}${extra}`);
440
+ error.slack_error = data.error || "unknown_error";
441
+ error.needed = data.needed;
442
+ error.provided = data.provided;
443
+ error.token_source = tokenSource;
444
+ throw error;
117
445
  }
118
446
 
119
447
  return data;
120
448
  }
121
449
 
122
- function successResult(payload) {
123
- return {
124
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
125
- structuredContent: payload,
126
- };
127
- }
450
+ async function callSlackApiWithCandidates(method, params, candidates) {
451
+ let lastError = null;
452
+
453
+ for (let i = 0; i < candidates.length; i += 1) {
454
+ const candidate = candidates[i];
455
+ try {
456
+ return await callSlackApiWithToken(method, params, candidate.token, candidate.source);
457
+ } catch (error) {
458
+ lastError = error;
459
+ const canRetry = i < candidates.length - 1 && RETRYABLE_TOKEN_ERRORS.has(error.slack_error);
460
+ if (!canRetry) {
461
+ throw error;
462
+ }
463
+ }
464
+ }
128
465
 
129
- function errorResult(error) {
130
- const message = error instanceof Error ? error.message : String(error);
131
- return {
132
- isError: true,
133
- content: [{ type: "text", text: message }],
134
- };
466
+ throw lastError || new Error(`Slack method ${method} failed.`);
135
467
  }
136
468
 
469
+ async function callSlackApi(method, params = {}, tokenOverride, options = {}) {
470
+ const runtimeGateway = getRuntimeGatewayConfig();
471
+ if (runtimeGateway.url) {
472
+ return callSlackApiViaGateway(method, params, tokenOverride, options);
473
+ }
474
+
475
+ const candidates = getSlackTokenCandidates(tokenOverride, options);
476
+ if (candidates.length === 0) {
477
+ throw new Error(
478
+ "Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or run `slack-max-api-mcp oauth login`."
479
+ );
480
+ }
481
+
482
+ return callSlackApiWithCandidates(method, params, candidates);
483
+ }
484
+
485
+ function successResult(payload) {
486
+ return {
487
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
488
+ structuredContent: payload,
489
+ };
490
+ }
491
+
492
+ function errorResult(error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ return {
495
+ isError: true,
496
+ content: [{ type: "text", text: message }],
497
+ };
498
+ }
499
+
137
500
  async function safeToolRun(executor) {
138
501
  try {
139
502
  const result = await executor();
@@ -143,576 +506,1662 @@ async function safeToolRun(executor) {
143
506
  }
144
507
  }
145
508
 
146
- function loadCatalog() {
147
- if (!fs.existsSync(CATALOG_PATH)) {
148
- return { methods: [], scopes: [], totals: {} };
509
+ function parseCliArgs(argv) {
510
+ const options = {};
511
+ const positionals = [];
512
+
513
+ for (let i = 0; i < argv.length; i += 1) {
514
+ const arg = argv[i];
515
+ if (!arg.startsWith("--")) {
516
+ positionals.push(arg);
517
+ continue;
518
+ }
519
+
520
+ const withoutPrefix = arg.slice(2);
521
+ const [rawKey, rawInlineValue] = withoutPrefix.split("=", 2);
522
+ const key = rawKey.trim();
523
+ if (!key) continue;
524
+
525
+ if (rawInlineValue !== undefined) {
526
+ options[key] = rawInlineValue;
527
+ continue;
528
+ }
529
+
530
+ const maybeValue = argv[i + 1];
531
+ if (maybeValue && !maybeValue.startsWith("--")) {
532
+ options[key] = maybeValue;
533
+ i += 1;
534
+ continue;
535
+ }
536
+
537
+ options[key] = true;
149
538
  }
150
539
 
540
+ return { options, positionals };
541
+ }
542
+
543
+ function base64UrlEncodeString(value) {
544
+ return Buffer.from(value, "utf8")
545
+ .toString("base64")
546
+ .replace(/\+/g, "-")
547
+ .replace(/\//g, "_")
548
+ .replace(/=+$/g, "");
549
+ }
550
+
551
+ function base64UrlDecodeToString(value) {
552
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
553
+ return Buffer.from(padded, "base64").toString("utf8");
554
+ }
555
+
556
+ function hmacSign(text, secret) {
557
+ return crypto
558
+ .createHmac("sha256", secret)
559
+ .update(text)
560
+ .digest("base64")
561
+ .replace(/\+/g, "-")
562
+ .replace(/\//g, "_")
563
+ .replace(/=+$/g, "");
564
+ }
565
+
566
+ function createSignedInviteToken(payload, secret) {
567
+ const encodedPayload = base64UrlEncodeString(JSON.stringify(payload));
568
+ const signature = hmacSign(encodedPayload, secret);
569
+ return `${encodedPayload}.${signature}`;
570
+ }
571
+
572
+ function parseAndVerifyInviteToken(token, secret) {
573
+ const [encodedPayload, signature] = String(token || "").split(".", 2);
574
+ if (!encodedPayload || !signature) {
575
+ throw new Error("Invalid invite token format.");
576
+ }
577
+ const expected = hmacSign(encodedPayload, secret);
578
+ const expectedBuf = Buffer.from(expected, "utf8");
579
+ const sigBuf = Buffer.from(signature, "utf8");
580
+ if (expectedBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expectedBuf, sigBuf)) {
581
+ throw new Error("Invalid invite token signature.");
582
+ }
583
+ let payload;
151
584
  try {
152
- const raw = fs.readFileSync(CATALOG_PATH, "utf8");
153
- const parsed = JSON.parse(raw);
154
- return parsed;
155
- } catch (error) {
156
- throw new Error(`Failed to load catalog at ${CATALOG_PATH}: ${error}`);
585
+ payload = JSON.parse(base64UrlDecodeToString(encodedPayload));
586
+ } catch {
587
+ throw new Error("Invalid invite token payload.");
588
+ }
589
+ if (typeof payload !== "object" || !payload) {
590
+ throw new Error("Invalid invite token payload object.");
591
+ }
592
+ if (!payload.exp || Number(payload.exp) < Date.now()) {
593
+ throw new Error("Invite token expired.");
157
594
  }
595
+ return payload;
158
596
  }
159
597
 
160
- function toolNameFromMethod(method, usedNames) {
161
- const base = `${METHOD_TOOL_PREFIX}_${method.replace(/[^a-zA-Z0-9]/g, "_")}`;
162
- if (!usedNames.has(base)) {
163
- usedNames.add(base);
164
- return base;
598
+ function requireGatewayInviteSecret() {
599
+ if (!GATEWAY_SHARED_SECRET) {
600
+ throw new Error("Set SLACK_GATEWAY_SHARED_SECRET before using gateway invite/onboarding.");
165
601
  }
602
+ return GATEWAY_SHARED_SECRET;
603
+ }
166
604
 
167
- let idx = 2;
168
- while (usedNames.has(`${base}_${idx}`)) idx += 1;
169
- const name = `${base}_${idx}`;
170
- usedNames.add(name);
171
- return name;
605
+ function isInteractiveTerminal() {
606
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
172
607
  }
173
608
 
174
- function registerCoreTools(server) {
175
- server.registerTool(
176
- "slack_api_call",
177
- {
178
- description: "Call any Slack Web API method directly.",
179
- inputSchema: {
180
- method: z
181
- .string()
182
- .min(3)
183
- .regex(/^[a-z][a-zA-Z0-9_.]+$/, "Method must look like chat.postMessage"),
184
- params: z.record(z.string(), z.any()).optional(),
185
- token_override: z.string().optional(),
186
- },
187
- },
188
- async ({ method, params, token_override }) =>
189
- safeToolRun(async () => {
190
- const data = await callSlackApi(method, params || {}, token_override);
191
- return { method, data };
192
- })
193
- );
609
+ function hasAnyLocalAuthMaterial() {
610
+ const runtimeGateway = getRuntimeGatewayConfig();
611
+ if (runtimeGateway.url) return true;
612
+ const tokenCandidates = getSlackTokenCandidates(undefined, {
613
+ includeEnvTokens: true,
614
+ includeTokenStore: true,
615
+ });
616
+ return tokenCandidates.length > 0;
617
+ }
194
618
 
195
- server.registerTool(
196
- "slack_http_api_call",
197
- {
198
- description:
199
- "Generic HTTP call for Slack APIs outside standard Web API methods (SCIM/Audit/Legal Holds).",
200
- inputSchema: {
201
- url: z.string().url(),
202
- http_method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
203
- query: z.record(z.string(), z.any()).optional(),
204
- json_body: z.record(z.string(), z.any()).optional(),
205
- form_body: z.record(z.string(), z.any()).optional(),
206
- headers: z.record(z.string(), z.string()).optional(),
207
- token_override: z.string().optional(),
208
- },
209
- },
210
- async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
211
- safeToolRun(async () => {
212
- const token = requireSlackToken(token_override);
213
- const method = http_method || "GET";
619
+ async function runAutoOnboardingIfPossible() {
620
+ if (!AUTO_ONBOARD_ENABLED) return false;
621
+ if (!isInteractiveTerminal()) return false;
622
+ if (hasAnyLocalAuthMaterial()) return false;
623
+
624
+ if (AUTO_ONBOARD_URL) {
625
+ const opened = openExternalUrl(AUTO_ONBOARD_URL);
626
+ if (!opened) {
627
+ console.log(`[auto-onboard] Open this URL in browser:\n${AUTO_ONBOARD_URL}`);
628
+ } else {
629
+ console.log("[auto-onboard] Browser opened for onboarding.");
630
+ }
631
+ return true;
632
+ }
214
633
 
215
- const endpoint = new URL(url);
216
- for (const [k, v] of Object.entries(toRecordObject(query))) {
217
- if (v === undefined || v === null) continue;
218
- endpoint.searchParams.set(k, String(v));
219
- }
634
+ if (AUTO_ONBOARD_GATEWAY && AUTO_ONBOARD_TOKEN) {
635
+ await runOnboardStart([
636
+ "--gateway",
637
+ AUTO_ONBOARD_GATEWAY,
638
+ "--token",
639
+ AUTO_ONBOARD_TOKEN,
640
+ ]);
641
+ return true;
642
+ }
220
643
 
221
- const reqHeaders = {
222
- Authorization: `Bearer ${token}`,
223
- ...(headers || {}),
224
- };
644
+ return false;
645
+ }
225
646
 
226
- let body;
227
- if (form_body && Object.keys(form_body).length > 0) {
228
- reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
229
- body = toUrlEncodedBody(form_body);
230
- } else if (json_body && Object.keys(json_body).length > 0) {
231
- reqHeaders["Content-Type"] = "application/json; charset=utf-8";
232
- body = JSON.stringify(json_body);
233
- }
647
+ function printOauthHelp() {
648
+ const lines = [
649
+ "Slack Max OAuth helper",
650
+ "",
651
+ "Usage:",
652
+ " slack-max-api-mcp oauth login [--profile NAME] [--team T123] [--scope a,b] [--user-scope c,d]",
653
+ " slack-max-api-mcp oauth list",
654
+ " slack-max-api-mcp oauth use <profile_key_or_name>",
655
+ " slack-max-api-mcp oauth current",
656
+ "",
657
+ "Required env vars for login:",
658
+ " SLACK_CLIENT_ID",
659
+ " SLACK_CLIENT_SECRET",
660
+ "",
661
+ "Optional env vars:",
662
+ " SLACK_OAUTH_BOT_SCOPES, SLACK_OAUTH_USER_SCOPES",
663
+ " SLACK_OAUTH_CALLBACK_HOST, SLACK_OAUTH_CALLBACK_PORT, SLACK_OAUTH_CALLBACK_PATH",
664
+ " SLACK_PROFILE, SLACK_TOKEN_STORE_PATH",
665
+ ];
666
+ console.log(lines.join("\n"));
667
+ }
234
668
 
235
- const res = await fetch(endpoint.toString(), {
236
- method,
237
- headers: reqHeaders,
238
- body,
239
- });
669
+ function openExternalUrl(url) {
670
+ try {
671
+ if (process.platform === "win32") {
672
+ const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
673
+ child.unref();
674
+ return true;
675
+ }
676
+ if (process.platform === "darwin") {
677
+ const child = spawn("open", [url], { detached: true, stdio: "ignore" });
678
+ child.unref();
679
+ return true;
680
+ }
681
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
682
+ child.unref();
683
+ return true;
684
+ } catch {
685
+ return false;
686
+ }
687
+ }
240
688
 
241
- const text = await res.text();
242
- let parsedBody = text;
243
- try {
244
- parsedBody = JSON.parse(text);
245
- } catch {
246
- // Keep plain text when response is not JSON.
247
- }
689
+ function buildOauthAuthorizeUrl({
690
+ clientId,
691
+ state,
692
+ redirectUri,
693
+ botScopes,
694
+ userScopes,
695
+ teamId,
696
+ }) {
697
+ const endpoint = new URL("https://slack.com/oauth/v2/authorize");
698
+ endpoint.searchParams.set("client_id", clientId);
699
+ endpoint.searchParams.set("state", state);
700
+ endpoint.searchParams.set("redirect_uri", redirectUri);
701
+ if (botScopes.length > 0) endpoint.searchParams.set("scope", botScopes.join(","));
702
+ if (userScopes.length > 0) endpoint.searchParams.set("user_scope", userScopes.join(","));
703
+ if (teamId) endpoint.searchParams.set("team", teamId);
704
+ return endpoint.toString();
705
+ }
248
706
 
249
- return {
250
- url: endpoint.toString(),
251
- status: res.status,
252
- ok: res.ok,
253
- headers: Object.fromEntries(res.headers.entries()),
254
- body: parsedBody,
255
- };
256
- })
257
- );
707
+ async function waitForOauthCode({ host, port, callbackPath, state, timeoutMs }) {
708
+ return new Promise((resolve, reject) => {
709
+ let settled = false;
710
+ let timeout = null;
711
+ const server = http.createServer((req, res) => {
712
+ const requestUrl = new URL(req.url || "/", `http://${host}:${port}`);
713
+ if (requestUrl.pathname !== callbackPath) {
714
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
715
+ res.end("Not found");
716
+ return;
717
+ }
718
+
719
+ const receivedError = requestUrl.searchParams.get("error");
720
+ if (receivedError) {
721
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
722
+ res.end(`Slack OAuth failed: ${receivedError}`);
723
+ settle(new Error(`Slack OAuth failed: ${receivedError}`));
724
+ return;
725
+ }
726
+
727
+ const receivedState = requestUrl.searchParams.get("state");
728
+ if (!receivedState || receivedState !== state) {
729
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
730
+ res.end("Invalid OAuth state.");
731
+ settle(new Error("Slack OAuth state mismatch."));
732
+ return;
733
+ }
734
+
735
+ const code = requestUrl.searchParams.get("code");
736
+ if (!code) {
737
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
738
+ res.end("Missing authorization code.");
739
+ settle(new Error("OAuth callback did not include `code`."));
740
+ return;
741
+ }
742
+
743
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
744
+ res.end("Slack OAuth authorization completed. You can close this tab.");
745
+ settle(null, code);
746
+ });
747
+
748
+ function settle(error, code) {
749
+ if (settled) return;
750
+ settled = true;
751
+ if (timeout) clearTimeout(timeout);
752
+ server.close(() => {
753
+ if (error) reject(error);
754
+ else resolve(code);
755
+ });
756
+ }
258
757
 
259
- server.registerTool(
260
- "search_messages_files",
261
- {
262
- description:
263
- "Search messages and files. Uses search.messages and search.files and returns both.",
264
- inputSchema: {
265
- query: z.string().min(1),
266
- count: z.number().int().min(1).max(100).optional(),
267
- page: z.number().int().min(1).optional(),
268
- sort: z.enum(["score", "timestamp"]).optional(),
269
- sort_dir: z.enum(["asc", "desc"]).optional(),
270
- include_messages: z.boolean().optional(),
271
- include_files: z.boolean().optional(),
272
- token_override: z.string().optional(),
273
- },
274
- },
275
- async ({
276
- query,
277
- count,
278
- page,
279
- sort,
280
- sort_dir,
281
- include_messages,
282
- include_files,
283
- token_override,
284
- }) =>
285
- safeToolRun(async () => {
286
- const shouldSearchMessages = include_messages !== false;
287
- const shouldSearchFiles = include_files !== false;
288
- const sharedParams = {
289
- query,
290
- count: count ?? 20,
291
- page: page ?? 1,
292
- sort,
293
- sort_dir,
294
- };
758
+ server.on("error", (error) => {
759
+ settle(new Error(`Failed to listen on ${host}:${port}: ${error.message}`));
760
+ });
295
761
 
296
- let messages = null;
297
- let files = null;
762
+ server.listen(port, host, () => {
763
+ timeout = setTimeout(() => {
764
+ settle(new Error("Timed out waiting for OAuth callback."));
765
+ }, timeoutMs);
766
+ });
767
+ });
768
+ }
298
769
 
299
- if (shouldSearchMessages) {
300
- messages = await callSlackApi("search.messages", sharedParams, token_override);
301
- }
302
- if (shouldSearchFiles) {
303
- files = await callSlackApi("search.files", sharedParams, token_override);
304
- }
770
+ async function exchangeOauthCode({ clientId, clientSecret, code, redirectUri }) {
771
+ const endpoint = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/oauth.v2.access`;
772
+ const response = await fetch(endpoint, {
773
+ method: "POST",
774
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" },
775
+ body: toUrlEncodedBody({
776
+ client_id: clientId,
777
+ client_secret: clientSecret,
778
+ code,
779
+ redirect_uri: redirectUri,
780
+ }),
781
+ });
305
782
 
306
- return {
307
- query,
308
- messages: messages ? messages.messages : null,
309
- files: files ? files.files : null,
310
- };
311
- })
312
- );
783
+ const text = await response.text();
784
+ let data;
785
+ try {
786
+ data = JSON.parse(text);
787
+ } catch {
788
+ throw new Error(`Slack OAuth token exchange returned non-JSON (HTTP ${response.status}).`);
789
+ }
313
790
 
314
- server.registerTool(
315
- "search_users",
316
- {
317
- description:
318
- "Find users by partial match on id/name/display_name/email using users.list and local filtering.",
319
- inputSchema: {
320
- query: z.string().optional(),
321
- limit: z.number().int().min(1).max(1000).optional(),
322
- include_locale: z.boolean().optional(),
323
- include_deleted: z.boolean().optional(),
324
- include_bots: z.boolean().optional(),
325
- cursor: z.string().optional(),
326
- token_override: z.string().optional(),
327
- },
328
- },
329
- async ({
330
- query,
331
- limit,
332
- include_locale,
333
- include_deleted,
334
- include_bots,
335
- cursor,
336
- token_override,
337
- }) =>
338
- safeToolRun(async () => {
339
- const listData = await callSlackApi(
340
- "users.list",
341
- {
342
- limit: limit ?? 200,
343
- include_locale: include_locale ?? true,
344
- cursor,
345
- },
346
- token_override
347
- );
791
+ if (!response.ok) {
792
+ throw new Error(`Slack OAuth token exchange failed (HTTP ${response.status}): ${data.error || "unknown_error"}`);
793
+ }
348
794
 
349
- const normalizedQuery = query ? query.toLowerCase() : null;
350
- let users = Array.isArray(listData.members) ? listData.members : [];
795
+ if (!data.ok) {
796
+ throw new Error(`Slack OAuth token exchange failed: ${data.error || "unknown_error"}`);
797
+ }
351
798
 
352
- if (include_deleted !== true) {
353
- users = users.filter((u) => !u.deleted);
354
- }
355
- if (include_bots !== true) {
356
- users = users.filter((u) => !u.is_bot && !u.is_app_user);
357
- }
358
- if (normalizedQuery) {
359
- users = users.filter((u) => {
360
- const candidates = [
361
- u.id,
362
- u.name,
363
- u.real_name,
364
- u.profile?.display_name,
365
- u.profile?.real_name,
366
- u.profile?.email,
367
- ]
368
- .filter((v) => typeof v === "string")
369
- .map((v) => v.toLowerCase());
370
- return candidates.some((value) => value.includes(normalizedQuery));
371
- });
372
- }
799
+ return data;
800
+ }
373
801
 
374
- return {
375
- users,
376
- next_cursor: listData.response_metadata?.next_cursor || null,
377
- count: users.length,
378
- };
379
- })
802
+ function normalizeProfileName(rawName, fallback) {
803
+ const name = (rawName || "").trim();
804
+ if (name) return name;
805
+ return fallback;
806
+ }
807
+
808
+ function upsertOauthProfile(oauthResponse, preferredProfileName) {
809
+ const teamId = oauthResponse.team?.id || "unknown_team";
810
+ const teamName = oauthResponse.team?.name || teamId;
811
+ const authedUserId = oauthResponse.authed_user?.id || oauthResponse.bot_user_id || "unknown_user";
812
+ const profileKey = `${teamId}:${authedUserId}`;
813
+
814
+ const tokenStore = loadTokenStore();
815
+ const existing = tokenStore.profiles[profileKey] || {};
816
+ const now = new Date().toISOString();
817
+
818
+ tokenStore.profiles[profileKey] = {
819
+ ...existing,
820
+ profile_name: normalizeProfileName(preferredProfileName, `${teamName}-${authedUserId}`),
821
+ team_id: teamId,
822
+ team_name: teamName,
823
+ app_id: oauthResponse.app_id || existing.app_id || "",
824
+ token_type: oauthResponse.token_type || existing.token_type || "",
825
+ bot_user_id: oauthResponse.bot_user_id || existing.bot_user_id || "",
826
+ bot_scope: oauthResponse.scope || existing.bot_scope || "",
827
+ user_scope: oauthResponse.authed_user?.scope || existing.user_scope || "",
828
+ bot_token: oauthResponse.access_token || existing.bot_token || "",
829
+ user_token: oauthResponse.authed_user?.access_token || existing.user_token || "",
830
+ authed_user_id: oauthResponse.authed_user?.id || existing.authed_user_id || "",
831
+ incoming_webhook_url: oauthResponse.incoming_webhook?.url || existing.incoming_webhook_url || "",
832
+ created_at: existing.created_at || now,
833
+ updated_at: now,
834
+ };
835
+ tokenStore.default_profile = profileKey;
836
+
837
+ saveTokenStore(tokenStore);
838
+ return { key: profileKey, profile: tokenStore.profiles[profileKey] };
839
+ }
840
+
841
+ function formatTokenProfileSummary(key, profile, isDefault) {
842
+ const flags = [];
843
+ if (isDefault) flags.push("default");
844
+ if (profile.bot_token) flags.push("bot");
845
+ if (profile.user_token) flags.push("user");
846
+
847
+ return [
848
+ `${isDefault ? "*" : " "} ${key} (${profile.profile_name || "unnamed"})`,
849
+ ` team=${profile.team_name || profile.team_id || "unknown"} | user=${profile.authed_user_id || "unknown"} | tokens=${flags.join(", ") || "none"}`,
850
+ ].join("\n");
851
+ }
852
+
853
+ async function runOauthLogin(args) {
854
+ const { options } = parseCliArgs(args);
855
+ const clientId = options["client-id"] || process.env.SLACK_CLIENT_ID;
856
+ const clientSecret = options["client-secret"] || process.env.SLACK_CLIENT_SECRET;
857
+ if (!clientId || !clientSecret) {
858
+ throw new Error("Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET. Set env vars or pass --client-id/--client-secret.");
859
+ }
860
+
861
+ const host = options.host || OAUTH_CALLBACK_HOST;
862
+ const port = Number(options.port || OAUTH_CALLBACK_PORT);
863
+ const callbackPath = options["callback-path"] || OAUTH_CALLBACK_PATH;
864
+ const redirectUri = options["redirect-uri"] || `http://${host}:${port}${callbackPath}`;
865
+ const timeoutMs = Number(options["timeout-ms"] || OAUTH_TIMEOUT_MS);
866
+ const botScopes = parseScopeList(options.scope || DEFAULT_OAUTH_BOT_SCOPES);
867
+ const userScopes = parseScopeList(options["user-scope"] || DEFAULT_OAUTH_USER_SCOPES);
868
+ const teamId = options.team || process.env.SLACK_OAUTH_TEAM_ID || "";
869
+
870
+ if (botScopes.length === 0 && userScopes.length === 0) {
871
+ throw new Error("At least one scope is required. Set --scope or --user-scope.");
872
+ }
873
+
874
+ const state = crypto.randomBytes(24).toString("hex");
875
+ const authorizeUrl = buildOauthAuthorizeUrl({
876
+ clientId,
877
+ state,
878
+ redirectUri,
879
+ botScopes,
880
+ userScopes,
881
+ teamId,
882
+ });
883
+
884
+ console.log(`[oauth] callback listening on http://${host}:${port}${callbackPath}`);
885
+ console.log(`[oauth] authorize URL:\n${authorizeUrl}`);
886
+
887
+ if (!options["no-open"]) {
888
+ const opened = openExternalUrl(authorizeUrl);
889
+ if (!opened) {
890
+ console.log("[oauth] Could not auto-open browser. Open the URL above manually.");
891
+ }
892
+ }
893
+
894
+ const code = await waitForOauthCode({ host, port, callbackPath, state, timeoutMs });
895
+ const oauthResponse = await exchangeOauthCode({ clientId, clientSecret, code, redirectUri });
896
+ const { key, profile } = upsertOauthProfile(oauthResponse, options.profile);
897
+
898
+ console.log(`[oauth] saved profile: ${profile.profile_name} (${key})`);
899
+ console.log(`[oauth] token store path: ${TOKEN_STORE_PATH}`);
900
+ console.log("[oauth] Next step for MCP clients:");
901
+ console.log(` setx SLACK_PROFILE \"${profile.profile_name}\"`);
902
+ }
903
+
904
+ function runOauthList() {
905
+ const tokenStore = loadTokenStore();
906
+ const keys = Object.keys(tokenStore.profiles);
907
+ if (keys.length === 0) {
908
+ console.log(`[oauth] no saved profiles in ${TOKEN_STORE_PATH}`);
909
+ return;
910
+ }
911
+
912
+ console.log(`[oauth] profiles in ${TOKEN_STORE_PATH}`);
913
+ for (const key of keys) {
914
+ console.log(formatTokenProfileSummary(key, tokenStore.profiles[key], tokenStore.default_profile === key));
915
+ }
916
+ }
917
+
918
+ function runOauthUse(args) {
919
+ const { positionals } = parseCliArgs(args);
920
+ const selector = positionals[0];
921
+ if (!selector) {
922
+ throw new Error("Usage: slack-max-api-mcp oauth use <profile_key_or_name>");
923
+ }
924
+
925
+ const tokenStore = loadTokenStore();
926
+ const resolved = resolveTokenStoreProfileBySelector(tokenStore, selector);
927
+ if (!resolved) {
928
+ throw new Error(`Profile not found: ${selector}`);
929
+ }
930
+
931
+ tokenStore.default_profile = resolved.key;
932
+ saveTokenStore(tokenStore);
933
+ console.log(`[oauth] default profile set to ${resolved.key} (${resolved.profile.profile_name || "unnamed"})`);
934
+ }
935
+
936
+ function runOauthCurrent() {
937
+ const tokenStore = loadTokenStore();
938
+ const resolved = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
939
+ if (!resolved) {
940
+ console.log("[oauth] no active profile");
941
+ return;
942
+ }
943
+ console.log(formatTokenProfileSummary(resolved.key, resolved.profile, tokenStore.default_profile === resolved.key));
944
+ }
945
+
946
+ async function runOauthCli(args) {
947
+ const subcommand = (args[0] || "help").toLowerCase();
948
+ const rest = args.slice(1);
949
+
950
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
951
+ printOauthHelp();
952
+ return;
953
+ }
954
+ if (subcommand === "login") {
955
+ await runOauthLogin(rest);
956
+ return;
957
+ }
958
+ if (subcommand === "list") {
959
+ runOauthList();
960
+ return;
961
+ }
962
+ if (subcommand === "use") {
963
+ runOauthUse(rest);
964
+ return;
965
+ }
966
+ if (subcommand === "current") {
967
+ runOauthCurrent();
968
+ return;
969
+ }
970
+
971
+ throw new Error(`Unknown oauth command: ${subcommand}`);
972
+ }
973
+
974
+ function printOnboardHelp() {
975
+ const lines = [
976
+ "Slack Max onboarding helper",
977
+ "",
978
+ "Usage:",
979
+ " slack-max-api-mcp onboard run --gateway https://gateway.example.com --token <invite_token>",
980
+ " slack-max-api-mcp onboard help",
981
+ "",
982
+ "This command writes local client config and opens the Slack OAuth approval page automatically.",
983
+ ];
984
+ console.log(lines.join("\n"));
985
+ }
986
+
987
+ async function runOnboardStart(args) {
988
+ const { options } = parseCliArgs(args);
989
+ const gateway = String(options.gateway || options.url || "").replace(/\/+$/, "");
990
+ const token = String(options.token || "");
991
+ if (!gateway || !token) {
992
+ throw new Error("Usage: slack-max-api-mcp onboard run --gateway <url> --token <invite_token>");
993
+ }
994
+
995
+ const response = await fetch(`${gateway}/onboard/resolve?token=${encodeURIComponent(token)}`, {
996
+ method: "GET",
997
+ headers: { Accept: "application/json" },
998
+ });
999
+
1000
+ const text = await response.text();
1001
+ let data;
1002
+ try {
1003
+ data = JSON.parse(text);
1004
+ } catch {
1005
+ throw new Error(`Onboarding response was non-JSON (HTTP ${response.status}).`);
1006
+ }
1007
+
1008
+ if (!response.ok || !data?.ok) {
1009
+ throw new Error(`Onboarding failed: ${data?.error || `http_${response.status}`}`);
1010
+ }
1011
+
1012
+ const resolvedGatewayUrl = String(data.gateway_url || gateway).replace(/\/+$/, "");
1013
+ const resolvedApiKey = String(data.gateway_api_key || "");
1014
+ const profile = String(data.profile || "");
1015
+ const oauthStartUrl = String(data.oauth_start_url || "");
1016
+
1017
+ saveClientConfig({
1018
+ version: 1,
1019
+ gateway_url: resolvedGatewayUrl,
1020
+ gateway_api_key: resolvedApiKey,
1021
+ profile,
1022
+ updated_at: new Date().toISOString(),
1023
+ });
1024
+
1025
+ if (oauthStartUrl) {
1026
+ const opened = openExternalUrl(oauthStartUrl);
1027
+ if (!opened) {
1028
+ console.log(`[onboard] Open this URL in browser:\n${oauthStartUrl}`);
1029
+ }
1030
+ }
1031
+
1032
+ console.log(`[onboard] client config saved: ${CLIENT_CONFIG_PATH}`);
1033
+ console.log(`[onboard] gateway: ${resolvedGatewayUrl}`);
1034
+ if (profile) console.log(`[onboard] profile: ${profile}`);
1035
+ console.log("[onboard] Next: approve in browser, then use Codex MCP as usual.");
1036
+ }
1037
+
1038
+ async function runOnboardCli(args) {
1039
+ const subcommand = (args[0] || "help").toLowerCase();
1040
+ const rest = args.slice(1);
1041
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1042
+ printOnboardHelp();
1043
+ return;
1044
+ }
1045
+ if (subcommand === "run" || subcommand === "start") {
1046
+ await runOnboardStart(rest);
1047
+ return;
1048
+ }
1049
+ throw new Error(`Unknown onboard command: ${subcommand}`);
1050
+ }
1051
+
1052
+ function sendText(res, statusCode, text) {
1053
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
1054
+ res.end(text);
1055
+ }
1056
+
1057
+ function sendJson(res, statusCode, payload) {
1058
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
1059
+ res.end(JSON.stringify(payload, null, 2));
1060
+ }
1061
+
1062
+ async function readRequestText(req, maxBytes = 1024 * 1024) {
1063
+ return new Promise((resolve, reject) => {
1064
+ const chunks = [];
1065
+ let total = 0;
1066
+ req.on("data", (chunk) => {
1067
+ total += chunk.length;
1068
+ if (total > maxBytes) {
1069
+ reject(new Error("Request body too large."));
1070
+ req.destroy();
1071
+ return;
1072
+ }
1073
+ chunks.push(chunk);
1074
+ });
1075
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1076
+ req.on("error", (error) => reject(error));
1077
+ });
1078
+ }
1079
+
1080
+ async function readRequestJson(req, maxBytes) {
1081
+ const text = await readRequestText(req, maxBytes);
1082
+ if (!text.trim()) return {};
1083
+ try {
1084
+ return JSON.parse(text);
1085
+ } catch {
1086
+ throw new Error("Invalid JSON body.");
1087
+ }
1088
+ }
1089
+
1090
+ function getRequestApiKey(req) {
1091
+ const authHeader = req.headers.authorization || "";
1092
+ if (authHeader.toLowerCase().startsWith("bearer ")) {
1093
+ return authHeader.slice(7).trim();
1094
+ }
1095
+ const xApiKey = req.headers["x-api-key"];
1096
+ return typeof xApiKey === "string" ? xApiKey.trim() : "";
1097
+ }
1098
+
1099
+ function isGatewayAuthorized(req) {
1100
+ if (GATEWAY_ALLOW_PUBLIC) return true;
1101
+ const allowedKeys = [GATEWAY_SHARED_SECRET, GATEWAY_CLIENT_API_KEY].filter(Boolean);
1102
+ if (allowedKeys.length === 0) return false;
1103
+ const provided = getRequestApiKey(req);
1104
+ return Boolean(provided && allowedKeys.includes(provided));
1105
+ }
1106
+
1107
+ function requireGatewayClientCredentials() {
1108
+ const clientId = process.env.SLACK_CLIENT_ID || "";
1109
+ const clientSecret = process.env.SLACK_CLIENT_SECRET || "";
1110
+ if (!clientId || !clientSecret) {
1111
+ throw new Error("Gateway OAuth requires SLACK_CLIENT_ID and SLACK_CLIENT_SECRET on the gateway server.");
1112
+ }
1113
+ return { clientId, clientSecret };
1114
+ }
1115
+
1116
+ function profileSummariesFromStore(store) {
1117
+ const summaries = [];
1118
+ for (const [key, profile] of Object.entries(store.profiles || {})) {
1119
+ summaries.push({
1120
+ key,
1121
+ profile_name: profile.profile_name || "",
1122
+ team_id: profile.team_id || "",
1123
+ team_name: profile.team_name || "",
1124
+ authed_user_id: profile.authed_user_id || "",
1125
+ has_bot_token: Boolean(profile.bot_token),
1126
+ has_user_token: Boolean(profile.user_token),
1127
+ updated_at: profile.updated_at || null,
1128
+ is_default: store.default_profile === key,
1129
+ });
1130
+ }
1131
+ return summaries;
1132
+ }
1133
+
1134
+ function buildGatewayRedirectUri() {
1135
+ const url = new URL(OAUTH_CALLBACK_PATH, `${GATEWAY_PUBLIC_BASE_URL.replace(/\/+$/, "")}/`);
1136
+ return url.toString();
1137
+ }
1138
+
1139
+ function parseScopesFromQuery(searchParams, key, fallback) {
1140
+ const value = searchParams.get(key);
1141
+ return parseScopeList(value || fallback);
1142
+ }
1143
+
1144
+ function buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload) {
1145
+ const params = new URLSearchParams();
1146
+ if (payload.profile) params.set("profile", payload.profile);
1147
+ if (payload.team) params.set("team", payload.team);
1148
+ if (payload.scope) params.set("scope", payload.scope);
1149
+ if (payload.user_scope) params.set("user_scope", payload.user_scope);
1150
+ return `${gatewayBaseUrl.replace(/\/+$/, "")}/oauth/start${params.toString() ? `?${params.toString()}` : ""}`;
1151
+ }
1152
+
1153
+ function buildOnboardPowerShellScript({ gatewayBaseUrl, token }) {
1154
+ const safeGateway = String(gatewayBaseUrl || "").replace(/'/g, "''");
1155
+ const safeToken = String(token || "").replace(/'/g, "''");
1156
+ const safePackageSpec = String(ONBOARD_PACKAGE_SPEC || "").replace(/'/g, "''");
1157
+ return [
1158
+ "$ErrorActionPreference = 'Stop'",
1159
+ "if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { throw 'npx is required. Install Node.js first.' }",
1160
+ `npx -y '${safePackageSpec}' onboard run --gateway '${safeGateway}' --token '${safeToken}'`,
1161
+ ].join("\r\n");
1162
+ }
1163
+
1164
+ function createGatewayInviteTokenFromOptions(options = {}) {
1165
+ const secret = requireGatewayInviteSecret();
1166
+ const profile = String(options.profile || "").trim();
1167
+ const team = String(options.team || "").trim();
1168
+ const scope = parseScopeList(options.scope || DEFAULT_OAUTH_BOT_SCOPES).join(",");
1169
+ const userScope = parseScopeList(options["user-scope"] || options.user_scope || DEFAULT_OAUTH_USER_SCOPES).join(
1170
+ ","
380
1171
  );
1172
+ const ttlDays = Math.max(1, Number(options.days || INVITE_TOKEN_DEFAULT_DAYS));
1173
+ const gatewayUrl = String(options.gateway || options.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1174
+
1175
+ const payload = {
1176
+ v: 1,
1177
+ exp: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
1178
+ gateway_url: gatewayUrl,
1179
+ gateway_api_key: String(options["client-api-key"] || options.client_api_key || GATEWAY_CLIENT_API_KEY || ""),
1180
+ profile,
1181
+ team,
1182
+ scope,
1183
+ user_scope: userScope,
1184
+ };
1185
+ const token = createSignedInviteToken(payload, secret);
1186
+ return { token, payload };
1187
+ }
381
1188
 
382
- server.registerTool(
383
- "search_channels",
384
- {
385
- description:
386
- "Find channels by partial match on name/topic/purpose using conversations.list and local filtering.",
387
- inputSchema: {
388
- query: z.string().optional(),
389
- types: z.string().optional(),
390
- exclude_archived: z.boolean().optional(),
391
- limit: z.number().int().min(1).max(1000).optional(),
392
- cursor: z.string().optional(),
393
- token_override: z.string().optional(),
394
- },
395
- },
396
- async ({ query, types, exclude_archived, limit, cursor, token_override }) =>
397
- safeToolRun(async () => {
398
- const data = await callSlackApi(
399
- "conversations.list",
400
- {
401
- types: types || "public_channel,private_channel",
402
- exclude_archived: exclude_archived ?? true,
403
- limit: limit ?? 200,
404
- cursor,
405
- },
406
- token_override
1189
+ async function proxySlackHttpRequest(payload) {
1190
+ const tokenCandidate = requireSlackTokenCandidate(payload.token_override, {
1191
+ profileSelector: payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1192
+ preferredTokenType: payload.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
1193
+ });
1194
+
1195
+ const method = payload.http_method || "GET";
1196
+ const endpoint = new URL(payload.url);
1197
+ for (const [k, v] of Object.entries(toRecordObject(payload.query))) {
1198
+ if (v === undefined || v === null) continue;
1199
+ endpoint.searchParams.set(k, String(v));
1200
+ }
1201
+
1202
+ const reqHeaders = {
1203
+ Authorization: `Bearer ${tokenCandidate.token}`,
1204
+ ...toRecordObject(payload.headers),
1205
+ };
1206
+
1207
+ let body;
1208
+ const formBody = toRecordObject(payload.form_body);
1209
+ const jsonBody = toRecordObject(payload.json_body);
1210
+ if (Object.keys(formBody).length > 0) {
1211
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1212
+ body = toUrlEncodedBody(formBody);
1213
+ } else if (Object.keys(jsonBody).length > 0) {
1214
+ reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1215
+ body = JSON.stringify(jsonBody);
1216
+ }
1217
+
1218
+ const res = await fetch(endpoint.toString(), { method, headers: reqHeaders, body });
1219
+ const text = await res.text();
1220
+ let parsedBody = text;
1221
+ try {
1222
+ parsedBody = JSON.parse(text);
1223
+ } catch {
1224
+ // keep text
1225
+ }
1226
+
1227
+ return {
1228
+ url: endpoint.toString(),
1229
+ status: res.status,
1230
+ ok: res.ok,
1231
+ headers: Object.fromEntries(res.headers.entries()),
1232
+ body: parsedBody,
1233
+ token_source: tokenCandidate.source,
1234
+ };
1235
+ }
1236
+
1237
+ async function startGatewayServer() {
1238
+ const pendingStates = new Map();
1239
+ const callbackPath = OAUTH_CALLBACK_PATH;
1240
+ const redirectUri = buildGatewayRedirectUri();
1241
+ const gatewayBaseUrl = `${GATEWAY_PUBLIC_BASE_URL.replace(/\/+$/, "")}`;
1242
+
1243
+ const server = http.createServer(async (req, res) => {
1244
+ try {
1245
+ const method = req.method || "GET";
1246
+ const requestUrl = new URL(req.url || "/", `http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
1247
+
1248
+ if (method === "GET" && requestUrl.pathname === "/health") {
1249
+ sendJson(res, 200, {
1250
+ ok: true,
1251
+ service: SERVER_NAME,
1252
+ mode: "gateway",
1253
+ token_store_path: TOKEN_STORE_PATH,
1254
+ client_config_path: CLIENT_CONFIG_PATH,
1255
+ callback_url: redirectUri,
1256
+ });
1257
+ return;
1258
+ }
1259
+
1260
+ if (method === "GET" && requestUrl.pathname === "/onboard.ps1") {
1261
+ const token = requestUrl.searchParams.get("token") || "";
1262
+ const secret = requireGatewayInviteSecret();
1263
+ const payload = parseAndVerifyInviteToken(token, secret);
1264
+ const script = buildOnboardPowerShellScript({
1265
+ gatewayBaseUrl: payload.gateway_url || gatewayBaseUrl,
1266
+ token,
1267
+ });
1268
+ res.writeHead(200, {
1269
+ "Content-Type": "text/plain; charset=utf-8",
1270
+ "Cache-Control": "no-store",
1271
+ });
1272
+ res.end(script);
1273
+ return;
1274
+ }
1275
+
1276
+ if (method === "GET" && requestUrl.pathname === "/onboard/resolve") {
1277
+ const token = requestUrl.searchParams.get("token") || "";
1278
+ const secret = requireGatewayInviteSecret();
1279
+ const payload = parseAndVerifyInviteToken(token, secret);
1280
+ const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1281
+ sendJson(res, 200, {
1282
+ ok: true,
1283
+ gateway_url: payload.gateway_url || gatewayBaseUrl,
1284
+ gateway_api_key: payload.gateway_api_key || "",
1285
+ profile: payload.profile || "",
1286
+ oauth_start_url: oauthStartUrl,
1287
+ expires_at: new Date(Number(payload.exp)).toISOString(),
1288
+ });
1289
+ return;
1290
+ }
1291
+
1292
+ if (method === "GET" && requestUrl.pathname === "/oauth/start") {
1293
+ const { clientId } = requireGatewayClientCredentials();
1294
+ const profileName = requestUrl.searchParams.get("profile") || "";
1295
+ const teamId = requestUrl.searchParams.get("team") || process.env.SLACK_OAUTH_TEAM_ID || "";
1296
+ const botScopes = parseScopesFromQuery(requestUrl.searchParams, "scope", DEFAULT_OAUTH_BOT_SCOPES);
1297
+ const userScopes = parseScopesFromQuery(
1298
+ requestUrl.searchParams,
1299
+ "user_scope",
1300
+ DEFAULT_OAUTH_USER_SCOPES
407
1301
  );
408
1302
 
409
- let channels = Array.isArray(data.channels) ? data.channels : [];
410
- if (query) {
411
- const normalizedQuery = query.toLowerCase();
412
- channels = channels.filter((channel) => {
413
- const candidates = [channel.id, channel.name, channel.purpose?.value, channel.topic?.value]
414
- .filter((v) => typeof v === "string")
415
- .map((v) => v.toLowerCase());
416
- return candidates.some((value) => value.includes(normalizedQuery));
417
- });
1303
+ if (botScopes.length === 0 && userScopes.length === 0) {
1304
+ sendJson(res, 400, { ok: false, error: "missing_scope" });
1305
+ return;
418
1306
  }
419
1307
 
420
- return {
421
- channels,
422
- next_cursor: data.response_metadata?.next_cursor || null,
423
- count: channels.length,
424
- };
425
- })
426
- );
1308
+ const state = crypto.randomBytes(24).toString("hex");
1309
+ pendingStates.set(state, {
1310
+ created_at: Date.now(),
1311
+ profile_name: profileName,
1312
+ team_id: teamId,
1313
+ bot_scopes: botScopes,
1314
+ user_scopes: userScopes,
1315
+ });
427
1316
 
428
- server.registerTool(
429
- "send_message",
430
- {
431
- description: "Send a message using chat.postMessage.",
432
- inputSchema: {
433
- channel: z.string().min(1),
434
- text: z.string().min(1),
435
- thread_ts: z.string().optional(),
436
- blocks: z.any().optional(),
437
- mrkdwn: z.boolean().optional(),
438
- unfurl_links: z.boolean().optional(),
439
- unfurl_media: z.boolean().optional(),
440
- token_override: z.string().optional(),
441
- },
442
- },
443
- async ({ channel, text, thread_ts, blocks, mrkdwn, unfurl_links, unfurl_media, token_override }) =>
444
- safeToolRun(async () => {
445
- const data = await callSlackApi(
446
- "chat.postMessage",
447
- {
448
- channel,
449
- text,
450
- thread_ts,
451
- blocks: parseJsonMaybe(blocks),
452
- mrkdwn,
453
- unfurl_links,
454
- unfurl_media,
455
- },
456
- token_override
457
- );
1317
+ const authorizeUrl = buildOauthAuthorizeUrl({
1318
+ clientId,
1319
+ state,
1320
+ redirectUri,
1321
+ botScopes,
1322
+ userScopes,
1323
+ teamId,
1324
+ });
458
1325
 
459
- return {
460
- channel: data.channel,
461
- ts: data.ts,
462
- message: data.message,
463
- };
464
- })
465
- );
1326
+ res.writeHead(302, { Location: authorizeUrl });
1327
+ res.end();
1328
+ return;
1329
+ }
1330
+
1331
+ if (method === "GET" && requestUrl.pathname === callbackPath) {
1332
+ const { clientId, clientSecret } = requireGatewayClientCredentials();
1333
+ const receivedError = requestUrl.searchParams.get("error");
1334
+ if (receivedError) {
1335
+ sendText(res, 400, `Slack OAuth failed: ${receivedError}`);
1336
+ return;
1337
+ }
466
1338
 
467
- server.registerTool(
468
- "read_channel",
469
- {
470
- description: "Read channel history with conversations.history.",
471
- inputSchema: {
472
- channel: z.string().min(1),
473
- limit: z.number().int().min(1).max(1000).optional(),
474
- cursor: z.string().optional(),
475
- oldest: z.string().optional(),
476
- latest: z.string().optional(),
477
- inclusive: z.boolean().optional(),
478
- token_override: z.string().optional(),
479
- },
480
- },
481
- async ({ channel, limit, cursor, oldest, latest, inclusive, token_override }) =>
482
- safeToolRun(async () => {
483
- const data = await callSlackApi(
484
- "conversations.history",
485
- {
486
- channel,
487
- limit: limit ?? 50,
488
- cursor,
489
- oldest,
490
- latest,
491
- inclusive,
492
- },
493
- token_override
494
- );
1339
+ const state = requestUrl.searchParams.get("state");
1340
+ const code = requestUrl.searchParams.get("code");
1341
+ if (!state || !code) {
1342
+ sendText(res, 400, "Missing state/code.");
1343
+ return;
1344
+ }
495
1345
 
496
- return {
497
- channel,
498
- messages: data.messages || [],
499
- has_more: Boolean(data.has_more),
500
- next_cursor: data.response_metadata?.next_cursor || null,
501
- };
502
- })
503
- );
1346
+ const pending = pendingStates.get(state);
1347
+ pendingStates.delete(state);
1348
+ if (!pending) {
1349
+ sendText(res, 400, "Invalid or expired OAuth state.");
1350
+ return;
1351
+ }
1352
+ if (Date.now() - pending.created_at > GATEWAY_STATE_TTL_MS) {
1353
+ sendText(res, 400, "Expired OAuth state.");
1354
+ return;
1355
+ }
504
1356
 
505
- server.registerTool(
506
- "read_thread",
507
- {
508
- description: "Read a thread using conversations.replies.",
509
- inputSchema: {
510
- channel: z.string().min(1),
511
- thread_ts: z.string().min(1),
512
- limit: z.number().int().min(1).max(1000).optional(),
513
- cursor: z.string().optional(),
514
- oldest: z.string().optional(),
515
- latest: z.string().optional(),
516
- inclusive: z.boolean().optional(),
517
- token_override: z.string().optional(),
518
- },
519
- },
520
- async ({ channel, thread_ts, limit, cursor, oldest, latest, inclusive, token_override }) =>
521
- safeToolRun(async () => {
522
- const data = await callSlackApi(
523
- "conversations.replies",
524
- {
525
- channel,
526
- ts: thread_ts,
527
- limit: limit ?? 50,
528
- cursor,
529
- oldest,
530
- latest,
531
- inclusive,
532
- },
533
- token_override
1357
+ const oauthResponse = await exchangeOauthCode({ clientId, clientSecret, code, redirectUri });
1358
+ const { key, profile } = upsertOauthProfile(oauthResponse, pending.profile_name);
1359
+ sendText(
1360
+ res,
1361
+ 200,
1362
+ [
1363
+ "Slack OAuth authorization completed.",
1364
+ `Saved profile: ${profile.profile_name || key}`,
1365
+ `Profile key: ${key}`,
1366
+ "You can close this tab.",
1367
+ ].join("\n")
534
1368
  );
1369
+ return;
1370
+ }
1371
+
1372
+ if (method === "GET" && requestUrl.pathname === "/oauth/link") {
1373
+ const params = new URLSearchParams();
1374
+ const profile = requestUrl.searchParams.get("profile") || "";
1375
+ const team = requestUrl.searchParams.get("team") || "";
1376
+ const scope = requestUrl.searchParams.get("scope") || "";
1377
+ const userScope = requestUrl.searchParams.get("user_scope") || "";
1378
+ if (profile) params.set("profile", profile);
1379
+ if (team) params.set("team", team);
1380
+ if (scope) params.set("scope", scope);
1381
+ if (userScope) params.set("user_scope", userScope);
1382
+ sendJson(res, 200, {
1383
+ ok: true,
1384
+ url: `${gatewayBaseUrl}/oauth/start${params.toString() ? `?${params.toString()}` : ""}`,
1385
+ });
1386
+ return;
1387
+ }
535
1388
 
536
- return {
537
- channel,
538
- thread_ts,
539
- messages: data.messages || [],
540
- has_more: Boolean(data.has_more),
541
- next_cursor: data.response_metadata?.next_cursor || null,
542
- };
543
- })
544
- );
1389
+ if (method === "GET" && requestUrl.pathname === "/profiles") {
1390
+ if (!isGatewayAuthorized(req)) {
1391
+ sendJson(res, 401, { ok: false, error: "unauthorized" });
1392
+ return;
1393
+ }
1394
+ const tokenStore = loadTokenStore();
1395
+ sendJson(res, 200, {
1396
+ ok: true,
1397
+ default_profile: tokenStore.default_profile,
1398
+ profiles: profileSummariesFromStore(tokenStore),
1399
+ });
1400
+ return;
1401
+ }
545
1402
 
546
- server.registerTool(
547
- "create_canvas",
548
- {
549
- description: "Create a canvas using canvases.create. Pass Slack params in `params`.",
550
- inputSchema: {
551
- params: z.record(z.string(), z.any()),
552
- token_override: z.string().optional(),
553
- },
554
- },
555
- async ({ params, token_override }) =>
556
- safeToolRun(async () => {
557
- const data = await callSlackApi("canvases.create", params, token_override);
558
- return data;
559
- })
560
- );
1403
+ if (method === "POST" && requestUrl.pathname === "/api/slack/call") {
1404
+ if (!isGatewayAuthorized(req)) {
1405
+ sendJson(res, 401, { ok: false, error: "unauthorized" });
1406
+ return;
1407
+ }
561
1408
 
562
- server.registerTool(
563
- "update_canvas",
564
- {
565
- description: "Update a canvas using canvases.edit. Pass Slack params in `params`.",
566
- inputSchema: {
567
- params: z.record(z.string(), z.any()),
568
- token_override: z.string().optional(),
569
- },
570
- },
571
- async ({ params, token_override }) =>
572
- safeToolRun(async () => {
573
- const data = await callSlackApi("canvases.edit", params, token_override);
574
- return data;
575
- })
576
- );
1409
+ const payload = await readRequestJson(req, 1024 * 1024);
1410
+ const methodName = payload.method;
1411
+ if (!methodName || typeof methodName !== "string") {
1412
+ sendJson(res, 400, { ok: false, error: "missing_method" });
1413
+ return;
1414
+ }
577
1415
 
578
- server.registerTool(
579
- "read_canvas",
580
- {
581
- description: "Read canvas content using canvases.sections.lookup. Pass Slack params in `params`.",
582
- inputSchema: {
583
- params: z.record(z.string(), z.any()),
584
- token_override: z.string().optional(),
585
- },
586
- },
587
- async ({ params, token_override }) =>
588
- safeToolRun(async () => {
589
- const data = await callSlackApi("canvases.sections.lookup", params, token_override);
590
- return data;
591
- })
592
- );
1416
+ const candidates = getSlackTokenCandidates(payload.token_override, {
1417
+ profileSelector:
1418
+ payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1419
+ preferredTokenType:
1420
+ payload.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
1421
+ });
1422
+ if (candidates.length === 0) {
1423
+ sendJson(res, 400, { ok: false, error: "missing_token" });
1424
+ return;
1425
+ }
593
1426
 
594
- server.registerTool(
595
- "read_user_profile",
596
- {
597
- description: "Read user info/profile using users.info plus users.profile.get (best effort).",
598
- inputSchema: {
599
- user: z.string().min(1),
600
- include_locale: z.boolean().optional(),
601
- include_labels: z.boolean().optional(),
602
- token_override: z.string().optional(),
603
- },
604
- },
605
- async ({ user, include_locale, include_labels, token_override }) =>
606
- safeToolRun(async () => {
607
- const info = await callSlackApi(
608
- "users.info",
609
- { user, include_locale: include_locale ?? true },
610
- token_override
611
- );
1427
+ const data = await callSlackApiWithCandidates(methodName, payload.params || {}, candidates);
1428
+ sendJson(res, 200, { ok: true, data });
1429
+ return;
1430
+ }
612
1431
 
613
- let profile = null;
614
- try {
615
- const profileData = await callSlackApi(
616
- "users.profile.get",
617
- { user, include_labels: include_labels ?? true },
618
- token_override
619
- );
620
- profile = profileData.profile || null;
621
- } catch {
622
- profile = info.user?.profile || null;
1432
+ if (method === "POST" && requestUrl.pathname === "/api/slack/http") {
1433
+ if (!isGatewayAuthorized(req)) {
1434
+ sendJson(res, 401, { ok: false, error: "unauthorized" });
1435
+ return;
623
1436
  }
624
1437
 
625
- return { user: info.user || null, profile };
626
- })
627
- );
628
- }
1438
+ const payload = await readRequestJson(req, 1024 * 1024);
1439
+ if (!payload.url || typeof payload.url !== "string") {
1440
+ sendJson(res, 400, { ok: false, error: "missing_url" });
1441
+ return;
1442
+ }
629
1443
 
630
- function registerCatalogMethodTools(server, catalog) {
631
- if (!ENABLE_METHOD_TOOLS) return { registered: 0 };
1444
+ const data = await proxySlackHttpRequest(payload);
1445
+ sendJson(res, 200, { ok: true, data });
1446
+ return;
1447
+ }
1448
+
1449
+ sendJson(res, 404, { ok: false, error: "not_found" });
1450
+ } catch (error) {
1451
+ sendJson(res, 500, {
1452
+ ok: false,
1453
+ error: error instanceof Error ? error.message : String(error),
1454
+ slack_error: error?.slack_error || null,
1455
+ needed: error?.needed || null,
1456
+ provided: error?.provided || null,
1457
+ token_source: error?.token_source || null,
1458
+ });
1459
+ } finally {
1460
+ for (const [state, value] of pendingStates.entries()) {
1461
+ if (Date.now() - value.created_at > GATEWAY_STATE_TTL_MS) {
1462
+ pendingStates.delete(state);
1463
+ }
1464
+ }
1465
+ }
1466
+ });
632
1467
 
633
- const methods = Array.isArray(catalog.methods) ? catalog.methods : [];
634
- const limited = MAX_METHOD_TOOLS > 0 ? methods.slice(0, MAX_METHOD_TOOLS) : methods;
1468
+ await new Promise((resolve, reject) => {
1469
+ server.once("error", reject);
1470
+ server.listen(GATEWAY_PORT, GATEWAY_HOST, resolve);
1471
+ });
635
1472
 
636
- const usedNames = new Set();
637
- let registered = 0;
1473
+ console.error(
1474
+ `[${SERVER_NAME}] gateway listening at http://${GATEWAY_HOST}:${GATEWAY_PORT} | public_base=${gatewayBaseUrl}`
1475
+ );
1476
+ console.error(`[${SERVER_NAME}] oauth start URL: ${gatewayBaseUrl}/oauth/start`);
1477
+ console.error(`[${SERVER_NAME}] profile list URL: ${gatewayBaseUrl}/profiles`);
1478
+ }
638
1479
 
639
- for (const methodInfo of limited) {
640
- const method = methodInfo?.method;
641
- if (!method || typeof method !== "string") continue;
1480
+ function printGatewayHelp() {
1481
+ const lines = [
1482
+ "Slack Max Gateway helper",
1483
+ "",
1484
+ "Usage:",
1485
+ " slack-max-api-mcp gateway start",
1486
+ " slack-max-api-mcp gateway invite --profile woobin --team T123",
1487
+ " slack-max-api-mcp gateway help",
1488
+ "",
1489
+ "Gateway env vars (server-side):",
1490
+ " SLACK_CLIENT_ID, SLACK_CLIENT_SECRET",
1491
+ " SLACK_GATEWAY_HOST, SLACK_GATEWAY_PORT, SLACK_GATEWAY_PUBLIC_BASE_URL",
1492
+ " SLACK_GATEWAY_SHARED_SECRET (recommended)",
1493
+ " SLACK_GATEWAY_CLIENT_API_KEY (optional, defaults to shared secret)",
1494
+ " SLACK_OAUTH_BOT_SCOPES, SLACK_OAUTH_USER_SCOPES",
1495
+ "",
1496
+ "Client env vars (mcp caller-side):",
1497
+ " SLACK_GATEWAY_URL, SLACK_GATEWAY_API_KEY",
1498
+ " SLACK_PROFILE or SLACK_GATEWAY_PROFILE",
1499
+ ];
1500
+ console.log(lines.join("\n"));
1501
+ }
642
1502
 
643
- const toolName = toolNameFromMethod(method, usedNames);
644
- const descriptionParts = [
645
- `Slack Web API method wrapper for ${method}.`,
646
- methodInfo.description ? `Official: ${methodInfo.description}` : "",
647
- Array.isArray(methodInfo.scopes) && methodInfo.scopes.length
648
- ? `Scopes: ${methodInfo.scopes.join(", ")}`
649
- : "",
650
- ].filter(Boolean);
1503
+ function runGatewayInvite(args) {
1504
+ const { options } = parseCliArgs(args);
1505
+ const { token, payload } = createGatewayInviteTokenFromOptions(options);
1506
+ const gatewayBaseUrl = String(payload.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1507
+ const onboardScriptUrl = `${gatewayBaseUrl}/onboard.ps1?token=${encodeURIComponent(token)}`;
1508
+ const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1509
+ const command = `powershell -ExecutionPolicy Bypass -Command "irm '${onboardScriptUrl}' | iex"`;
1510
+
1511
+ console.log("[gateway] invite token created");
1512
+ console.log(`[gateway] expires_at: ${new Date(Number(payload.exp)).toISOString()}`);
1513
+ console.log(`[gateway] onboarding_script: ${onboardScriptUrl}`);
1514
+ console.log(`[gateway] oauth_start_url: ${oauthStartUrl}`);
1515
+ console.log("[gateway] one-click command for team member:");
1516
+ console.log(command);
1517
+ }
651
1518
 
652
- server.registerTool(
653
- toolName,
654
- {
655
- description: descriptionParts.join(" "),
656
- inputSchema: {
657
- params: z.record(z.string(), z.any()).optional(),
658
- token_override: z.string().optional(),
659
- },
660
- },
661
- async ({ params, token_override }) =>
662
- safeToolRun(async () => {
663
- const data = await callSlackApi(method, params || {}, token_override);
664
- return { method, data };
665
- })
666
- );
667
- registered += 1;
1519
+ async function runGatewayCli(args) {
1520
+ const subcommand = (args[0] || "help").toLowerCase();
1521
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1522
+ printGatewayHelp();
1523
+ return;
668
1524
  }
1525
+ if (subcommand === "start") {
1526
+ await startGatewayServer();
1527
+ return;
1528
+ }
1529
+ if (subcommand === "invite") {
1530
+ runGatewayInvite(args.slice(1));
1531
+ return;
1532
+ }
1533
+ throw new Error(`Unknown gateway command: ${subcommand}`);
1534
+ }
669
1535
 
670
- server.registerTool(
671
- "slack_method_tools_info",
672
- {
673
- description: "Return summary for catalog-driven method tools currently loaded.",
674
- inputSchema: {},
1536
+ function loadCatalog() {
1537
+ if (!fs.existsSync(CATALOG_PATH)) {
1538
+ return { methods: [], scopes: [], totals: {} };
1539
+ }
1540
+
1541
+ try {
1542
+ const raw = fs.readFileSync(CATALOG_PATH, "utf8");
1543
+ const parsed = JSON.parse(raw);
1544
+ return parsed;
1545
+ } catch (error) {
1546
+ throw new Error(`Failed to load catalog at ${CATALOG_PATH}: ${error}`);
1547
+ }
1548
+ }
1549
+
1550
+ function toolNameFromMethod(method, usedNames) {
1551
+ const base = `${METHOD_TOOL_PREFIX}_${method.replace(/[^a-zA-Z0-9]/g, "_")}`;
1552
+ if (!usedNames.has(base)) {
1553
+ usedNames.add(base);
1554
+ return base;
1555
+ }
1556
+
1557
+ let idx = 2;
1558
+ while (usedNames.has(`${base}_${idx}`)) idx += 1;
1559
+ const name = `${base}_${idx}`;
1560
+ usedNames.add(name);
1561
+ return name;
1562
+ }
1563
+
1564
+ function registerCoreTools(server) {
1565
+ server.registerTool(
1566
+ "slack_api_call",
1567
+ {
1568
+ description: "Call any Slack Web API method directly.",
1569
+ inputSchema: {
1570
+ method: z
1571
+ .string()
1572
+ .min(3)
1573
+ .regex(/^[a-z][a-zA-Z0-9_.]+$/, "Method must look like chat.postMessage"),
1574
+ params: z.record(z.string(), z.any()).optional(),
1575
+ token_override: z.string().optional(),
1576
+ },
1577
+ },
1578
+ async ({ method, params, token_override }) =>
1579
+ safeToolRun(async () => {
1580
+ const data = await callSlackApi(method, params || {}, token_override);
1581
+ return { method, data };
1582
+ })
1583
+ );
1584
+
1585
+ server.registerTool(
1586
+ "slack_http_api_call",
1587
+ {
1588
+ description:
1589
+ "Generic HTTP call for Slack APIs outside standard Web API methods (SCIM/Audit/Legal Holds).",
1590
+ inputSchema: {
1591
+ url: z.string().url(),
1592
+ http_method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
1593
+ query: z.record(z.string(), z.any()).optional(),
1594
+ json_body: z.record(z.string(), z.any()).optional(),
1595
+ form_body: z.record(z.string(), z.any()).optional(),
1596
+ headers: z.record(z.string(), z.string()).optional(),
1597
+ token_override: z.string().optional(),
1598
+ },
1599
+ },
1600
+ async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
1601
+ safeToolRun(async () => {
1602
+ const runtimeGateway = getRuntimeGatewayConfig();
1603
+ if (runtimeGateway.url) {
1604
+ return slackHttpViaGateway({
1605
+ url,
1606
+ http_method,
1607
+ query,
1608
+ json_body,
1609
+ form_body,
1610
+ headers,
1611
+ token_override,
1612
+ });
1613
+ }
1614
+
1615
+ const tokenCandidate = requireSlackTokenCandidate(token_override);
1616
+ const method = http_method || "GET";
1617
+
1618
+ const endpoint = new URL(url);
1619
+ for (const [k, v] of Object.entries(toRecordObject(query))) {
1620
+ if (v === undefined || v === null) continue;
1621
+ endpoint.searchParams.set(k, String(v));
1622
+ }
1623
+
1624
+ const reqHeaders = {
1625
+ Authorization: `Bearer ${tokenCandidate.token}`,
1626
+ ...(headers || {}),
1627
+ };
1628
+
1629
+ let body;
1630
+ if (form_body && Object.keys(form_body).length > 0) {
1631
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1632
+ body = toUrlEncodedBody(form_body);
1633
+ } else if (json_body && Object.keys(json_body).length > 0) {
1634
+ reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1635
+ body = JSON.stringify(json_body);
1636
+ }
1637
+
1638
+ const res = await fetch(endpoint.toString(), {
1639
+ method,
1640
+ headers: reqHeaders,
1641
+ body,
1642
+ });
1643
+
1644
+ const text = await res.text();
1645
+ let parsedBody = text;
1646
+ try {
1647
+ parsedBody = JSON.parse(text);
1648
+ } catch {
1649
+ // Keep plain text when response is not JSON.
1650
+ }
1651
+
1652
+ return {
1653
+ url: endpoint.toString(),
1654
+ status: res.status,
1655
+ ok: res.ok,
1656
+ headers: Object.fromEntries(res.headers.entries()),
1657
+ body: parsedBody,
1658
+ };
1659
+ })
1660
+ );
1661
+
1662
+ server.registerTool(
1663
+ "search_messages_files",
1664
+ {
1665
+ description:
1666
+ "Search messages and files. Uses search.messages and search.files and returns both.",
1667
+ inputSchema: {
1668
+ query: z.string().min(1),
1669
+ count: z.number().int().min(1).max(100).optional(),
1670
+ page: z.number().int().min(1).optional(),
1671
+ sort: z.enum(["score", "timestamp"]).optional(),
1672
+ sort_dir: z.enum(["asc", "desc"]).optional(),
1673
+ include_messages: z.boolean().optional(),
1674
+ include_files: z.boolean().optional(),
1675
+ token_override: z.string().optional(),
1676
+ },
1677
+ },
1678
+ async ({
1679
+ query,
1680
+ count,
1681
+ page,
1682
+ sort,
1683
+ sort_dir,
1684
+ include_messages,
1685
+ include_files,
1686
+ token_override,
1687
+ }) =>
1688
+ safeToolRun(async () => {
1689
+ const shouldSearchMessages = include_messages !== false;
1690
+ const shouldSearchFiles = include_files !== false;
1691
+ const sharedParams = {
1692
+ query,
1693
+ count: count ?? 20,
1694
+ page: page ?? 1,
1695
+ sort,
1696
+ sort_dir,
1697
+ };
1698
+
1699
+ let messages = null;
1700
+ let files = null;
1701
+
1702
+ if (shouldSearchMessages) {
1703
+ messages = await callSlackApi("search.messages", sharedParams, token_override);
1704
+ }
1705
+ if (shouldSearchFiles) {
1706
+ files = await callSlackApi("search.files", sharedParams, token_override);
1707
+ }
1708
+
1709
+ return {
1710
+ query,
1711
+ messages: messages ? messages.messages : null,
1712
+ files: files ? files.files : null,
1713
+ };
1714
+ })
1715
+ );
1716
+
1717
+ server.registerTool(
1718
+ "search_users",
1719
+ {
1720
+ description:
1721
+ "Find users by partial match on id/name/display_name/email using users.list and local filtering.",
1722
+ inputSchema: {
1723
+ query: z.string().optional(),
1724
+ limit: z.number().int().min(1).max(1000).optional(),
1725
+ include_locale: z.boolean().optional(),
1726
+ include_deleted: z.boolean().optional(),
1727
+ include_bots: z.boolean().optional(),
1728
+ cursor: z.string().optional(),
1729
+ token_override: z.string().optional(),
1730
+ },
1731
+ },
1732
+ async ({
1733
+ query,
1734
+ limit,
1735
+ include_locale,
1736
+ include_deleted,
1737
+ include_bots,
1738
+ cursor,
1739
+ token_override,
1740
+ }) =>
1741
+ safeToolRun(async () => {
1742
+ const listData = await callSlackApi(
1743
+ "users.list",
1744
+ {
1745
+ limit: limit ?? 200,
1746
+ include_locale: include_locale ?? true,
1747
+ cursor,
1748
+ },
1749
+ token_override
1750
+ );
1751
+
1752
+ const normalizedQuery = query ? query.toLowerCase() : null;
1753
+ let users = Array.isArray(listData.members) ? listData.members : [];
1754
+
1755
+ if (include_deleted !== true) {
1756
+ users = users.filter((u) => !u.deleted);
1757
+ }
1758
+ if (include_bots !== true) {
1759
+ users = users.filter((u) => !u.is_bot && !u.is_app_user);
1760
+ }
1761
+ if (normalizedQuery) {
1762
+ users = users.filter((u) => {
1763
+ const candidates = [
1764
+ u.id,
1765
+ u.name,
1766
+ u.real_name,
1767
+ u.profile?.display_name,
1768
+ u.profile?.real_name,
1769
+ u.profile?.email,
1770
+ ]
1771
+ .filter((v) => typeof v === "string")
1772
+ .map((v) => v.toLowerCase());
1773
+ return candidates.some((value) => value.includes(normalizedQuery));
1774
+ });
1775
+ }
1776
+
1777
+ return {
1778
+ users,
1779
+ next_cursor: listData.response_metadata?.next_cursor || null,
1780
+ count: users.length,
1781
+ };
1782
+ })
1783
+ );
1784
+
1785
+ server.registerTool(
1786
+ "search_channels",
1787
+ {
1788
+ description:
1789
+ "Find channels by partial match on name/topic/purpose using conversations.list and local filtering.",
1790
+ inputSchema: {
1791
+ query: z.string().optional(),
1792
+ types: z.string().optional(),
1793
+ exclude_archived: z.boolean().optional(),
1794
+ limit: z.number().int().min(1).max(1000).optional(),
1795
+ cursor: z.string().optional(),
1796
+ token_override: z.string().optional(),
1797
+ },
1798
+ },
1799
+ async ({ query, types, exclude_archived, limit, cursor, token_override }) =>
1800
+ safeToolRun(async () => {
1801
+ const data = await callSlackApi(
1802
+ "conversations.list",
1803
+ {
1804
+ types: types || "public_channel,private_channel",
1805
+ exclude_archived: exclude_archived ?? true,
1806
+ limit: limit ?? 200,
1807
+ cursor,
1808
+ },
1809
+ token_override
1810
+ );
1811
+
1812
+ let channels = Array.isArray(data.channels) ? data.channels : [];
1813
+ if (query) {
1814
+ const normalizedQuery = query.toLowerCase();
1815
+ channels = channels.filter((channel) => {
1816
+ const candidates = [channel.id, channel.name, channel.purpose?.value, channel.topic?.value]
1817
+ .filter((v) => typeof v === "string")
1818
+ .map((v) => v.toLowerCase());
1819
+ return candidates.some((value) => value.includes(normalizedQuery));
1820
+ });
1821
+ }
1822
+
1823
+ return {
1824
+ channels,
1825
+ next_cursor: data.response_metadata?.next_cursor || null,
1826
+ count: channels.length,
1827
+ };
1828
+ })
1829
+ );
1830
+
1831
+ server.registerTool(
1832
+ "send_message",
1833
+ {
1834
+ description: "Send a message using chat.postMessage.",
1835
+ inputSchema: {
1836
+ channel: z.string().min(1),
1837
+ text: z.string().min(1),
1838
+ thread_ts: z.string().optional(),
1839
+ blocks: z.any().optional(),
1840
+ mrkdwn: z.boolean().optional(),
1841
+ unfurl_links: z.boolean().optional(),
1842
+ unfurl_media: z.boolean().optional(),
1843
+ token_override: z.string().optional(),
1844
+ },
1845
+ },
1846
+ async ({ channel, text, thread_ts, blocks, mrkdwn, unfurl_links, unfurl_media, token_override }) =>
1847
+ safeToolRun(async () => {
1848
+ const data = await callSlackApi(
1849
+ "chat.postMessage",
1850
+ {
1851
+ channel,
1852
+ text,
1853
+ thread_ts,
1854
+ blocks: parseJsonMaybe(blocks),
1855
+ mrkdwn,
1856
+ unfurl_links,
1857
+ unfurl_media,
1858
+ },
1859
+ token_override
1860
+ );
1861
+
1862
+ return {
1863
+ channel: data.channel,
1864
+ ts: data.ts,
1865
+ message: data.message,
1866
+ };
1867
+ })
1868
+ );
1869
+
1870
+ server.registerTool(
1871
+ "read_channel",
1872
+ {
1873
+ description: "Read channel history with conversations.history.",
1874
+ inputSchema: {
1875
+ channel: z.string().min(1),
1876
+ limit: z.number().int().min(1).max(1000).optional(),
1877
+ cursor: z.string().optional(),
1878
+ oldest: z.string().optional(),
1879
+ latest: z.string().optional(),
1880
+ inclusive: z.boolean().optional(),
1881
+ token_override: z.string().optional(),
1882
+ },
1883
+ },
1884
+ async ({ channel, limit, cursor, oldest, latest, inclusive, token_override }) =>
1885
+ safeToolRun(async () => {
1886
+ const data = await callSlackApi(
1887
+ "conversations.history",
1888
+ {
1889
+ channel,
1890
+ limit: limit ?? 50,
1891
+ cursor,
1892
+ oldest,
1893
+ latest,
1894
+ inclusive,
1895
+ },
1896
+ token_override
1897
+ );
1898
+
1899
+ return {
1900
+ channel,
1901
+ messages: data.messages || [],
1902
+ has_more: Boolean(data.has_more),
1903
+ next_cursor: data.response_metadata?.next_cursor || null,
1904
+ };
1905
+ })
1906
+ );
1907
+
1908
+ server.registerTool(
1909
+ "read_thread",
1910
+ {
1911
+ description: "Read a thread using conversations.replies.",
1912
+ inputSchema: {
1913
+ channel: z.string().min(1),
1914
+ thread_ts: z.string().min(1),
1915
+ limit: z.number().int().min(1).max(1000).optional(),
1916
+ cursor: z.string().optional(),
1917
+ oldest: z.string().optional(),
1918
+ latest: z.string().optional(),
1919
+ inclusive: z.boolean().optional(),
1920
+ token_override: z.string().optional(),
1921
+ },
1922
+ },
1923
+ async ({ channel, thread_ts, limit, cursor, oldest, latest, inclusive, token_override }) =>
1924
+ safeToolRun(async () => {
1925
+ const data = await callSlackApi(
1926
+ "conversations.replies",
1927
+ {
1928
+ channel,
1929
+ ts: thread_ts,
1930
+ limit: limit ?? 50,
1931
+ cursor,
1932
+ oldest,
1933
+ latest,
1934
+ inclusive,
1935
+ },
1936
+ token_override
1937
+ );
1938
+
1939
+ return {
1940
+ channel,
1941
+ thread_ts,
1942
+ messages: data.messages || [],
1943
+ has_more: Boolean(data.has_more),
1944
+ next_cursor: data.response_metadata?.next_cursor || null,
1945
+ };
1946
+ })
1947
+ );
1948
+
1949
+ server.registerTool(
1950
+ "create_canvas",
1951
+ {
1952
+ description: "Create a canvas using canvases.create. Pass Slack params in `params`.",
1953
+ inputSchema: {
1954
+ params: z.record(z.string(), z.any()),
1955
+ token_override: z.string().optional(),
1956
+ },
1957
+ },
1958
+ async ({ params, token_override }) =>
1959
+ safeToolRun(async () => {
1960
+ const data = await callSlackApi("canvases.create", params, token_override);
1961
+ return data;
1962
+ })
1963
+ );
1964
+
1965
+ server.registerTool(
1966
+ "update_canvas",
1967
+ {
1968
+ description: "Update a canvas using canvases.edit. Pass Slack params in `params`.",
1969
+ inputSchema: {
1970
+ params: z.record(z.string(), z.any()),
1971
+ token_override: z.string().optional(),
1972
+ },
1973
+ },
1974
+ async ({ params, token_override }) =>
1975
+ safeToolRun(async () => {
1976
+ const data = await callSlackApi("canvases.edit", params, token_override);
1977
+ return data;
1978
+ })
1979
+ );
1980
+
1981
+ server.registerTool(
1982
+ "read_canvas",
1983
+ {
1984
+ description: "Read canvas content using canvases.sections.lookup. Pass Slack params in `params`.",
1985
+ inputSchema: {
1986
+ params: z.record(z.string(), z.any()),
1987
+ token_override: z.string().optional(),
1988
+ },
1989
+ },
1990
+ async ({ params, token_override }) =>
1991
+ safeToolRun(async () => {
1992
+ const data = await callSlackApi("canvases.sections.lookup", params, token_override);
1993
+ return data;
1994
+ })
1995
+ );
1996
+
1997
+ server.registerTool(
1998
+ "read_user_profile",
1999
+ {
2000
+ description: "Read user info/profile using users.info plus users.profile.get (best effort).",
2001
+ inputSchema: {
2002
+ user: z.string().min(1),
2003
+ include_locale: z.boolean().optional(),
2004
+ include_labels: z.boolean().optional(),
2005
+ token_override: z.string().optional(),
2006
+ },
2007
+ },
2008
+ async ({ user, include_locale, include_labels, token_override }) =>
2009
+ safeToolRun(async () => {
2010
+ const info = await callSlackApi(
2011
+ "users.info",
2012
+ { user, include_locale: include_locale ?? true },
2013
+ token_override
2014
+ );
2015
+
2016
+ let profile = null;
2017
+ try {
2018
+ const profileData = await callSlackApi(
2019
+ "users.profile.get",
2020
+ { user, include_labels: include_labels ?? true },
2021
+ token_override
2022
+ );
2023
+ profile = profileData.profile || null;
2024
+ } catch {
2025
+ profile = info.user?.profile || null;
2026
+ }
2027
+
2028
+ return { user: info.user || null, profile };
2029
+ })
2030
+ );
2031
+ }
2032
+
2033
+ function registerCatalogMethodTools(server, catalog) {
2034
+ if (!ENABLE_METHOD_TOOLS) return { registered: 0 };
2035
+
2036
+ const methods = Array.isArray(catalog.methods) ? catalog.methods : [];
2037
+ const limited = MAX_METHOD_TOOLS > 0 ? methods.slice(0, MAX_METHOD_TOOLS) : methods;
2038
+
2039
+ const usedNames = new Set();
2040
+ let registered = 0;
2041
+
2042
+ for (const methodInfo of limited) {
2043
+ const method = methodInfo?.method;
2044
+ if (!method || typeof method !== "string") continue;
2045
+
2046
+ const toolName = toolNameFromMethod(method, usedNames);
2047
+ const descriptionParts = [
2048
+ `Slack Web API method wrapper for ${method}.`,
2049
+ methodInfo.description ? `Official: ${methodInfo.description}` : "",
2050
+ Array.isArray(methodInfo.scopes) && methodInfo.scopes.length
2051
+ ? `Scopes: ${methodInfo.scopes.join(", ")}`
2052
+ : "",
2053
+ ].filter(Boolean);
2054
+
2055
+ server.registerTool(
2056
+ toolName,
2057
+ {
2058
+ description: descriptionParts.join(" "),
2059
+ inputSchema: {
2060
+ params: z.record(z.string(), z.any()).optional(),
2061
+ token_override: z.string().optional(),
2062
+ },
2063
+ },
2064
+ async ({ params, token_override }) =>
2065
+ safeToolRun(async () => {
2066
+ const data = await callSlackApi(method, params || {}, token_override);
2067
+ return { method, data };
2068
+ })
2069
+ );
2070
+ registered += 1;
2071
+ }
2072
+
2073
+ server.registerTool(
2074
+ "slack_method_tools_info",
2075
+ {
2076
+ description: "Return summary for catalog-driven method tools currently loaded.",
2077
+ inputSchema: {},
675
2078
  },
676
2079
  async () =>
677
- safeToolRun(async () => ({
678
- catalog_path: CATALOG_PATH,
679
- method_tools_enabled: ENABLE_METHOD_TOOLS,
680
- max_method_tools: MAX_METHOD_TOOLS,
681
- methods_in_catalog: methods.length,
682
- method_tools_registered: registered,
683
- method_tool_prefix: METHOD_TOOL_PREFIX,
684
- }))
2080
+ safeToolRun(async () => {
2081
+ const tokenStore = loadTokenStore();
2082
+ const activeProfile = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
2083
+ const clientConfig = loadClientConfig();
2084
+ const runtimeGateway = getRuntimeGatewayConfig();
2085
+ return {
2086
+ catalog_path: CATALOG_PATH,
2087
+ method_tools_enabled: ENABLE_METHOD_TOOLS,
2088
+ max_method_tools: MAX_METHOD_TOOLS,
2089
+ methods_in_catalog: methods.length,
2090
+ method_tools_registered: registered,
2091
+ method_tool_prefix: METHOD_TOOL_PREFIX,
2092
+ token_store_path: TOKEN_STORE_PATH,
2093
+ client_config_path: CLIENT_CONFIG_PATH,
2094
+ active_profile: activeProfile
2095
+ ? {
2096
+ key: activeProfile.key,
2097
+ profile_name: activeProfile.profile?.profile_name || "",
2098
+ team_id: activeProfile.profile?.team_id || "",
2099
+ }
2100
+ : null,
2101
+ client_profile: clientConfig.profile || "",
2102
+ env_tokens_present: {
2103
+ bot: Boolean(process.env.SLACK_BOT_TOKEN),
2104
+ user: Boolean(process.env.SLACK_USER_TOKEN),
2105
+ generic: Boolean(process.env.SLACK_TOKEN),
2106
+ },
2107
+ gateway_mode: Boolean(runtimeGateway.url),
2108
+ gateway_url: runtimeGateway.url || null,
2109
+ env_example_fallback_enabled: ALLOW_ENV_EXAMPLE_FALLBACK,
2110
+ };
2111
+ })
685
2112
  );
686
-
687
- return { registered };
688
- }
689
-
690
- async function main() {
2113
+
2114
+ return { registered };
2115
+ }
2116
+
2117
+ async function startMcpServer() {
691
2118
  const server = new McpServer(
692
2119
  { name: SERVER_NAME, version: SERVER_VERSION },
693
2120
  { capabilities: { logging: {} } }
694
2121
  );
695
-
696
- registerCoreTools(server);
697
- const catalog = loadCatalog();
698
- const methodStats = registerCatalogMethodTools(server, catalog);
699
-
700
- const transport = new StdioServerTransport();
701
- await server.connect(transport);
702
-
703
- const catalogCount =
704
- catalog && catalog.totals && typeof catalog.totals.methods === "number"
705
- ? catalog.totals.methods
706
- : Array.isArray(catalog.methods)
707
- ? catalog.methods.length
708
- : 0;
709
-
2122
+
2123
+ registerCoreTools(server);
2124
+ const catalog = loadCatalog();
2125
+ const methodStats = registerCatalogMethodTools(server, catalog);
2126
+
2127
+ const transport = new StdioServerTransport();
2128
+ await server.connect(transport);
2129
+
2130
+ const catalogCount =
2131
+ catalog && catalog.totals && typeof catalog.totals.methods === "number"
2132
+ ? catalog.totals.methods
2133
+ : Array.isArray(catalog.methods)
2134
+ ? catalog.methods.length
2135
+ : 0;
2136
+
710
2137
  console.error(
711
2138
  `[${SERVER_NAME}] connected via stdio | catalog_methods=${catalogCount} | method_tools_registered=${methodStats.registered}`
712
2139
  );
713
2140
  }
714
2141
 
715
- main().catch((error) => {
2142
+ async function runEntryPoint() {
2143
+ const [firstArg, ...rest] = process.argv.slice(2);
2144
+ const command = (firstArg || "").toLowerCase();
2145
+ if (command === "oauth") {
2146
+ await runOauthCli(rest);
2147
+ return;
2148
+ }
2149
+ if (command === "gateway") {
2150
+ await runGatewayCli(rest);
2151
+ return;
2152
+ }
2153
+ if (command === "onboard") {
2154
+ await runOnboardCli(rest);
2155
+ return;
2156
+ }
2157
+ if (!command) {
2158
+ const onboarded = await runAutoOnboardingIfPossible();
2159
+ if (onboarded) return;
2160
+ }
2161
+ await startMcpServer();
2162
+ }
2163
+
2164
+ runEntryPoint().catch((error) => {
716
2165
  console.error(`[${SERVER_NAME}] fatal error:`, error);
717
2166
  process.exit(1);
718
2167
  });