slack-max-api-mcp 1.0.8 → 1.0.9

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.
@@ -6,7 +6,6 @@ const http = require("node:http");
6
6
  const path = require("node:path");
7
7
  const crypto = require("node:crypto");
8
8
  const { spawn } = require("node:child_process");
9
- const { Agent, fetch: undiciFetch } = require("undici");
10
9
  const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
11
10
  const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
12
11
  const { z } = require("zod");
@@ -14,20 +13,28 @@ const { z } = require("zod");
14
13
  const SERVER_NAME = "slack-max-api-mcp";
15
14
  const SERVER_VERSION = "2.0.0";
16
15
 
17
- const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
18
-
16
+ const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
17
+
19
18
  const CATALOG_PATH =
20
19
  process.env.SLACK_CATALOG_PATH || path.join(process.cwd(), "data", "slack-catalog.json");
21
20
  const METHOD_TOOL_PREFIX = process.env.SLACK_METHOD_TOOL_PREFIX || "slack_method";
22
- const ENABLE_METHOD_TOOLS = process.env.SLACK_ENABLE_METHOD_TOOLS !== "false";
23
- const MAX_METHOD_TOOLS = Number(process.env.SLACK_MAX_METHOD_TOOLS || 0);
21
+ const TOOL_EXPOSURE_MODE = normalizeToolExposureMode(process.env.SLACK_TOOL_EXPOSURE_MODE);
22
+ const ENABLE_METHOD_TOOLS = parseBooleanEnv(
23
+ process.env.SLACK_ENABLE_METHOD_TOOLS,
24
+ TOOL_EXPOSURE_MODE === "legacy"
25
+ );
26
+ const MAX_METHOD_TOOLS = parseNumberEnv(
27
+ process.env.SLACK_MAX_METHOD_TOOLS,
28
+ TOOL_EXPOSURE_MODE === "legacy" ? 0 : 50
29
+ );
30
+ const SMART_COMPAT_CORE_TOOLS = parseBooleanEnv(
31
+ process.env.SLACK_SMART_COMPAT_CORE_TOOLS,
32
+ true
33
+ );
24
34
  const ENV_EXAMPLE_PATH = path.join(process.cwd(), ".env.example");
25
35
  const TOKEN_STORE_PATH =
26
36
  process.env.SLACK_TOKEN_STORE_PATH ||
27
37
  path.join(os.homedir(), ".slack-max-api-mcp", "tokens.json");
28
- const CLIENT_CONFIG_PATH =
29
- process.env.SLACK_CLIENT_CONFIG_PATH ||
30
- path.join(os.homedir(), ".slack-max-api-mcp", "client.json");
31
38
  const ALLOW_ENV_EXAMPLE_FALLBACK = process.env.SLACK_ALLOW_ENV_EXAMPLE_FALLBACK === "true";
32
39
  const OAUTH_CALLBACK_HOST = process.env.SLACK_OAUTH_CALLBACK_HOST || "127.0.0.1";
33
40
  const OAUTH_CALLBACK_PORT = Number(process.env.SLACK_OAUTH_CALLBACK_PORT || 8787);
@@ -39,54 +46,37 @@ const DEFAULT_OAUTH_USER_SCOPES =
39
46
  process.env.SLACK_OAUTH_USER_SCOPES ||
40
47
  "search:read,channels:read,groups:read,channels:history,groups:history";
41
48
  const RETRYABLE_TOKEN_ERRORS = new Set(["not_allowed_token_type", "missing_scope"]);
42
- const GATEWAY_API_KEY = process.env.SLACK_GATEWAY_API_KEY || "";
43
- const GATEWAY_PROFILE = process.env.SLACK_GATEWAY_PROFILE || "";
44
- const GATEWAY_HOST = process.env.SLACK_GATEWAY_HOST || "127.0.0.1";
45
- const GATEWAY_PORT = Number(process.env.SLACK_GATEWAY_PORT || 8790);
46
- const GATEWAY_PUBLIC_BASE_URL =
47
- process.env.SLACK_GATEWAY_PUBLIC_BASE_URL || `http://${GATEWAY_HOST}:${GATEWAY_PORT}`;
48
- const GATEWAY_ALLOW_PUBLIC = process.env.SLACK_GATEWAY_ALLOW_PUBLIC === "true";
49
- const GATEWAY_SHARED_SECRET = process.env.SLACK_GATEWAY_SHARED_SECRET || GATEWAY_API_KEY;
50
- const GATEWAY_CLIENT_API_KEY =
51
- process.env.SLACK_GATEWAY_CLIENT_API_KEY || GATEWAY_API_KEY || GATEWAY_SHARED_SECRET;
52
- const GATEWAY_PUBLIC_ONBOARD_ENABLED = process.env.SLACK_GATEWAY_PUBLIC_ONBOARD === "true";
53
- const GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY =
54
- process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY === "true";
55
- const GATEWAY_PUBLIC_ONBOARD_API_KEY = process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY || "";
56
- const GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX =
57
- process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX || "auto";
58
- const GATEWAY_STATE_TTL_MS = Number(process.env.SLACK_GATEWAY_STATE_TTL_MS || 15 * 60 * 1000);
59
- const INVITE_TOKEN_DEFAULT_DAYS = Number(process.env.SLACK_INVITE_TOKEN_DEFAULT_DAYS || 7);
60
- const AUTO_ONBOARD_ENABLED = process.env.SLACK_AUTO_ONBOARD !== "false";
61
- const DEFAULT_TEAM_GATEWAY_URL =
62
- process.env.SLACK_DEFAULT_TEAM_GATEWAY_URL || "https://43.202.54.65.sslip.io";
63
- const DEFAULT_TEAM_GATEWAY_INSECURE_TLS =
64
- process.env.SLACK_DEFAULT_TEAM_GATEWAY_INSECURE_TLS !== "false";
65
- const GATEWAY_INSECURE_TLS =
66
- process.env.SLACK_GATEWAY_INSECURE_TLS === "true"
67
- ? true
68
- : process.env.SLACK_GATEWAY_INSECURE_TLS === "false"
69
- ? false
70
- : null;
71
- const AUTO_ONBOARD_GATEWAY =
72
- process.env.SLACK_AUTO_ONBOARD_GATEWAY ||
73
- process.env.SLACK_ONBOARD_GATEWAY_URL ||
74
- DEFAULT_TEAM_GATEWAY_URL;
75
- const AUTO_ONBOARD_PROFILE = process.env.SLACK_AUTO_ONBOARD_PROFILE || "";
76
- const AUTO_ONBOARD_TOKEN = process.env.SLACK_AUTO_ONBOARD_TOKEN || process.env.SLACK_ONBOARD_TOKEN || "";
77
- const AUTO_ONBOARD_URL = process.env.SLACK_AUTO_ONBOARD_URL || process.env.SLACK_ONBOARD_URL || "";
78
- const AUTO_ONBOARD_PROFILE_PREFIX = process.env.SLACK_AUTO_ONBOARD_PROFILE_PREFIX || "auto";
79
- const ONBOARD_PACKAGE_SPEC =
80
- process.env.SLACK_ONBOARD_PACKAGE_SPEC ||
81
- process.env.SLACK_ONBOARD_INSTALL_SPEC ||
82
- "slack-max-api-mcp@latest";
83
- const ONBOARD_SKIP_TLS_VERIFY = process.env.SLACK_ONBOARD_SKIP_TLS_VERIFY === "true";
84
- const INSECURE_TLS_DISPATCHER = new Agent({
85
- connect: {
86
- rejectUnauthorized: false,
87
- },
88
- });
89
-
49
+ const DEFAULT_ONBOARD_SERVER_URL = "https://43.202.54.65.sslip.io";
50
+ const ONBOARD_SERVER_URL = process.env.SLACK_ONBOARD_SERVER_URL || DEFAULT_ONBOARD_SERVER_URL;
51
+ const ONBOARD_SERVER_HOST = process.env.SLACK_ONBOARD_SERVER_HOST || "127.0.0.1";
52
+ const ONBOARD_SERVER_PORT = Number(process.env.SLACK_ONBOARD_SERVER_PORT || 8790);
53
+ const ONBOARD_PUBLIC_BASE_URL =
54
+ process.env.SLACK_ONBOARD_PUBLIC_BASE_URL || `http://${ONBOARD_SERVER_HOST}:${ONBOARD_SERVER_PORT}`;
55
+ const ONBOARD_CALLBACK_PATH = process.env.SLACK_ONBOARD_SERVER_CALLBACK_PATH || OAUTH_CALLBACK_PATH;
56
+ const ONBOARD_CLAIM_TTL_MS = Number(process.env.SLACK_ONBOARD_CLAIM_TTL_MS || 10 * 60 * 1000);
57
+ const ONBOARD_POLL_INTERVAL_MS = Number(process.env.SLACK_ONBOARD_POLL_INTERVAL_MS || 2000);
58
+ const ONBOARD_TIMEOUT_MS = Number(process.env.SLACK_ONBOARD_TIMEOUT_MS || 5 * 60 * 1000);
59
+ function parseBooleanEnv(rawValue, defaultValue) {
60
+ if (rawValue === undefined || rawValue === null || rawValue === "") return defaultValue;
61
+ const normalized = String(rawValue).trim().toLowerCase();
62
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
63
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
64
+ return defaultValue;
65
+ }
66
+
67
+ function parseNumberEnv(rawValue, defaultValue) {
68
+ if (rawValue === undefined || rawValue === null || rawValue === "") return defaultValue;
69
+ const parsed = Number(rawValue);
70
+ if (!Number.isFinite(parsed)) return defaultValue;
71
+ return parsed;
72
+ }
73
+
74
+ function normalizeToolExposureMode(rawValue) {
75
+ const normalized = String(rawValue || "smart").trim().toLowerCase();
76
+ if (normalized === "legacy") return "legacy";
77
+ return "smart";
78
+ }
79
+
90
80
  function parseSimpleEnvFile(filePath) {
91
81
  if (!fs.existsSync(filePath)) return {};
92
82
 
@@ -115,63 +105,11 @@ function parseScopeList(raw) {
115
105
  return [...new Set(String(raw).split(",").map((part) => part.trim()).filter(Boolean))];
116
106
  }
117
107
 
118
- function normalizeOnboardNamePart(value, fallback) {
119
- const normalized = String(value || "")
120
- .trim()
121
- .toLowerCase()
122
- .replace(/[^a-z0-9_-]+/g, "-")
123
- .replace(/^-+|-+$/g, "");
124
- if (!normalized) return fallback;
125
- return normalized;
126
- }
127
-
128
- function createAutoOnboardProfileName(prefix = "auto") {
129
- let username = "user";
130
- try {
131
- username = os.userInfo().username || process.env.USERNAME || process.env.USER || "user";
132
- } catch {
133
- username = process.env.USERNAME || process.env.USER || "user";
134
- }
135
- const host = os.hostname() || "host";
136
- const profilePrefix = normalizeOnboardNamePart(prefix, "auto");
137
- const userPart = normalizeOnboardNamePart(username, "user");
138
- const hostPart = normalizeOnboardNamePart(host, "host");
139
- const rand = crypto.randomBytes(3).toString("hex");
140
- return `${profilePrefix}-${userPart}-${hostPart}-${rand}`.slice(0, 80);
141
- }
142
-
143
108
  function ensureParentDirectory(filePath) {
144
109
  const dirPath = path.dirname(filePath);
145
110
  fs.mkdirSync(dirPath, { recursive: true });
146
111
  }
147
112
 
148
- function normalizeBaseUrl(url) {
149
- return String(url || "").trim().replace(/\/+$/, "");
150
- }
151
-
152
- function normalizeUrlOrigin(url) {
153
- try {
154
- const parsed = new URL(String(url || "").trim());
155
- return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, "");
156
- } catch {
157
- return normalizeBaseUrl(url);
158
- }
159
- }
160
-
161
- function shouldUseInsecureGatewayTls(url) {
162
- if (!url) return false;
163
- if (GATEWAY_INSECURE_TLS !== null) return GATEWAY_INSECURE_TLS;
164
- if (!DEFAULT_TEAM_GATEWAY_INSECURE_TLS) return false;
165
- return normalizeUrlOrigin(url) === normalizeUrlOrigin(DEFAULT_TEAM_GATEWAY_URL);
166
- }
167
-
168
- async function fetchWithOptionalInsecureGatewayTls(url, options) {
169
- if (!shouldUseInsecureGatewayTls(url)) {
170
- return undiciFetch(url, options);
171
- }
172
- return undiciFetch(url, { ...(options || {}), dispatcher: INSECURE_TLS_DISPATCHER });
173
- }
174
-
175
113
  function emptyTokenStore() {
176
114
  return { version: 1, default_profile: null, profiles: {} };
177
115
  }
@@ -201,50 +139,6 @@ function saveTokenStore(store) {
201
139
  fs.writeFileSync(TOKEN_STORE_PATH, JSON.stringify(normalizeTokenStore(store), null, 2), "utf8");
202
140
  }
203
141
 
204
- function emptyClientConfig() {
205
- return {
206
- version: 1,
207
- gateway_url: "",
208
- gateway_api_key: "",
209
- profile: "",
210
- updated_at: "",
211
- };
212
- }
213
-
214
- function normalizeClientConfig(value) {
215
- if (!value || typeof value !== "object") return emptyClientConfig();
216
- return { ...emptyClientConfig(), ...value };
217
- }
218
-
219
- function loadClientConfig() {
220
- if (!fs.existsSync(CLIENT_CONFIG_PATH)) return emptyClientConfig();
221
- try {
222
- const parsed = JSON.parse(fs.readFileSync(CLIENT_CONFIG_PATH, "utf8"));
223
- return normalizeClientConfig(parsed);
224
- } catch {
225
- return emptyClientConfig();
226
- }
227
- }
228
-
229
- function saveClientConfig(config) {
230
- ensureParentDirectory(CLIENT_CONFIG_PATH);
231
- fs.writeFileSync(CLIENT_CONFIG_PATH, JSON.stringify(normalizeClientConfig(config), null, 2), "utf8");
232
- }
233
-
234
- function getRuntimeGatewayConfig() {
235
- const config = loadClientConfig();
236
- return {
237
- url: (process.env.SLACK_GATEWAY_URL || config.gateway_url || "").replace(/\/+$/, ""),
238
- apiKey: process.env.SLACK_GATEWAY_API_KEY || config.gateway_api_key || "",
239
- profile:
240
- process.env.SLACK_PROFILE ||
241
- process.env.SLACK_GATEWAY_PROFILE ||
242
- config.profile ||
243
- GATEWAY_PROFILE ||
244
- "",
245
- };
246
- }
247
-
248
142
  function resolveTokenStoreProfileBySelector(store, selector) {
249
143
  const profiles = store?.profiles || {};
250
144
  const keys = Object.keys(profiles);
@@ -311,7 +205,7 @@ function getSlackTokenCandidates(tokenOverride, options = {}) {
311
205
  const tokenStore = loadTokenStore();
312
206
  const activeProfile = resolveTokenStoreProfileBySelector(
313
207
  tokenStore,
314
- options.profileSelector || process.env.SLACK_PROFILE || GATEWAY_PROFILE
208
+ options.profileSelector || process.env.SLACK_PROFILE
315
209
  );
316
210
  if (activeProfile) {
317
211
  appendCandidateTokens(
@@ -384,100 +278,6 @@ function toRecordObject(value) {
384
278
  return value;
385
279
  }
386
280
 
387
- function buildGatewayAuthHeaders(apiKey) {
388
- const headers = { "Content-Type": "application/json; charset=utf-8" };
389
- if (apiKey) {
390
- headers.Authorization = `Bearer ${apiKey}`;
391
- headers["X-API-Key"] = apiKey;
392
- }
393
- return headers;
394
- }
395
-
396
- async function callSlackApiViaGateway(method, params = {}, tokenOverride, options = {}) {
397
- const runtimeGateway = getRuntimeGatewayConfig();
398
- if (!runtimeGateway.url) {
399
- throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
400
- }
401
-
402
- const gatewayCallUrl = `${runtimeGateway.url}/api/slack/call`;
403
- const response = await fetchWithOptionalInsecureGatewayTls(gatewayCallUrl, {
404
- method: "POST",
405
- headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
406
- body: JSON.stringify({
407
- method,
408
- params,
409
- token_override: tokenOverride || undefined,
410
- profile_selector: options.profileSelector || runtimeGateway.profile || undefined,
411
- preferred_token_type: options.preferredTokenType || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
412
- }),
413
- });
414
-
415
- const text = await response.text();
416
- let body;
417
- try {
418
- body = JSON.parse(text);
419
- } catch {
420
- throw new Error(`Gateway returned non-JSON for ${method} (HTTP ${response.status}).`);
421
- }
422
-
423
- if (!response.ok) {
424
- const error = new Error(
425
- `Gateway HTTP ${response.status} for ${method}: ${body?.error || body?.message || "unknown_error"}`
426
- );
427
- error.http_status = response.status;
428
- error.slack_error = body?.slack_error || body?.error || "gateway_error";
429
- error.needed = body?.needed;
430
- error.provided = body?.provided;
431
- error.token_source = body?.token_source || "gateway";
432
- throw error;
433
- }
434
-
435
- if (!body?.ok) {
436
- const error = new Error(`Gateway call failed for ${method}: ${body?.error || "unknown_error"}`);
437
- error.slack_error = body?.slack_error || body?.error || "gateway_error";
438
- error.needed = body?.needed;
439
- error.provided = body?.provided;
440
- error.token_source = body?.token_source || "gateway";
441
- throw error;
442
- }
443
-
444
- return body.data;
445
- }
446
-
447
- async function slackHttpViaGateway(input) {
448
- const runtimeGateway = getRuntimeGatewayConfig();
449
- if (!runtimeGateway.url) {
450
- throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
451
- }
452
-
453
- const gatewayHttpUrl = `${runtimeGateway.url}/api/slack/http`;
454
- const response = await fetchWithOptionalInsecureGatewayTls(gatewayHttpUrl, {
455
- method: "POST",
456
- headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
457
- body: JSON.stringify({
458
- ...input,
459
- profile_selector: input.profile_selector || runtimeGateway.profile || undefined,
460
- preferred_token_type: input.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
461
- }),
462
- });
463
-
464
- const text = await response.text();
465
- let body;
466
- try {
467
- body = JSON.parse(text);
468
- } catch {
469
- throw new Error(`Gateway returned non-JSON for HTTP proxy (HTTP ${response.status}).`);
470
- }
471
-
472
- if (!response.ok) {
473
- throw new Error(`Gateway HTTP ${response.status}: ${body?.error || "gateway_error"}`);
474
- }
475
- if (!body?.ok) {
476
- throw new Error(`Gateway HTTP proxy failed: ${body?.error || "gateway_error"}`);
477
- }
478
- return body.data;
479
- }
480
-
481
281
  async function callSlackApiWithToken(method, params = {}, token, tokenSource) {
482
282
  const url = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/${method}`;
483
283
 
@@ -548,21 +348,16 @@ async function callSlackApiWithCandidates(method, params, candidates) {
548
348
  }
549
349
 
550
350
  async function callSlackApi(method, params = {}, tokenOverride, options = {}) {
551
- const runtimeGateway = getRuntimeGatewayConfig();
552
- if (runtimeGateway.url) {
553
- return callSlackApiViaGateway(method, params, tokenOverride, options);
554
- }
555
-
556
351
  const candidates = getSlackTokenCandidates(tokenOverride, options);
557
352
  if (candidates.length === 0) {
558
353
  throw new Error(
559
- "Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or run `slack-max-api-mcp oauth login`."
354
+ "Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or run slack-max-api-mcp oauth login."
560
355
  );
561
356
  }
562
357
 
563
358
  return callSlackApiWithCandidates(method, params, candidates);
564
359
  }
565
-
360
+
566
361
  function successResult(payload) {
567
362
  return {
568
363
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -621,118 +416,6 @@ function parseCliArgs(argv) {
621
416
  return { options, positionals };
622
417
  }
623
418
 
624
- function base64UrlEncodeString(value) {
625
- return Buffer.from(value, "utf8")
626
- .toString("base64")
627
- .replace(/\+/g, "-")
628
- .replace(/\//g, "_")
629
- .replace(/=+$/g, "");
630
- }
631
-
632
- function base64UrlDecodeToString(value) {
633
- const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
634
- return Buffer.from(padded, "base64").toString("utf8");
635
- }
636
-
637
- function hmacSign(text, secret) {
638
- return crypto
639
- .createHmac("sha256", secret)
640
- .update(text)
641
- .digest("base64")
642
- .replace(/\+/g, "-")
643
- .replace(/\//g, "_")
644
- .replace(/=+$/g, "");
645
- }
646
-
647
- function createSignedInviteToken(payload, secret) {
648
- const encodedPayload = base64UrlEncodeString(JSON.stringify(payload));
649
- const signature = hmacSign(encodedPayload, secret);
650
- return `${encodedPayload}.${signature}`;
651
- }
652
-
653
- function parseAndVerifyInviteToken(token, secret) {
654
- const [encodedPayload, signature] = String(token || "").split(".", 2);
655
- if (!encodedPayload || !signature) {
656
- throw new Error("Invalid invite token format.");
657
- }
658
- const expected = hmacSign(encodedPayload, secret);
659
- const expectedBuf = Buffer.from(expected, "utf8");
660
- const sigBuf = Buffer.from(signature, "utf8");
661
- if (expectedBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expectedBuf, sigBuf)) {
662
- throw new Error("Invalid invite token signature.");
663
- }
664
- let payload;
665
- try {
666
- payload = JSON.parse(base64UrlDecodeToString(encodedPayload));
667
- } catch {
668
- throw new Error("Invalid invite token payload.");
669
- }
670
- if (typeof payload !== "object" || !payload) {
671
- throw new Error("Invalid invite token payload object.");
672
- }
673
- if (!payload.exp || Number(payload.exp) < Date.now()) {
674
- throw new Error("Invite token expired.");
675
- }
676
- return payload;
677
- }
678
-
679
- function requireGatewayInviteSecret() {
680
- if (!GATEWAY_SHARED_SECRET) {
681
- throw new Error("Set SLACK_GATEWAY_SHARED_SECRET before using gateway invite/onboarding.");
682
- }
683
- return GATEWAY_SHARED_SECRET;
684
- }
685
-
686
- function isInteractiveTerminal() {
687
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
688
- }
689
-
690
- function hasAnyLocalAuthMaterial() {
691
- const runtimeGateway = getRuntimeGatewayConfig();
692
- if (runtimeGateway.url) return true;
693
- const tokenCandidates = getSlackTokenCandidates(undefined, {
694
- includeEnvTokens: true,
695
- includeTokenStore: true,
696
- });
697
- return tokenCandidates.length > 0;
698
- }
699
-
700
- async function runAutoOnboardingIfPossible() {
701
- if (!AUTO_ONBOARD_ENABLED) return false;
702
- if (!isInteractiveTerminal()) return false;
703
- if (hasAnyLocalAuthMaterial()) return false;
704
-
705
- if (AUTO_ONBOARD_URL) {
706
- const opened = openExternalUrl(AUTO_ONBOARD_URL);
707
- if (!opened) {
708
- console.log(`[auto-onboard] Open this URL in browser:\n${AUTO_ONBOARD_URL}`);
709
- } else {
710
- console.log("[auto-onboard] Browser opened for onboarding.");
711
- }
712
- return true;
713
- }
714
-
715
- if (AUTO_ONBOARD_GATEWAY && AUTO_ONBOARD_TOKEN) {
716
- const args = ["--gateway", AUTO_ONBOARD_GATEWAY, "--token", AUTO_ONBOARD_TOKEN];
717
- if (AUTO_ONBOARD_PROFILE) args.push("--profile", AUTO_ONBOARD_PROFILE);
718
- await runOnboardStart(args);
719
- return true;
720
- }
721
-
722
- if (AUTO_ONBOARD_GATEWAY) {
723
- const args = ["--gateway", AUTO_ONBOARD_GATEWAY];
724
- if (AUTO_ONBOARD_PROFILE) {
725
- args.push("--profile", AUTO_ONBOARD_PROFILE);
726
- } else if (AUTO_ONBOARD_PROFILE_PREFIX) {
727
- args.push("--profile", createAutoOnboardProfileName(AUTO_ONBOARD_PROFILE_PREFIX));
728
- }
729
- await runOnboardStart(args);
730
- return true;
731
- }
732
-
733
- return false;
734
- }
735
-
736
419
  function printOauthHelp() {
737
420
  const lines = [
738
421
  "Slack Max OAuth helper",
@@ -1060,118 +743,8 @@ async function runOauthCli(args) {
1060
743
  throw new Error(`Unknown oauth command: ${subcommand}`);
1061
744
  }
1062
745
 
1063
- function printOnboardHelp() {
1064
- const lines = [
1065
- "Slack Max onboarding helper",
1066
- "",
1067
- "Usage:",
1068
- " slack-max-api-mcp onboard run [--gateway https://gateway.example.com] [--token <invite_token>]",
1069
- " [--profile NAME] [--team T123] [--scope a,b] [--user-scope c,d]",
1070
- " slack-max-api-mcp onboard quick [--gateway https://gateway.example.com]",
1071
- " slack-max-api-mcp onboard help",
1072
- "",
1073
- `Default gateway (if omitted): ${DEFAULT_TEAM_GATEWAY_URL}`,
1074
- "If --token is omitted, it uses gateway public onboarding endpoint (/onboard/bootstrap).",
1075
- "This command writes local client config and opens the Slack OAuth approval page automatically.",
1076
- ];
1077
- console.log(lines.join("\n"));
1078
- }
1079
-
1080
- async function runOnboardStart(args) {
1081
- const { options } = parseCliArgs(args);
1082
- const gateway = normalizeBaseUrl(options.gateway || options.url || DEFAULT_TEAM_GATEWAY_URL);
1083
- const token = String(options.token || "");
1084
- if (!gateway) {
1085
- throw new Error(
1086
- "Usage: slack-max-api-mcp onboard run [--gateway <url>] [--token <invite_token>] [--profile <name>]"
1087
- );
1088
- }
1089
-
1090
- const requestedProfile =
1091
- String(options.profile || "").trim() || createAutoOnboardProfileName(AUTO_ONBOARD_PROFILE_PREFIX);
1092
- const requestedTeam = String(options.team || "").trim();
1093
- const requestedScope = parseScopeList(options.scope || "").join(",");
1094
- const requestedUserScope = parseScopeList(options["user-scope"] || options.user_scope || "").join(",");
1095
-
1096
- const onboardingUrl = token
1097
- ? `${gateway}/onboard/resolve?token=${encodeURIComponent(token)}`
1098
- : (() => {
1099
- const params = new URLSearchParams();
1100
- if (requestedProfile) params.set("profile", requestedProfile);
1101
- if (requestedTeam) params.set("team", requestedTeam);
1102
- if (requestedScope) params.set("scope", requestedScope);
1103
- if (requestedUserScope) params.set("user_scope", requestedUserScope);
1104
- const query = params.toString();
1105
- return `${gateway}/onboard/bootstrap${query ? `?${query}` : ""}`;
1106
- })();
1107
-
1108
- const response = await fetchWithOptionalInsecureGatewayTls(onboardingUrl, {
1109
- method: "GET",
1110
- headers: { Accept: "application/json" },
1111
- });
1112
-
1113
- const text = await response.text();
1114
- let data;
1115
- try {
1116
- data = JSON.parse(text);
1117
- } catch {
1118
- throw new Error(`Onboarding response was non-JSON (HTTP ${response.status}).`);
1119
- }
1120
-
1121
- if (!response.ok || !data?.ok) {
1122
- if (!token && response.status === 404) {
1123
- throw new Error("Onboarding failed: public onboarding is disabled on gateway (enable SLACK_GATEWAY_PUBLIC_ONBOARD=true).");
1124
- }
1125
- throw new Error(`Onboarding failed: ${data?.error || `http_${response.status}`}`);
1126
- }
1127
-
1128
- const resolvedGatewayUrl = String(data.gateway_url || gateway).replace(/\/+$/, "");
1129
- const resolvedApiKey = String(data.gateway_api_key || "");
1130
- const profile = String(data.profile || requestedProfile || "");
1131
- const oauthStartUrl = String(data.oauth_start_url || "");
1132
-
1133
- if (data.requires_gateway_api_key && !resolvedApiKey) {
1134
- throw new Error(
1135
- "Gateway requires API key but onboarding response did not provide one. Enable public gateway access or set SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY."
1136
- );
1137
- }
1138
-
1139
- saveClientConfig({
1140
- version: 1,
1141
- gateway_url: resolvedGatewayUrl,
1142
- gateway_api_key: resolvedApiKey,
1143
- profile,
1144
- updated_at: new Date().toISOString(),
1145
- });
1146
-
1147
- if (oauthStartUrl) {
1148
- const opened = openExternalUrl(oauthStartUrl);
1149
- if (!opened) {
1150
- console.log(`[onboard] Open this URL in browser:\n${oauthStartUrl}`);
1151
- }
1152
- }
1153
-
1154
- console.log(`[onboard] client config saved: ${CLIENT_CONFIG_PATH}`);
1155
- console.log(`[onboard] gateway: ${resolvedGatewayUrl}`);
1156
- if (profile) console.log(`[onboard] profile: ${profile}`);
1157
- if (data.mode === "public_onboard") {
1158
- console.log("[onboard] mode: public_onboard (tokenless)");
1159
- }
1160
- console.log("[onboard] Next: approve in browser, then use Codex MCP as usual.");
1161
- }
1162
-
1163
- async function runOnboardCli(args) {
1164
- const subcommand = (args[0] || "help").toLowerCase();
1165
- const rest = args.slice(1);
1166
- if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1167
- printOnboardHelp();
1168
- return;
1169
- }
1170
- if (subcommand === "run" || subcommand === "start" || subcommand === "quick") {
1171
- await runOnboardStart(rest);
1172
- return;
1173
- }
1174
- throw new Error(`Unknown onboard command: ${subcommand}`);
746
+ function sleep(ms) {
747
+ return new Promise((resolve) => setTimeout(resolve, ms));
1175
748
  }
1176
749
 
1177
750
  function sendText(res, statusCode, text) {
@@ -1184,355 +757,242 @@ function sendJson(res, statusCode, payload) {
1184
757
  res.end(JSON.stringify(payload, null, 2));
1185
758
  }
1186
759
 
1187
- async function readRequestText(req, maxBytes = 1024 * 1024) {
1188
- return new Promise((resolve, reject) => {
1189
- const chunks = [];
1190
- let total = 0;
1191
- req.on("data", (chunk) => {
1192
- total += chunk.length;
1193
- if (total > maxBytes) {
1194
- reject(new Error("Request body too large."));
1195
- req.destroy();
1196
- return;
1197
- }
1198
- chunks.push(chunk);
1199
- });
1200
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1201
- req.on("error", (error) => reject(error));
1202
- });
1203
- }
1204
-
1205
- async function readRequestJson(req, maxBytes) {
1206
- const text = await readRequestText(req, maxBytes);
1207
- if (!text.trim()) return {};
1208
- try {
1209
- return JSON.parse(text);
1210
- } catch {
1211
- throw new Error("Invalid JSON body.");
1212
- }
760
+ function isClaimSessionExpired(session) {
761
+ return !session || Date.now() > Number(session.expires_at || 0);
1213
762
  }
1214
763
 
1215
- function getRequestApiKey(req) {
1216
- const authHeader = req.headers.authorization || "";
1217
- if (authHeader.toLowerCase().startsWith("bearer ")) {
1218
- return authHeader.slice(7).trim();
764
+ function cleanupExpiredClaimSessions(claimSessions, stateToClaim) {
765
+ for (const [claimToken, session] of claimSessions.entries()) {
766
+ if (!isClaimSessionExpired(session)) continue;
767
+ claimSessions.delete(claimToken);
768
+ if (session.state) stateToClaim.delete(session.state);
1219
769
  }
1220
- const xApiKey = req.headers["x-api-key"];
1221
- return typeof xApiKey === "string" ? xApiKey.trim() : "";
1222
- }
1223
-
1224
- function isGatewayAuthorized(req) {
1225
- if (GATEWAY_ALLOW_PUBLIC) return true;
1226
- const allowedKeys = [GATEWAY_SHARED_SECRET, GATEWAY_CLIENT_API_KEY].filter(Boolean);
1227
- if (allowedKeys.length === 0) return false;
1228
- const provided = getRequestApiKey(req);
1229
- return Boolean(provided && allowedKeys.includes(provided));
1230
770
  }
1231
771
 
1232
- function requireGatewayClientCredentials() {
1233
- const clientId = process.env.SLACK_CLIENT_ID || "";
1234
- const clientSecret = process.env.SLACK_CLIENT_SECRET || "";
1235
- if (!clientId || !clientSecret) {
1236
- throw new Error("Gateway OAuth requires SLACK_CLIENT_ID and SLACK_CLIENT_SECRET on the gateway server.");
772
+ async function fetchJsonResponse(url, options, label) {
773
+ const response = await fetch(url, options);
774
+ const rawText = await response.text();
775
+ let data;
776
+ try {
777
+ data = JSON.parse(rawText);
778
+ } catch {
779
+ throw new Error(`${label} returned non-JSON (HTTP ${response.status}).`);
1237
780
  }
1238
- return { clientId, clientSecret };
1239
- }
1240
-
1241
- function profileSummariesFromStore(store) {
1242
- const summaries = [];
1243
- for (const [key, profile] of Object.entries(store.profiles || {})) {
1244
- summaries.push({
1245
- key,
1246
- profile_name: profile.profile_name || "",
1247
- team_id: profile.team_id || "",
1248
- team_name: profile.team_name || "",
1249
- authed_user_id: profile.authed_user_id || "",
1250
- has_bot_token: Boolean(profile.bot_token),
1251
- has_user_token: Boolean(profile.user_token),
1252
- updated_at: profile.updated_at || null,
1253
- is_default: store.default_profile === key,
1254
- });
781
+ if (!response.ok || !data?.ok) {
782
+ throw new Error(`${label} failed: ${data?.error || `http_${response.status}`}`);
1255
783
  }
1256
- return summaries;
1257
- }
1258
-
1259
- function buildGatewayRedirectUri() {
1260
- const url = new URL(OAUTH_CALLBACK_PATH, `${GATEWAY_PUBLIC_BASE_URL.replace(/\/+$/, "")}/`);
1261
- return url.toString();
784
+ return data;
1262
785
  }
1263
786
 
1264
- function parseScopesFromQuery(searchParams, key, fallback) {
1265
- const value = searchParams.get(key);
1266
- return parseScopeList(value || fallback);
787
+ function printOnboardHelp() {
788
+ const lines = [
789
+ "Slack Max onboard helper (client-side)",
790
+ "",
791
+ "Usage:",
792
+ " slack-max-api-mcp onboard run",
793
+ " slack-max-api-mcp onboard run --server https://onboard.example.com",
794
+ " [--profile NAME] [--team T123] [--scope a,b] [--user-scope c,d]",
795
+ " slack-max-api-mcp onboard help",
796
+ "",
797
+ "Notes:",
798
+ ` - Default onboard server: ${ONBOARD_SERVER_URL}`,
799
+ " - This command does not require SLACK_CLIENT_SECRET on team PCs.",
800
+ " - It opens browser OAuth via central onboarding server and saves tokens locally.",
801
+ ];
802
+ console.log(lines.join("\n"));
1267
803
  }
1268
804
 
1269
- function buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload) {
1270
- const params = new URLSearchParams();
1271
- if (payload.profile) params.set("profile", payload.profile);
1272
- if (payload.team) params.set("team", payload.team);
1273
- if (payload.scope) params.set("scope", payload.scope);
1274
- if (payload.user_scope) params.set("user_scope", payload.user_scope);
1275
- return `${gatewayBaseUrl.replace(/\/+$/, "")}/oauth/start${params.toString() ? `?${params.toString()}` : ""}`;
805
+ function printOnboardServerHelp() {
806
+ const lines = [
807
+ "Slack Max onboard server (central)",
808
+ "",
809
+ "Usage:",
810
+ " slack-max-api-mcp onboard-server start",
811
+ " [--host 0.0.0.0] [--port 8790] [--public-base-url https://onboard.example.com]",
812
+ " [--callback-path /slack/oauth/callback]",
813
+ " slack-max-api-mcp onboard-server help",
814
+ "",
815
+ "Required env vars (server-side only):",
816
+ " SLACK_CLIENT_ID",
817
+ " SLACK_CLIENT_SECRET",
818
+ "",
819
+ "Optional env vars:",
820
+ " SLACK_ONBOARD_SERVER_HOST, SLACK_ONBOARD_SERVER_PORT",
821
+ " SLACK_ONBOARD_PUBLIC_BASE_URL, SLACK_ONBOARD_SERVER_CALLBACK_PATH",
822
+ " SLACK_ONBOARD_CLAIM_TTL_MS",
823
+ ];
824
+ console.log(lines.join("\n"));
1276
825
  }
1277
826
 
1278
- function buildPublicOnboardPayload(gatewayBaseUrl, params = {}) {
1279
- const profile = String(params.profile || "").trim() || createAutoOnboardProfileName(GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX);
1280
- const team = String(params.team || process.env.SLACK_OAUTH_TEAM_ID || "").trim();
1281
- const scope = parseScopeList(params.scope || DEFAULT_OAUTH_BOT_SCOPES).join(",");
1282
- const userScope = parseScopeList(params.user_scope || DEFAULT_OAUTH_USER_SCOPES).join(",");
1283
- const payload = {
1284
- gateway_url: gatewayBaseUrl,
1285
- gateway_api_key: "",
1286
- profile,
1287
- team,
1288
- scope,
1289
- user_scope: userScope,
1290
- };
1291
- if (GATEWAY_ALLOW_PUBLIC) {
1292
- payload.gateway_api_key = "";
1293
- } else if (GATEWAY_PUBLIC_ONBOARD_API_KEY) {
1294
- payload.gateway_api_key = GATEWAY_PUBLIC_ONBOARD_API_KEY;
1295
- } else if (GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY) {
1296
- payload.gateway_api_key = GATEWAY_CLIENT_API_KEY || "";
827
+ async function runOnboardClient(args) {
828
+ const { options } = parseCliArgs(args);
829
+ const serverBase = String(options.server || ONBOARD_SERVER_URL).trim().replace(/\/+$/, "");
830
+ if (!serverBase) {
831
+ throw new Error("Missing onboard server URL. Use --server or set SLACK_ONBOARD_SERVER_URL.");
1297
832
  }
1298
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1299
- return {
1300
- ok: true,
1301
- mode: "public_onboard",
1302
- gateway_url: payload.gateway_url,
1303
- gateway_api_key: payload.gateway_api_key,
1304
- profile: payload.profile,
1305
- oauth_start_url: oauthStartUrl,
1306
- requires_gateway_api_key: !GATEWAY_ALLOW_PUBLIC,
1307
- };
1308
- }
1309
833
 
1310
- function buildOnboardPowerShellScript({ gatewayBaseUrl, token, profile, team, scope, userScope }) {
1311
- const safeGateway = String(gatewayBaseUrl || "").replace(/'/g, "''");
1312
- const safeToken = String(token || "").replace(/'/g, "''");
1313
- const safeProfile = String(profile || "").replace(/'/g, "''");
1314
- const safeTeam = String(team || "").replace(/'/g, "''");
1315
- const safeScope = String(scope || "").replace(/'/g, "''");
1316
- const safeUserScope = String(userScope || "").replace(/'/g, "''");
1317
- const safePackageSpec = String(ONBOARD_PACKAGE_SPEC || "").replace(/'/g, "''");
1318
- const onboardCommandParts = [`npx -y '${safePackageSpec}' onboard run --gateway '${safeGateway}'`];
1319
- if (safeToken) onboardCommandParts.push(`--token '${safeToken}'`);
1320
- if (safeProfile) onboardCommandParts.push(`--profile '${safeProfile}'`);
1321
- if (safeTeam) onboardCommandParts.push(`--team '${safeTeam}'`);
1322
- if (safeScope) onboardCommandParts.push(`--scope '${safeScope}'`);
1323
- if (safeUserScope) onboardCommandParts.push(`--user-scope '${safeUserScope}'`);
834
+ const requestedProfile = String(options.profile || "").trim();
835
+ const requestedTeam = String(options.team || "").trim();
836
+ const requestedScope = parseScopeList(options.scope || "").join(",");
837
+ const requestedUserScope = parseScopeList(options["user-scope"] || "").join(",");
838
+
839
+ const bootstrapParams = new URLSearchParams();
840
+ if (requestedProfile) bootstrapParams.set("profile", requestedProfile);
841
+ if (requestedTeam) bootstrapParams.set("team", requestedTeam);
842
+ if (requestedScope) bootstrapParams.set("scope", requestedScope);
843
+ if (requestedUserScope) bootstrapParams.set("user_scope", requestedUserScope);
844
+
845
+ const bootstrapUrl = `${serverBase}/onboard/bootstrap${bootstrapParams.toString() ? `?${bootstrapParams.toString()}` : ""}`;
846
+ const bootstrap = await fetchJsonResponse(
847
+ bootstrapUrl,
848
+ { method: "GET", headers: { Accept: "application/json" } },
849
+ "Onboard bootstrap"
850
+ );
1324
851
 
1325
- const lines = [
1326
- "$ErrorActionPreference = 'Stop'",
1327
- "if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { throw 'npx is required. Install Node.js first.' }",
1328
- ];
1329
- if (ONBOARD_SKIP_TLS_VERIFY) {
1330
- lines.push("$env:NODE_TLS_REJECT_UNAUTHORIZED='0'");
852
+ const startUrl = String(bootstrap.onboard_start_url || "");
853
+ const claimToken = String(bootstrap.claim_token || "");
854
+ if (!startUrl || !claimToken) {
855
+ throw new Error("Onboard bootstrap response is missing onboard_start_url or claim_token.");
1331
856
  }
1332
- lines.push(onboardCommandParts.join(" "));
1333
- if (ONBOARD_SKIP_TLS_VERIFY) {
1334
- lines.push("Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue");
857
+
858
+ const opened = openExternalUrl(startUrl);
859
+ if (!opened) {
860
+ console.log(`[onboard] Open this URL in browser:\n${startUrl}`);
861
+ } else {
862
+ console.log("[onboard] Browser opened for OAuth approval.");
1335
863
  }
1336
- return lines.join("\r\n");
1337
- }
1338
864
 
1339
- function createGatewayInviteTokenFromOptions(options = {}) {
1340
- const secret = requireGatewayInviteSecret();
1341
- const profile = String(options.profile || "").trim();
1342
- const team = String(options.team || "").trim();
1343
- const scope = parseScopeList(options.scope || DEFAULT_OAUTH_BOT_SCOPES).join(",");
1344
- const userScope = parseScopeList(options["user-scope"] || options.user_scope || DEFAULT_OAUTH_USER_SCOPES).join(
1345
- ","
1346
- );
1347
- const ttlDays = Math.max(1, Number(options.days || INVITE_TOKEN_DEFAULT_DAYS));
1348
- const gatewayUrl = String(options.gateway || options.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1349
-
1350
- const payload = {
1351
- v: 1,
1352
- exp: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
1353
- gateway_url: gatewayUrl,
1354
- gateway_api_key: String(options["client-api-key"] || options.client_api_key || GATEWAY_CLIENT_API_KEY || ""),
1355
- profile,
1356
- team,
1357
- scope,
1358
- user_scope: userScope,
1359
- };
1360
- const token = createSignedInviteToken(payload, secret);
1361
- return { token, payload };
1362
- }
865
+ const pollIntervalMs = Math.max(500, Number(options["poll-ms"] || ONBOARD_POLL_INTERVAL_MS));
866
+ const timeoutMs = Math.max(30_000, Number(options["timeout-ms"] || ONBOARD_TIMEOUT_MS));
867
+ const deadline = Date.now() + timeoutMs;
868
+ const claimUrl = `${serverBase}/onboard/claim?claim=${encodeURIComponent(claimToken)}`;
1363
869
 
1364
- async function proxySlackHttpRequest(payload) {
1365
- const tokenCandidate = requireSlackTokenCandidate(payload.token_override, {
1366
- profileSelector: payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1367
- preferredTokenType: payload.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
1368
- });
870
+ while (Date.now() < deadline) {
871
+ await sleep(pollIntervalMs);
872
+ const claimData = await fetchJsonResponse(
873
+ claimUrl,
874
+ { method: "GET", headers: { Accept: "application/json" } },
875
+ "Onboard claim"
876
+ );
1369
877
 
1370
- const method = payload.http_method || "GET";
1371
- const endpoint = new URL(payload.url);
1372
- for (const [k, v] of Object.entries(toRecordObject(payload.query))) {
1373
- if (v === undefined || v === null) continue;
1374
- endpoint.searchParams.set(k, String(v));
1375
- }
878
+ if (claimData.status === "pending") {
879
+ continue;
880
+ }
1376
881
 
1377
- const reqHeaders = {
1378
- Authorization: `Bearer ${tokenCandidate.token}`,
1379
- ...toRecordObject(payload.headers),
1380
- };
882
+ if (claimData.status !== "ready") {
883
+ throw new Error(`Unexpected onboard claim status: ${claimData.status || "unknown"}`);
884
+ }
1381
885
 
1382
- let body;
1383
- const formBody = toRecordObject(payload.form_body);
1384
- const jsonBody = toRecordObject(payload.json_body);
1385
- if (Object.keys(formBody).length > 0) {
1386
- reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1387
- body = toUrlEncodedBody(formBody);
1388
- } else if (Object.keys(jsonBody).length > 0) {
1389
- reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1390
- body = JSON.stringify(jsonBody);
1391
- }
886
+ const oauthResponse = claimData.oauth_response;
887
+ if (!oauthResponse || typeof oauthResponse !== "object") {
888
+ throw new Error("Onboard claim result is missing oauth_response.");
889
+ }
1392
890
 
1393
- const res = await fetch(endpoint.toString(), { method, headers: reqHeaders, body });
1394
- const text = await res.text();
1395
- let parsedBody = text;
1396
- try {
1397
- parsedBody = JSON.parse(text);
1398
- } catch {
1399
- // keep text
891
+ const { key, profile } = upsertOauthProfile(oauthResponse, claimData.profile || requestedProfile || "");
892
+ console.log(`[onboard] saved profile: ${profile.profile_name} (${key})`);
893
+ console.log(`[onboard] token store path: ${TOKEN_STORE_PATH}`);
894
+ console.log("[onboard] Next step for MCP clients:");
895
+ console.log(` setx SLACK_PROFILE \"${profile.profile_name}\"`);
896
+ return;
1400
897
  }
1401
898
 
1402
- return {
1403
- url: endpoint.toString(),
1404
- status: res.status,
1405
- ok: res.ok,
1406
- headers: Object.fromEntries(res.headers.entries()),
1407
- body: parsedBody,
1408
- token_source: tokenCandidate.source,
1409
- };
899
+ throw new Error("Timed out waiting for central onboarding completion.");
1410
900
  }
1411
901
 
1412
- async function startGatewayServer() {
1413
- const pendingStates = new Map();
1414
- const callbackPath = OAUTH_CALLBACK_PATH;
1415
- const redirectUri = buildGatewayRedirectUri();
1416
- const gatewayBaseUrl = `${GATEWAY_PUBLIC_BASE_URL.replace(/\/+$/, "")}`;
902
+ async function runOnboardServerStart(args) {
903
+ const { options } = parseCliArgs(args);
904
+ const clientId = options["client-id"] || process.env.SLACK_CLIENT_ID;
905
+ const clientSecret = options["client-secret"] || process.env.SLACK_CLIENT_SECRET;
906
+ if (!clientId || !clientSecret) {
907
+ throw new Error("Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET on onboarding server.");
908
+ }
909
+
910
+ const host = String(options.host || ONBOARD_SERVER_HOST);
911
+ const port = Number(options.port || ONBOARD_SERVER_PORT);
912
+ const callbackPath = String(options["callback-path"] || ONBOARD_CALLBACK_PATH);
913
+ const publicBaseUrl = String(options["public-base-url"] || ONBOARD_PUBLIC_BASE_URL).replace(/\/+$/, "");
914
+ const claimTtlMs = Math.max(60_000, Number(options["claim-ttl-ms"] || ONBOARD_CLAIM_TTL_MS));
915
+ const redirectUri = new URL(callbackPath, `${publicBaseUrl}/`).toString();
916
+
917
+ const claimSessions = new Map();
918
+ const stateToClaim = new Map();
1417
919
 
1418
920
  const server = http.createServer(async (req, res) => {
1419
921
  try {
922
+ cleanupExpiredClaimSessions(claimSessions, stateToClaim);
1420
923
  const method = req.method || "GET";
1421
- const requestUrl = new URL(req.url || "/", `http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
924
+ const requestUrl = new URL(req.url || "/", `http://${host}:${port}`);
1422
925
 
1423
926
  if (method === "GET" && requestUrl.pathname === "/health") {
1424
927
  sendJson(res, 200, {
1425
928
  ok: true,
1426
929
  service: SERVER_NAME,
1427
- mode: "gateway",
1428
- token_store_path: TOKEN_STORE_PATH,
1429
- client_config_path: CLIENT_CONFIG_PATH,
1430
- callback_url: redirectUri,
930
+ mode: "onboard_server",
931
+ public_base_url: publicBaseUrl,
932
+ callback_path: callbackPath,
1431
933
  });
1432
934
  return;
1433
935
  }
1434
936
 
1435
- if (method === "GET" && requestUrl.pathname === "/onboard.ps1") {
1436
- const token = requestUrl.searchParams.get("token") || "";
1437
- let script = "";
1438
- if (token) {
1439
- const secret = requireGatewayInviteSecret();
1440
- const payload = parseAndVerifyInviteToken(token, secret);
1441
- script = buildOnboardPowerShellScript({
1442
- gatewayBaseUrl: payload.gateway_url || gatewayBaseUrl,
1443
- token,
1444
- });
1445
- } else {
1446
- if (!GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1447
- sendJson(res, 404, { ok: false, error: "public_onboard_disabled" });
1448
- return;
1449
- }
1450
- const profile =
1451
- requestUrl.searchParams.get("profile") ||
1452
- createAutoOnboardProfileName(GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX);
1453
- const team = requestUrl.searchParams.get("team") || "";
1454
- const scope = requestUrl.searchParams.get("scope") || "";
1455
- const userScope = requestUrl.searchParams.get("user_scope") || "";
1456
- script = buildOnboardPowerShellScript({
1457
- gatewayBaseUrl,
1458
- profile,
1459
- team,
1460
- scope,
1461
- userScope,
1462
- });
1463
- }
1464
- res.writeHead(200, {
1465
- "Content-Type": "text/plain; charset=utf-8",
1466
- "Cache-Control": "no-store",
1467
- });
1468
- res.end(script);
1469
- return;
1470
- }
1471
-
1472
937
  if (method === "GET" && requestUrl.pathname === "/onboard/bootstrap") {
1473
- if (!GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1474
- sendJson(res, 404, { ok: false, error: "public_onboard_disabled" });
938
+ const profile = String(requestUrl.searchParams.get("profile") || "").trim();
939
+ const team = String(requestUrl.searchParams.get("team") || "").trim();
940
+ const botScopes = parseScopeList(requestUrl.searchParams.get("scope") || DEFAULT_OAUTH_BOT_SCOPES);
941
+ const userScopes = parseScopeList(
942
+ requestUrl.searchParams.get("user_scope") || DEFAULT_OAUTH_USER_SCOPES
943
+ );
944
+
945
+ if (botScopes.length === 0 && userScopes.length === 0) {
946
+ sendJson(res, 400, { ok: false, error: "missing_scope" });
1475
947
  return;
1476
948
  }
1477
- const payload = buildPublicOnboardPayload(gatewayBaseUrl, {
1478
- profile: requestUrl.searchParams.get("profile") || "",
1479
- team: requestUrl.searchParams.get("team") || "",
1480
- scope: requestUrl.searchParams.get("scope") || "",
1481
- user_scope: requestUrl.searchParams.get("user_scope") || "",
949
+
950
+ const claimToken = crypto.randomBytes(32).toString("hex");
951
+ const expiresAt = Date.now() + claimTtlMs;
952
+ claimSessions.set(claimToken, {
953
+ claim_token: claimToken,
954
+ profile,
955
+ team,
956
+ bot_scopes: botScopes,
957
+ user_scopes: userScopes,
958
+ oauth_response: null,
959
+ state: "",
960
+ expires_at: expiresAt,
1482
961
  });
1483
- sendJson(res, 200, payload);
1484
- return;
1485
- }
1486
962
 
1487
- if (method === "GET" && requestUrl.pathname === "/onboard/resolve") {
1488
- const token = requestUrl.searchParams.get("token") || "";
1489
- const secret = requireGatewayInviteSecret();
1490
- const payload = parseAndVerifyInviteToken(token, secret);
1491
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
963
+ const startParams = new URLSearchParams();
964
+ startParams.set("claim", claimToken);
965
+ const startUrl = `${publicBaseUrl}/onboard/start?${startParams.toString()}`;
966
+
1492
967
  sendJson(res, 200, {
1493
968
  ok: true,
1494
- mode: "invite_token",
1495
- gateway_url: payload.gateway_url || gatewayBaseUrl,
1496
- gateway_api_key: payload.gateway_api_key || "",
1497
- profile: payload.profile || "",
1498
- oauth_start_url: oauthStartUrl,
1499
- expires_at: new Date(Number(payload.exp)).toISOString(),
969
+ onboard_start_url: startUrl,
970
+ claim_token: claimToken,
971
+ profile,
972
+ expires_at: new Date(expiresAt).toISOString(),
1500
973
  });
1501
974
  return;
1502
975
  }
1503
976
 
1504
- if (method === "GET" && requestUrl.pathname === "/oauth/start") {
1505
- const { clientId } = requireGatewayClientCredentials();
1506
- const profileName = requestUrl.searchParams.get("profile") || "";
1507
- const teamId = requestUrl.searchParams.get("team") || process.env.SLACK_OAUTH_TEAM_ID || "";
1508
- const botScopes = parseScopesFromQuery(requestUrl.searchParams, "scope", DEFAULT_OAUTH_BOT_SCOPES);
1509
- const userScopes = parseScopesFromQuery(
1510
- requestUrl.searchParams,
1511
- "user_scope",
1512
- DEFAULT_OAUTH_USER_SCOPES
1513
- );
1514
-
1515
- if (botScopes.length === 0 && userScopes.length === 0) {
1516
- sendJson(res, 400, { ok: false, error: "missing_scope" });
977
+ if (method === "GET" && requestUrl.pathname === "/onboard/start") {
978
+ const claimToken = String(requestUrl.searchParams.get("claim") || "");
979
+ const session = claimSessions.get(claimToken);
980
+ if (!session || isClaimSessionExpired(session)) {
981
+ sendText(res, 400, "Invalid or expired onboarding claim.");
1517
982
  return;
1518
983
  }
1519
984
 
1520
985
  const state = crypto.randomBytes(24).toString("hex");
1521
- pendingStates.set(state, {
1522
- created_at: Date.now(),
1523
- profile_name: profileName,
1524
- team_id: teamId,
1525
- bot_scopes: botScopes,
1526
- user_scopes: userScopes,
1527
- });
986
+ session.state = state;
987
+ stateToClaim.set(state, claimToken);
1528
988
 
1529
989
  const authorizeUrl = buildOauthAuthorizeUrl({
1530
990
  clientId,
1531
991
  state,
1532
992
  redirectUri,
1533
- botScopes,
1534
- userScopes,
1535
- teamId,
993
+ botScopes: session.bot_scopes,
994
+ userScopes: session.user_scopes,
995
+ teamId: session.team,
1536
996
  });
1537
997
 
1538
998
  res.writeHead(302, { Location: authorizeUrl });
@@ -1541,7 +1001,6 @@ async function startGatewayServer() {
1541
1001
  }
1542
1002
 
1543
1003
  if (method === "GET" && requestUrl.pathname === callbackPath) {
1544
- const { clientId, clientSecret } = requireGatewayClientCredentials();
1545
1004
  const receivedError = requestUrl.searchParams.get("error");
1546
1005
  if (receivedError) {
1547
1006
  sendText(res, 400, `Slack OAuth failed: ${receivedError}`);
@@ -1555,106 +1014,45 @@ async function startGatewayServer() {
1555
1014
  return;
1556
1015
  }
1557
1016
 
1558
- const pending = pendingStates.get(state);
1559
- pendingStates.delete(state);
1560
- if (!pending) {
1561
- sendText(res, 400, "Invalid or expired OAuth state.");
1017
+ const claimToken = stateToClaim.get(state);
1018
+ if (!claimToken) {
1019
+ sendText(res, 400, "Invalid OAuth state.");
1562
1020
  return;
1563
1021
  }
1564
- if (Date.now() - pending.created_at > GATEWAY_STATE_TTL_MS) {
1565
- sendText(res, 400, "Expired OAuth state.");
1022
+
1023
+ const session = claimSessions.get(claimToken);
1024
+ if (!session || isClaimSessionExpired(session)) {
1025
+ sendText(res, 400, "Expired onboarding claim.");
1566
1026
  return;
1567
1027
  }
1568
1028
 
1569
1029
  const oauthResponse = await exchangeOauthCode({ clientId, clientSecret, code, redirectUri });
1570
- const { key, profile } = upsertOauthProfile(oauthResponse, pending.profile_name);
1571
- sendText(
1572
- res,
1573
- 200,
1574
- [
1575
- "Slack OAuth authorization completed.",
1576
- `Saved profile: ${profile.profile_name || key}`,
1577
- `Profile key: ${key}`,
1578
- "You can close this tab.",
1579
- ].join("\n")
1580
- );
1581
- return;
1582
- }
1583
-
1584
- if (method === "GET" && requestUrl.pathname === "/oauth/link") {
1585
- const params = new URLSearchParams();
1586
- const profile = requestUrl.searchParams.get("profile") || "";
1587
- const team = requestUrl.searchParams.get("team") || "";
1588
- const scope = requestUrl.searchParams.get("scope") || "";
1589
- const userScope = requestUrl.searchParams.get("user_scope") || "";
1590
- if (profile) params.set("profile", profile);
1591
- if (team) params.set("team", team);
1592
- if (scope) params.set("scope", scope);
1593
- if (userScope) params.set("user_scope", userScope);
1594
- sendJson(res, 200, {
1595
- ok: true,
1596
- url: `${gatewayBaseUrl}/oauth/start${params.toString() ? `?${params.toString()}` : ""}`,
1597
- });
1030
+ session.oauth_response = oauthResponse;
1031
+ stateToClaim.delete(state);
1032
+ sendText(res, 200, "Slack OAuth completed. Return to your CLI and wait for onboarding to finish.");
1598
1033
  return;
1599
1034
  }
1600
1035
 
1601
- if (method === "GET" && requestUrl.pathname === "/profiles") {
1602
- if (!isGatewayAuthorized(req)) {
1603
- sendJson(res, 401, { ok: false, error: "unauthorized" });
1604
- return;
1605
- }
1606
- const tokenStore = loadTokenStore();
1607
- sendJson(res, 200, {
1608
- ok: true,
1609
- default_profile: tokenStore.default_profile,
1610
- profiles: profileSummariesFromStore(tokenStore),
1611
- });
1612
- return;
1613
- }
1614
-
1615
- if (method === "POST" && requestUrl.pathname === "/api/slack/call") {
1616
- if (!isGatewayAuthorized(req)) {
1617
- sendJson(res, 401, { ok: false, error: "unauthorized" });
1036
+ if (method === "GET" && requestUrl.pathname === "/onboard/claim") {
1037
+ const claimToken = String(requestUrl.searchParams.get("claim") || "");
1038
+ const session = claimSessions.get(claimToken);
1039
+ if (!session || isClaimSessionExpired(session)) {
1040
+ sendJson(res, 400, { ok: false, error: "invalid_or_expired_claim" });
1618
1041
  return;
1619
1042
  }
1620
1043
 
1621
- const payload = await readRequestJson(req, 1024 * 1024);
1622
- const methodName = payload.method;
1623
- if (!methodName || typeof methodName !== "string") {
1624
- sendJson(res, 400, { ok: false, error: "missing_method" });
1044
+ if (!session.oauth_response) {
1045
+ sendJson(res, 200, { ok: true, status: "pending" });
1625
1046
  return;
1626
1047
  }
1627
1048
 
1628
- const candidates = getSlackTokenCandidates(payload.token_override, {
1629
- profileSelector:
1630
- payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1631
- preferredTokenType:
1632
- payload.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
1049
+ sendJson(res, 200, {
1050
+ ok: true,
1051
+ status: "ready",
1052
+ profile: session.profile || "",
1053
+ oauth_response: session.oauth_response,
1633
1054
  });
1634
- if (candidates.length === 0) {
1635
- sendJson(res, 400, { ok: false, error: "missing_token" });
1636
- return;
1637
- }
1638
-
1639
- const data = await callSlackApiWithCandidates(methodName, payload.params || {}, candidates);
1640
- sendJson(res, 200, { ok: true, data });
1641
- return;
1642
- }
1643
-
1644
- if (method === "POST" && requestUrl.pathname === "/api/slack/http") {
1645
- if (!isGatewayAuthorized(req)) {
1646
- sendJson(res, 401, { ok: false, error: "unauthorized" });
1647
- return;
1648
- }
1649
-
1650
- const payload = await readRequestJson(req, 1024 * 1024);
1651
- if (!payload.url || typeof payload.url !== "string") {
1652
- sendJson(res, 400, { ok: false, error: "missing_url" });
1653
- return;
1654
- }
1655
-
1656
- const data = await proxySlackHttpRequest(payload);
1657
- sendJson(res, 200, { ok: true, data });
1055
+ claimSessions.delete(claimToken);
1658
1056
  return;
1659
1057
  }
1660
1058
 
@@ -1663,102 +1061,50 @@ async function startGatewayServer() {
1663
1061
  sendJson(res, 500, {
1664
1062
  ok: false,
1665
1063
  error: error instanceof Error ? error.message : String(error),
1666
- slack_error: error?.slack_error || null,
1667
- needed: error?.needed || null,
1668
- provided: error?.provided || null,
1669
- token_source: error?.token_source || null,
1670
1064
  });
1671
- } finally {
1672
- for (const [state, value] of pendingStates.entries()) {
1673
- if (Date.now() - value.created_at > GATEWAY_STATE_TTL_MS) {
1674
- pendingStates.delete(state);
1675
- }
1676
- }
1677
1065
  }
1678
1066
  });
1679
1067
 
1680
1068
  await new Promise((resolve, reject) => {
1681
1069
  server.once("error", reject);
1682
- server.listen(GATEWAY_PORT, GATEWAY_HOST, resolve);
1070
+ server.listen(port, host, resolve);
1683
1071
  });
1684
1072
 
1685
- console.error(
1686
- `[${SERVER_NAME}] gateway listening at http://${GATEWAY_HOST}:${GATEWAY_PORT} | public_base=${gatewayBaseUrl}`
1687
- );
1688
- console.error(`[${SERVER_NAME}] oauth start URL: ${gatewayBaseUrl}/oauth/start`);
1689
- console.error(`[${SERVER_NAME}] profile list URL: ${gatewayBaseUrl}/profiles`);
1690
- if (GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1691
- console.error(`[${SERVER_NAME}] public onboard URL: ${gatewayBaseUrl}/onboard/bootstrap`);
1692
- }
1073
+ console.error(`[${SERVER_NAME}] onboard server listening at http://${host}:${port}`);
1074
+ console.error(`[${SERVER_NAME}] public base: ${publicBaseUrl}`);
1075
+ console.error(`[${SERVER_NAME}] bootstrap URL: ${publicBaseUrl}/onboard/bootstrap`);
1693
1076
  }
1694
1077
 
1695
- function printGatewayHelp() {
1696
- const lines = [
1697
- "Slack Max Gateway helper",
1698
- "",
1699
- "Usage:",
1700
- " slack-max-api-mcp gateway start",
1701
- " slack-max-api-mcp gateway invite --profile woobin --team T123",
1702
- " # tokenless onboarding endpoint (when enabled):",
1703
- " # https://gateway.example.com/onboard/bootstrap",
1704
- " slack-max-api-mcp gateway help",
1705
- "",
1706
- "Gateway env vars (server-side):",
1707
- " SLACK_CLIENT_ID, SLACK_CLIENT_SECRET",
1708
- " SLACK_GATEWAY_HOST, SLACK_GATEWAY_PORT, SLACK_GATEWAY_PUBLIC_BASE_URL",
1709
- " SLACK_GATEWAY_SHARED_SECRET (recommended)",
1710
- " SLACK_GATEWAY_CLIENT_API_KEY (optional, defaults to shared secret)",
1711
- " SLACK_GATEWAY_PUBLIC_ONBOARD=true # allow tokenless onboarding endpoint",
1712
- " SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY=<client key> # optional, used when gateway is not fully public",
1713
- " SLACK_GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY=true # fallback: expose client key as-is",
1714
- " SLACK_OAUTH_BOT_SCOPES, SLACK_OAUTH_USER_SCOPES",
1715
- "",
1716
- "Client env vars (mcp caller-side):",
1717
- " SLACK_GATEWAY_URL, SLACK_GATEWAY_API_KEY",
1718
- " SLACK_PROFILE or SLACK_GATEWAY_PROFILE",
1719
- ];
1720
- console.log(lines.join("\n"));
1721
- }
1078
+ async function runOnboardCli(args) {
1079
+ const subcommand = (args[0] || "help").toLowerCase();
1080
+ const rest = args.slice(1);
1722
1081
 
1723
- function runGatewayInvite(args) {
1724
- const { options } = parseCliArgs(args);
1725
- const { token, payload } = createGatewayInviteTokenFromOptions(options);
1726
- const gatewayBaseUrl = String(payload.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1727
- const onboardScriptUrl = `${gatewayBaseUrl}/onboard.ps1?token=${encodeURIComponent(token)}`;
1728
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1729
- const command = `powershell -ExecutionPolicy Bypass -Command "irm '${onboardScriptUrl}' | iex"`;
1730
- const commandCurlFallback = [
1731
- `$tmp = Join-Path $env:TEMP 'slack-onboard.ps1'`,
1732
- `curl.exe -k -sS '${onboardScriptUrl}' -o $tmp`,
1733
- `powershell -ExecutionPolicy Bypass -File $tmp`,
1734
- `Remove-Item $tmp -Force`,
1735
- ].join("; ");
1736
-
1737
- console.log("[gateway] invite token created");
1738
- console.log(`[gateway] expires_at: ${new Date(Number(payload.exp)).toISOString()}`);
1739
- console.log(`[gateway] onboarding_script: ${onboardScriptUrl}`);
1740
- console.log(`[gateway] oauth_start_url: ${oauthStartUrl}`);
1741
- console.log("[gateway] one-click command for team member:");
1742
- console.log(command);
1743
- console.log("[gateway] fallback command (self-signed TLS):");
1744
- console.log(commandCurlFallback);
1082
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1083
+ printOnboardHelp();
1084
+ return;
1085
+ }
1086
+ if (subcommand === "run") {
1087
+ await runOnboardClient(rest);
1088
+ return;
1089
+ }
1090
+
1091
+ throw new Error(`Unknown onboard command: ${subcommand}`);
1745
1092
  }
1746
1093
 
1747
- async function runGatewayCli(args) {
1094
+ async function runOnboardServerCli(args) {
1748
1095
  const subcommand = (args[0] || "help").toLowerCase();
1096
+ const rest = args.slice(1);
1097
+
1749
1098
  if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1750
- printGatewayHelp();
1099
+ printOnboardServerHelp();
1751
1100
  return;
1752
1101
  }
1753
1102
  if (subcommand === "start") {
1754
- await startGatewayServer();
1103
+ await runOnboardServerStart(rest);
1755
1104
  return;
1756
1105
  }
1757
- if (subcommand === "invite") {
1758
- runGatewayInvite(args.slice(1));
1759
- return;
1760
- }
1761
- throw new Error(`Unknown gateway command: ${subcommand}`);
1106
+
1107
+ throw new Error(`Unknown onboard-server command: ${subcommand}`);
1762
1108
  }
1763
1109
 
1764
1110
  function loadCatalog() {
@@ -1775,21 +1121,301 @@ function loadCatalog() {
1775
1121
  }
1776
1122
  }
1777
1123
 
1778
- function toolNameFromMethod(method, usedNames) {
1779
- const base = `${METHOD_TOOL_PREFIX}_${method.replace(/[^a-zA-Z0-9]/g, "_")}`;
1780
- if (!usedNames.has(base)) {
1781
- usedNames.add(base);
1124
+ function toolNameFromMethod(method, usedNames) {
1125
+ const base = `${METHOD_TOOL_PREFIX}_${method.replace(/[^a-zA-Z0-9]/g, "_")}`;
1126
+ if (!usedNames.has(base)) {
1127
+ usedNames.add(base);
1782
1128
  return base;
1783
1129
  }
1784
1130
 
1785
1131
  let idx = 2;
1786
1132
  while (usedNames.has(`${base}_${idx}`)) idx += 1;
1787
1133
  const name = `${base}_${idx}`;
1788
- usedNames.add(name);
1789
- return name;
1790
- }
1791
-
1792
- function registerCoreTools(server) {
1134
+ usedNames.add(name);
1135
+ return name;
1136
+ }
1137
+
1138
+ function normalizeSearchTokens(value) {
1139
+ return String(value || "")
1140
+ .toLowerCase()
1141
+ .split(/[^a-z0-9_.-]+/)
1142
+ .map((part) => part.trim())
1143
+ .filter(Boolean);
1144
+ }
1145
+
1146
+ function compactCatalogMethodInfo(methodInfo, options = {}) {
1147
+ const includeScopes = options.includeScopes !== false;
1148
+ const includeUrl = options.includeUrl === true;
1149
+ const out = {
1150
+ method: methodInfo?.method || "",
1151
+ family: methodInfo?.family || "",
1152
+ description: methodInfo?.description || "",
1153
+ };
1154
+ if (includeScopes) {
1155
+ out.scopes = Array.isArray(methodInfo?.scopes) ? methodInfo.scopes : [];
1156
+ }
1157
+ if (includeUrl) {
1158
+ out.url = methodInfo?.url || "";
1159
+ }
1160
+ return out;
1161
+ }
1162
+
1163
+ function scoreCatalogMethod(methodInfo, tokens) {
1164
+ if (!Array.isArray(tokens) || tokens.length === 0) return 0;
1165
+ const method = String(methodInfo?.method || "").toLowerCase();
1166
+ const description = String(methodInfo?.description || "").toLowerCase();
1167
+ const family = String(methodInfo?.family || "").toLowerCase();
1168
+ const scopes = Array.isArray(methodInfo?.scopes) ? methodInfo.scopes.join(" ").toLowerCase() : "";
1169
+
1170
+ let score = 0;
1171
+ for (const token of tokens) {
1172
+ if (!token) continue;
1173
+ if (method.includes(token)) score += 8;
1174
+ if (description.includes(token)) score += 4;
1175
+ if (family.includes(token)) score += 3;
1176
+ if (scopes.includes(token)) score += 2;
1177
+ }
1178
+ return score;
1179
+ }
1180
+
1181
+ function findCatalogMethods(catalog, query, maxItems = 10) {
1182
+ const methods = Array.isArray(catalog?.methods) ? catalog.methods : [];
1183
+ const size = Math.max(1, Math.min(50, Number(maxItems) || 10));
1184
+ const tokens = normalizeSearchTokens(query);
1185
+
1186
+ if (tokens.length === 0) {
1187
+ return methods.slice(0, size);
1188
+ }
1189
+
1190
+ return methods
1191
+ .map((methodInfo) => ({
1192
+ methodInfo,
1193
+ score: scoreCatalogMethod(methodInfo, tokens),
1194
+ }))
1195
+ .filter((item) => item.score > 0)
1196
+ .sort((a, b) => {
1197
+ if (b.score !== a.score) return b.score - a.score;
1198
+ return String(a.methodInfo?.method || "").localeCompare(String(b.methodInfo?.method || ""));
1199
+ })
1200
+ .slice(0, size)
1201
+ .map((item) => item.methodInfo);
1202
+ }
1203
+
1204
+ function findCatalogMethodByExactName(catalog, methodName) {
1205
+ const target = String(methodName || "").trim();
1206
+ if (!target) return null;
1207
+ const methods = Array.isArray(catalog?.methods) ? catalog.methods : [];
1208
+ return methods.find((methodInfo) => methodInfo?.method === target) || null;
1209
+ }
1210
+
1211
+ async function executeSlackHttpRequest({
1212
+ url,
1213
+ http_method,
1214
+ query,
1215
+ json_body,
1216
+ form_body,
1217
+ headers,
1218
+ token_override,
1219
+ }) {
1220
+ const tokenCandidate = requireSlackTokenCandidate(token_override);
1221
+ const method = http_method || "GET";
1222
+
1223
+ const endpoint = new URL(url);
1224
+ for (const [k, v] of Object.entries(toRecordObject(query))) {
1225
+ if (v === undefined || v === null) continue;
1226
+ endpoint.searchParams.set(k, String(v));
1227
+ }
1228
+
1229
+ const reqHeaders = {
1230
+ Authorization:
1231
+ "Bearer " + tokenCandidate.token,
1232
+ ...(headers || {}),
1233
+ };
1234
+
1235
+ let body;
1236
+ if (form_body && Object.keys(form_body).length > 0) {
1237
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1238
+ body = toUrlEncodedBody(form_body);
1239
+ } else if (json_body && Object.keys(json_body).length > 0) {
1240
+ reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1241
+ body = JSON.stringify(json_body);
1242
+ }
1243
+
1244
+ const res = await fetch(endpoint.toString(), {
1245
+ method,
1246
+ headers: reqHeaders,
1247
+ body,
1248
+ });
1249
+
1250
+ const text = await res.text();
1251
+ let parsedBody = text;
1252
+ try {
1253
+ parsedBody = JSON.parse(text);
1254
+ } catch {
1255
+ // Keep plain text when response is not JSON.
1256
+ }
1257
+
1258
+ return {
1259
+ url: endpoint.toString(),
1260
+ status: res.status,
1261
+ ok: res.ok,
1262
+ headers: Object.fromEntries(res.headers.entries()),
1263
+ body: parsedBody,
1264
+ };
1265
+ }
1266
+
1267
+ function registerSmartGatewayTools(server, catalog) {
1268
+ server.registerTool(
1269
+ "gateway_plan",
1270
+ {
1271
+ description:
1272
+ "Plan a Slack task with minimal context. Returns ranked candidate methods and the next actions.",
1273
+ inputSchema: {
1274
+ goal: z.string().min(3),
1275
+ max_methods: z.number().int().min(1).max(20).optional(),
1276
+ },
1277
+ },
1278
+ async ({ goal, max_methods }) =>
1279
+ safeToolRun(async () => {
1280
+ const candidates = findCatalogMethods(catalog, goal, max_methods ?? 8).map((methodInfo) =>
1281
+ compactCatalogMethodInfo(methodInfo)
1282
+ );
1283
+ return {
1284
+ mode: TOOL_EXPOSURE_MODE,
1285
+ goal,
1286
+ candidate_methods: candidates,
1287
+ next_steps: [
1288
+ "1) Use gateway_load to inspect candidate methods in detail.",
1289
+ "2) Execute the chosen method with gateway_run(action=method).",
1290
+ "3) Summarize output and repeat only when additional actions are needed.",
1291
+ ],
1292
+ note:
1293
+ "This planner keeps tool exposure small by routing through a minimal tool surface.",
1294
+ };
1295
+ })
1296
+ );
1297
+
1298
+ server.registerTool(
1299
+ "gateway_load",
1300
+ {
1301
+ description:
1302
+ "Load only the method references you need. Supports exact method lookup or query search.",
1303
+ inputSchema: {
1304
+ method: z.string().optional(),
1305
+ query: z.string().optional(),
1306
+ max_items: z.number().int().min(1).max(30).optional(),
1307
+ include_scopes: z.boolean().optional(),
1308
+ include_url: z.boolean().optional(),
1309
+ },
1310
+ },
1311
+ async ({ method, query, max_items, include_scopes, include_url }) =>
1312
+ safeToolRun(async () => {
1313
+ if (!method && !query) {
1314
+ throw new Error("Either `method` or `query` is required.");
1315
+ }
1316
+
1317
+ const includeScopes = include_scopes !== false;
1318
+ const includeUrl = include_url === true;
1319
+ let methods = [];
1320
+
1321
+ if (method) {
1322
+ const exact = findCatalogMethodByExactName(catalog, method);
1323
+ methods = exact ? [exact] : [];
1324
+ } else {
1325
+ methods = findCatalogMethods(catalog, query, max_items ?? 10);
1326
+ }
1327
+
1328
+ return {
1329
+ mode: TOOL_EXPOSURE_MODE,
1330
+ count: methods.length,
1331
+ methods: methods.map((methodInfo) =>
1332
+ compactCatalogMethodInfo(methodInfo, { includeScopes, includeUrl })
1333
+ ),
1334
+ };
1335
+ })
1336
+ );
1337
+
1338
+ server.registerTool(
1339
+ "gateway_run",
1340
+ {
1341
+ description:
1342
+ "Run a Slack action through a single gateway tool. Supports Slack Web API method call and generic HTTP call.",
1343
+ inputSchema: {
1344
+ action: z.enum(["method", "http"]),
1345
+ method: z.string().optional(),
1346
+ params: z.record(z.string(), z.any()).optional(),
1347
+ preferred_token_type: z.enum(["bot", "user", "generic", "auto"]).optional(),
1348
+ url: z.string().url().optional(),
1349
+ http_method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
1350
+ query: z.record(z.string(), z.any()).optional(),
1351
+ json_body: z.record(z.string(), z.any()).optional(),
1352
+ form_body: z.record(z.string(), z.any()).optional(),
1353
+ headers: z.record(z.string(), z.string()).optional(),
1354
+ token_override: z.string().optional(),
1355
+ },
1356
+ },
1357
+ async ({
1358
+ action,
1359
+ method,
1360
+ params,
1361
+ preferred_token_type,
1362
+ url,
1363
+ http_method,
1364
+ query,
1365
+ json_body,
1366
+ form_body,
1367
+ headers,
1368
+ token_override,
1369
+ }) =>
1370
+ safeToolRun(async () => {
1371
+ if (action === "method") {
1372
+ if (!method) throw new Error("`method` is required when action=method.");
1373
+ const data = await callSlackApi(
1374
+ method,
1375
+ params || {},
1376
+ token_override,
1377
+ preferred_token_type ? { preferredTokenType: preferred_token_type } : {}
1378
+ );
1379
+ return { action, method, data };
1380
+ }
1381
+
1382
+ if (!url) throw new Error("`url` is required when action=http.");
1383
+ const data = await executeSlackHttpRequest({
1384
+ url,
1385
+ http_method,
1386
+ query,
1387
+ json_body,
1388
+ form_body,
1389
+ headers,
1390
+ token_override,
1391
+ });
1392
+ return { action, data };
1393
+ })
1394
+ );
1395
+
1396
+ server.registerTool(
1397
+ "gateway_info",
1398
+ {
1399
+ description: "Return gateway exposure mode and lightweight tool registration summary.",
1400
+ inputSchema: {},
1401
+ },
1402
+ async () =>
1403
+ safeToolRun(async () => {
1404
+ return {
1405
+ mode: TOOL_EXPOSURE_MODE,
1406
+ execution_mode: "local",
1407
+ method_tools_enabled: ENABLE_METHOD_TOOLS,
1408
+ max_method_tools: MAX_METHOD_TOOLS,
1409
+ methods_in_catalog: Array.isArray(catalog?.methods) ? catalog.methods.length : 0,
1410
+ exposed_tools_hint: "smart mode keeps the tool surface compact via gateway_plan/load/run/info.",
1411
+ };
1412
+ })
1413
+ );
1414
+
1415
+ return { registered: 4 };
1416
+ }
1417
+
1418
+ function registerCoreTools(server) {
1793
1419
  server.registerTool(
1794
1420
  "slack_api_call",
1795
1421
  {
@@ -1827,65 +1453,17 @@ function registerCoreTools(server) {
1827
1453
  },
1828
1454
  async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
1829
1455
  safeToolRun(async () => {
1830
- const runtimeGateway = getRuntimeGatewayConfig();
1831
- if (runtimeGateway.url) {
1832
- return slackHttpViaGateway({
1833
- url,
1834
- http_method,
1835
- query,
1836
- json_body,
1837
- form_body,
1838
- headers,
1839
- token_override,
1840
- });
1841
- }
1842
-
1843
- const tokenCandidate = requireSlackTokenCandidate(token_override);
1844
- const method = http_method || "GET";
1845
-
1846
- const endpoint = new URL(url);
1847
- for (const [k, v] of Object.entries(toRecordObject(query))) {
1848
- if (v === undefined || v === null) continue;
1849
- endpoint.searchParams.set(k, String(v));
1850
- }
1851
-
1852
- const reqHeaders = {
1853
- Authorization: `Bearer ${tokenCandidate.token}`,
1854
- ...(headers || {}),
1855
- };
1856
-
1857
- let body;
1858
- if (form_body && Object.keys(form_body).length > 0) {
1859
- reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1860
- body = toUrlEncodedBody(form_body);
1861
- } else if (json_body && Object.keys(json_body).length > 0) {
1862
- reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1863
- body = JSON.stringify(json_body);
1864
- }
1865
-
1866
- const res = await fetch(endpoint.toString(), {
1867
- method,
1868
- headers: reqHeaders,
1869
- body,
1870
- });
1871
-
1872
- const text = await res.text();
1873
- let parsedBody = text;
1874
- try {
1875
- parsedBody = JSON.parse(text);
1876
- } catch {
1877
- // Keep plain text when response is not JSON.
1878
- }
1879
-
1880
- return {
1881
- url: endpoint.toString(),
1882
- status: res.status,
1883
- ok: res.ok,
1884
- headers: Object.fromEntries(res.headers.entries()),
1885
- body: parsedBody,
1886
- };
1887
- })
1888
- );
1456
+ return executeSlackHttpRequest({
1457
+ url,
1458
+ http_method,
1459
+ query,
1460
+ json_body,
1461
+ form_body,
1462
+ headers,
1463
+ token_override,
1464
+ });
1465
+ })
1466
+ );
1889
1467
 
1890
1468
  server.registerTool(
1891
1469
  "search_messages_files",
@@ -2253,10 +1831,12 @@ function registerCoreTools(server) {
2253
1831
  profile = info.user?.profile || null;
2254
1832
  }
2255
1833
 
2256
- return { user: info.user || null, profile };
2257
- })
2258
- );
2259
- }
1834
+ return { user: info.user || null, profile };
1835
+ })
1836
+ );
1837
+
1838
+ return { registered: 12 };
1839
+ }
2260
1840
 
2261
1841
  function registerCatalogMethodTools(server, catalog) {
2262
1842
  if (!ENABLE_METHOD_TOOLS) return { registered: 0 };
@@ -2308,17 +1888,15 @@ function registerCatalogMethodTools(server, catalog) {
2308
1888
  safeToolRun(async () => {
2309
1889
  const tokenStore = loadTokenStore();
2310
1890
  const activeProfile = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
2311
- const clientConfig = loadClientConfig();
2312
- const runtimeGateway = getRuntimeGatewayConfig();
2313
1891
  return {
2314
1892
  catalog_path: CATALOG_PATH,
1893
+ execution_mode: "local",
2315
1894
  method_tools_enabled: ENABLE_METHOD_TOOLS,
2316
1895
  max_method_tools: MAX_METHOD_TOOLS,
2317
1896
  methods_in_catalog: methods.length,
2318
1897
  method_tools_registered: registered,
2319
1898
  method_tool_prefix: METHOD_TOOL_PREFIX,
2320
1899
  token_store_path: TOKEN_STORE_PATH,
2321
- client_config_path: CLIENT_CONFIG_PATH,
2322
1900
  active_profile: activeProfile
2323
1901
  ? {
2324
1902
  key: activeProfile.key,
@@ -2326,14 +1904,11 @@ function registerCatalogMethodTools(server, catalog) {
2326
1904
  team_id: activeProfile.profile?.team_id || "",
2327
1905
  }
2328
1906
  : null,
2329
- client_profile: clientConfig.profile || "",
2330
1907
  env_tokens_present: {
2331
1908
  bot: Boolean(process.env.SLACK_BOT_TOKEN),
2332
1909
  user: Boolean(process.env.SLACK_USER_TOKEN),
2333
1910
  generic: Boolean(process.env.SLACK_TOKEN),
2334
1911
  },
2335
- gateway_mode: Boolean(runtimeGateway.url),
2336
- gateway_url: runtimeGateway.url || null,
2337
1912
  env_example_fallback_enabled: ALLOW_ENV_EXAMPLE_FALLBACK,
2338
1913
  };
2339
1914
  })
@@ -2347,13 +1922,25 @@ async function startMcpServer() {
2347
1922
  { name: SERVER_NAME, version: SERVER_VERSION },
2348
1923
  { capabilities: { logging: {} } }
2349
1924
  );
2350
-
2351
- registerCoreTools(server);
2352
- const catalog = loadCatalog();
2353
- const methodStats = registerCatalogMethodTools(server, catalog);
2354
-
2355
- const transport = new StdioServerTransport();
2356
- await server.connect(transport);
1925
+
1926
+ const catalog = loadCatalog();
1927
+ let coreStats = { registered: 0 };
1928
+ let smartStats = { registered: 0 };
1929
+ let compatCoreStats = { registered: 0 };
1930
+
1931
+ if (TOOL_EXPOSURE_MODE === "legacy") {
1932
+ coreStats = registerCoreTools(server);
1933
+ } else {
1934
+ smartStats = registerSmartGatewayTools(server, catalog);
1935
+ if (SMART_COMPAT_CORE_TOOLS) {
1936
+ compatCoreStats = registerCoreTools(server);
1937
+ }
1938
+ coreStats = { registered: smartStats.registered + compatCoreStats.registered };
1939
+ }
1940
+ const methodStats = registerCatalogMethodTools(server, catalog);
1941
+
1942
+ const transport = new StdioServerTransport();
1943
+ await server.connect(transport);
2357
1944
 
2358
1945
  const catalogCount =
2359
1946
  catalog && catalog.totals && typeof catalog.totals.methods === "number"
@@ -2363,7 +1950,7 @@ async function startMcpServer() {
2363
1950
  : 0;
2364
1951
 
2365
1952
  console.error(
2366
- `[${SERVER_NAME}] connected via stdio | catalog_methods=${catalogCount} | method_tools_registered=${methodStats.registered}`
1953
+ `[${SERVER_NAME}] connected via stdio | mode=${TOOL_EXPOSURE_MODE} | core_tools_registered=${coreStats?.registered ?? 0} | smart_tools_registered=${smartStats.registered} | compat_core_tools_registered=${compatCoreStats.registered} | catalog_methods=${catalogCount} | method_tools_registered=${methodStats.registered}`
2367
1954
  );
2368
1955
  }
2369
1956
 
@@ -2374,17 +1961,26 @@ async function runEntryPoint() {
2374
1961
  await runOauthCli(rest);
2375
1962
  return;
2376
1963
  }
2377
- if (command === "gateway") {
2378
- await runGatewayCli(rest);
2379
- return;
2380
- }
2381
1964
  if (command === "onboard") {
2382
1965
  await runOnboardCli(rest);
2383
1966
  return;
2384
1967
  }
2385
- if (!command) {
2386
- const onboarded = await runAutoOnboardingIfPossible();
2387
- if (onboarded) return;
1968
+ if (command === "onboard-server") {
1969
+ await runOnboardServerCli(rest);
1970
+ return;
1971
+ }
1972
+ if (command === "help" || command === "--help" || command === "-h") {
1973
+ console.log("Usage:");
1974
+ console.log(" slack-max-api-mcp");
1975
+ console.log(" slack-max-api-mcp oauth <login|list|use|current|help>");
1976
+ console.log(" slack-max-api-mcp onboard <run|help>");
1977
+ console.log(" slack-max-api-mcp onboard-server <start|help>");
1978
+ return;
1979
+ }
1980
+ if (command) {
1981
+ throw new Error(
1982
+ `Unknown command: ${command}. Use 'slack-max-api-mcp help' for available commands.`
1983
+ );
2388
1984
  }
2389
1985
  await startMcpServer();
2390
1986
  }