slack-max-api-mcp 1.0.7 → 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.
@@ -13,20 +13,28 @@ const { z } = require("zod");
13
13
  const SERVER_NAME = "slack-max-api-mcp";
14
14
  const SERVER_VERSION = "2.0.0";
15
15
 
16
- const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
17
-
16
+ const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
17
+
18
18
  const CATALOG_PATH =
19
19
  process.env.SLACK_CATALOG_PATH || path.join(process.cwd(), "data", "slack-catalog.json");
20
20
  const METHOD_TOOL_PREFIX = process.env.SLACK_METHOD_TOOL_PREFIX || "slack_method";
21
- const ENABLE_METHOD_TOOLS = process.env.SLACK_ENABLE_METHOD_TOOLS !== "false";
22
- 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
+ );
23
34
  const ENV_EXAMPLE_PATH = path.join(process.cwd(), ".env.example");
24
35
  const TOKEN_STORE_PATH =
25
36
  process.env.SLACK_TOKEN_STORE_PATH ||
26
37
  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
38
  const ALLOW_ENV_EXAMPLE_FALLBACK = process.env.SLACK_ALLOW_ENV_EXAMPLE_FALLBACK === "true";
31
39
  const OAUTH_CALLBACK_HOST = process.env.SLACK_OAUTH_CALLBACK_HOST || "127.0.0.1";
32
40
  const OAUTH_CALLBACK_PORT = Number(process.env.SLACK_OAUTH_CALLBACK_PORT || 8787);
@@ -38,37 +46,37 @@ const DEFAULT_OAUTH_USER_SCOPES =
38
46
  process.env.SLACK_OAUTH_USER_SCOPES ||
39
47
  "search:read,channels:read,groups:read,channels:history,groups:history";
40
48
  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_PUBLIC_ONBOARD_ENABLED = process.env.SLACK_GATEWAY_PUBLIC_ONBOARD === "true";
52
- const GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY =
53
- process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY === "true";
54
- const GATEWAY_PUBLIC_ONBOARD_API_KEY = process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY || "";
55
- const GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX =
56
- process.env.SLACK_GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX || "auto";
57
- const GATEWAY_STATE_TTL_MS = Number(process.env.SLACK_GATEWAY_STATE_TTL_MS || 15 * 60 * 1000);
58
- const INVITE_TOKEN_DEFAULT_DAYS = Number(process.env.SLACK_INVITE_TOKEN_DEFAULT_DAYS || 7);
59
- const AUTO_ONBOARD_ENABLED = process.env.SLACK_AUTO_ONBOARD !== "false";
60
- const AUTO_ONBOARD_GATEWAY =
61
- process.env.SLACK_AUTO_ONBOARD_GATEWAY || process.env.SLACK_ONBOARD_GATEWAY_URL || "";
62
- const AUTO_ONBOARD_PROFILE = process.env.SLACK_AUTO_ONBOARD_PROFILE || "";
63
- const AUTO_ONBOARD_TOKEN = process.env.SLACK_AUTO_ONBOARD_TOKEN || process.env.SLACK_ONBOARD_TOKEN || "";
64
- const AUTO_ONBOARD_URL = process.env.SLACK_AUTO_ONBOARD_URL || process.env.SLACK_ONBOARD_URL || "";
65
- const AUTO_ONBOARD_PROFILE_PREFIX = process.env.SLACK_AUTO_ONBOARD_PROFILE_PREFIX || "auto";
66
- const ONBOARD_PACKAGE_SPEC =
67
- process.env.SLACK_ONBOARD_PACKAGE_SPEC ||
68
- process.env.SLACK_ONBOARD_INSTALL_SPEC ||
69
- "slack-max-api-mcp@latest";
70
- const ONBOARD_SKIP_TLS_VERIFY = process.env.SLACK_ONBOARD_SKIP_TLS_VERIFY === "true";
71
-
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
+
72
80
  function parseSimpleEnvFile(filePath) {
73
81
  if (!fs.existsSync(filePath)) return {};
74
82
 
@@ -97,31 +105,6 @@ function parseScopeList(raw) {
97
105
  return [...new Set(String(raw).split(",").map((part) => part.trim()).filter(Boolean))];
98
106
  }
99
107
 
100
- function normalizeOnboardNamePart(value, fallback) {
101
- const normalized = String(value || "")
102
- .trim()
103
- .toLowerCase()
104
- .replace(/[^a-z0-9_-]+/g, "-")
105
- .replace(/^-+|-+$/g, "");
106
- if (!normalized) return fallback;
107
- return normalized;
108
- }
109
-
110
- function createAutoOnboardProfileName(prefix = "auto") {
111
- let username = "user";
112
- try {
113
- username = os.userInfo().username || process.env.USERNAME || process.env.USER || "user";
114
- } catch {
115
- username = process.env.USERNAME || process.env.USER || "user";
116
- }
117
- const host = os.hostname() || "host";
118
- const profilePrefix = normalizeOnboardNamePart(prefix, "auto");
119
- const userPart = normalizeOnboardNamePart(username, "user");
120
- const hostPart = normalizeOnboardNamePart(host, "host");
121
- const rand = crypto.randomBytes(3).toString("hex");
122
- return `${profilePrefix}-${userPart}-${hostPart}-${rand}`.slice(0, 80);
123
- }
124
-
125
108
  function ensureParentDirectory(filePath) {
126
109
  const dirPath = path.dirname(filePath);
127
110
  fs.mkdirSync(dirPath, { recursive: true });
@@ -156,50 +139,6 @@ function saveTokenStore(store) {
156
139
  fs.writeFileSync(TOKEN_STORE_PATH, JSON.stringify(normalizeTokenStore(store), null, 2), "utf8");
157
140
  }
158
141
 
159
- function emptyClientConfig() {
160
- return {
161
- version: 1,
162
- gateway_url: "",
163
- gateway_api_key: "",
164
- profile: "",
165
- updated_at: "",
166
- };
167
- }
168
-
169
- function normalizeClientConfig(value) {
170
- if (!value || typeof value !== "object") return emptyClientConfig();
171
- return { ...emptyClientConfig(), ...value };
172
- }
173
-
174
- function loadClientConfig() {
175
- if (!fs.existsSync(CLIENT_CONFIG_PATH)) return emptyClientConfig();
176
- try {
177
- const parsed = JSON.parse(fs.readFileSync(CLIENT_CONFIG_PATH, "utf8"));
178
- return normalizeClientConfig(parsed);
179
- } catch {
180
- return emptyClientConfig();
181
- }
182
- }
183
-
184
- function saveClientConfig(config) {
185
- ensureParentDirectory(CLIENT_CONFIG_PATH);
186
- fs.writeFileSync(CLIENT_CONFIG_PATH, JSON.stringify(normalizeClientConfig(config), null, 2), "utf8");
187
- }
188
-
189
- function getRuntimeGatewayConfig() {
190
- const config = loadClientConfig();
191
- return {
192
- url: (process.env.SLACK_GATEWAY_URL || config.gateway_url || "").replace(/\/+$/, ""),
193
- apiKey: process.env.SLACK_GATEWAY_API_KEY || config.gateway_api_key || "",
194
- profile:
195
- process.env.SLACK_PROFILE ||
196
- process.env.SLACK_GATEWAY_PROFILE ||
197
- config.profile ||
198
- GATEWAY_PROFILE ||
199
- "",
200
- };
201
- }
202
-
203
142
  function resolveTokenStoreProfileBySelector(store, selector) {
204
143
  const profiles = store?.profiles || {};
205
144
  const keys = Object.keys(profiles);
@@ -266,7 +205,7 @@ function getSlackTokenCandidates(tokenOverride, options = {}) {
266
205
  const tokenStore = loadTokenStore();
267
206
  const activeProfile = resolveTokenStoreProfileBySelector(
268
207
  tokenStore,
269
- options.profileSelector || process.env.SLACK_PROFILE || GATEWAY_PROFILE
208
+ options.profileSelector || process.env.SLACK_PROFILE
270
209
  );
271
210
  if (activeProfile) {
272
211
  appendCandidateTokens(
@@ -339,98 +278,6 @@ function toRecordObject(value) {
339
278
  return value;
340
279
  }
341
280
 
342
- function buildGatewayAuthHeaders(apiKey) {
343
- const headers = { "Content-Type": "application/json; charset=utf-8" };
344
- if (apiKey) {
345
- headers.Authorization = `Bearer ${apiKey}`;
346
- headers["X-API-Key"] = apiKey;
347
- }
348
- return headers;
349
- }
350
-
351
- async function callSlackApiViaGateway(method, params = {}, tokenOverride, options = {}) {
352
- const runtimeGateway = getRuntimeGatewayConfig();
353
- if (!runtimeGateway.url) {
354
- throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
355
- }
356
-
357
- const response = await fetch(`${runtimeGateway.url}/api/slack/call`, {
358
- method: "POST",
359
- headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
360
- body: JSON.stringify({
361
- method,
362
- params,
363
- token_override: tokenOverride || undefined,
364
- profile_selector: options.profileSelector || runtimeGateway.profile || undefined,
365
- preferred_token_type: options.preferredTokenType || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
366
- }),
367
- });
368
-
369
- const text = await response.text();
370
- let body;
371
- try {
372
- body = JSON.parse(text);
373
- } catch {
374
- throw new Error(`Gateway returned non-JSON for ${method} (HTTP ${response.status}).`);
375
- }
376
-
377
- if (!response.ok) {
378
- const error = new Error(
379
- `Gateway HTTP ${response.status} for ${method}: ${body?.error || body?.message || "unknown_error"}`
380
- );
381
- error.http_status = response.status;
382
- error.slack_error = body?.slack_error || body?.error || "gateway_error";
383
- error.needed = body?.needed;
384
- error.provided = body?.provided;
385
- error.token_source = body?.token_source || "gateway";
386
- throw error;
387
- }
388
-
389
- if (!body?.ok) {
390
- const error = new Error(`Gateway call failed for ${method}: ${body?.error || "unknown_error"}`);
391
- error.slack_error = body?.slack_error || body?.error || "gateway_error";
392
- error.needed = body?.needed;
393
- error.provided = body?.provided;
394
- error.token_source = body?.token_source || "gateway";
395
- throw error;
396
- }
397
-
398
- return body.data;
399
- }
400
-
401
- async function slackHttpViaGateway(input) {
402
- const runtimeGateway = getRuntimeGatewayConfig();
403
- if (!runtimeGateway.url) {
404
- throw new Error("Gateway URL is missing. Set SLACK_GATEWAY_URL to use gateway mode.");
405
- }
406
-
407
- const response = await fetch(`${runtimeGateway.url}/api/slack/http`, {
408
- method: "POST",
409
- headers: buildGatewayAuthHeaders(runtimeGateway.apiKey),
410
- body: JSON.stringify({
411
- ...input,
412
- profile_selector: input.profile_selector || runtimeGateway.profile || undefined,
413
- preferred_token_type: input.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
414
- }),
415
- });
416
-
417
- const text = await response.text();
418
- let body;
419
- try {
420
- body = JSON.parse(text);
421
- } catch {
422
- throw new Error(`Gateway returned non-JSON for HTTP proxy (HTTP ${response.status}).`);
423
- }
424
-
425
- if (!response.ok) {
426
- throw new Error(`Gateway HTTP ${response.status}: ${body?.error || "gateway_error"}`);
427
- }
428
- if (!body?.ok) {
429
- throw new Error(`Gateway HTTP proxy failed: ${body?.error || "gateway_error"}`);
430
- }
431
- return body.data;
432
- }
433
-
434
281
  async function callSlackApiWithToken(method, params = {}, token, tokenSource) {
435
282
  const url = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/${method}`;
436
283
 
@@ -501,21 +348,16 @@ async function callSlackApiWithCandidates(method, params, candidates) {
501
348
  }
502
349
 
503
350
  async function callSlackApi(method, params = {}, tokenOverride, options = {}) {
504
- const runtimeGateway = getRuntimeGatewayConfig();
505
- if (runtimeGateway.url) {
506
- return callSlackApiViaGateway(method, params, tokenOverride, options);
507
- }
508
-
509
351
  const candidates = getSlackTokenCandidates(tokenOverride, options);
510
352
  if (candidates.length === 0) {
511
353
  throw new Error(
512
- "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."
513
355
  );
514
356
  }
515
357
 
516
358
  return callSlackApiWithCandidates(method, params, candidates);
517
359
  }
518
-
360
+
519
361
  function successResult(payload) {
520
362
  return {
521
363
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -574,118 +416,6 @@ function parseCliArgs(argv) {
574
416
  return { options, positionals };
575
417
  }
576
418
 
577
- function base64UrlEncodeString(value) {
578
- return Buffer.from(value, "utf8")
579
- .toString("base64")
580
- .replace(/\+/g, "-")
581
- .replace(/\//g, "_")
582
- .replace(/=+$/g, "");
583
- }
584
-
585
- function base64UrlDecodeToString(value) {
586
- const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
587
- return Buffer.from(padded, "base64").toString("utf8");
588
- }
589
-
590
- function hmacSign(text, secret) {
591
- return crypto
592
- .createHmac("sha256", secret)
593
- .update(text)
594
- .digest("base64")
595
- .replace(/\+/g, "-")
596
- .replace(/\//g, "_")
597
- .replace(/=+$/g, "");
598
- }
599
-
600
- function createSignedInviteToken(payload, secret) {
601
- const encodedPayload = base64UrlEncodeString(JSON.stringify(payload));
602
- const signature = hmacSign(encodedPayload, secret);
603
- return `${encodedPayload}.${signature}`;
604
- }
605
-
606
- function parseAndVerifyInviteToken(token, secret) {
607
- const [encodedPayload, signature] = String(token || "").split(".", 2);
608
- if (!encodedPayload || !signature) {
609
- throw new Error("Invalid invite token format.");
610
- }
611
- const expected = hmacSign(encodedPayload, secret);
612
- const expectedBuf = Buffer.from(expected, "utf8");
613
- const sigBuf = Buffer.from(signature, "utf8");
614
- if (expectedBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expectedBuf, sigBuf)) {
615
- throw new Error("Invalid invite token signature.");
616
- }
617
- let payload;
618
- try {
619
- payload = JSON.parse(base64UrlDecodeToString(encodedPayload));
620
- } catch {
621
- throw new Error("Invalid invite token payload.");
622
- }
623
- if (typeof payload !== "object" || !payload) {
624
- throw new Error("Invalid invite token payload object.");
625
- }
626
- if (!payload.exp || Number(payload.exp) < Date.now()) {
627
- throw new Error("Invite token expired.");
628
- }
629
- return payload;
630
- }
631
-
632
- function requireGatewayInviteSecret() {
633
- if (!GATEWAY_SHARED_SECRET) {
634
- throw new Error("Set SLACK_GATEWAY_SHARED_SECRET before using gateway invite/onboarding.");
635
- }
636
- return GATEWAY_SHARED_SECRET;
637
- }
638
-
639
- function isInteractiveTerminal() {
640
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
641
- }
642
-
643
- function hasAnyLocalAuthMaterial() {
644
- const runtimeGateway = getRuntimeGatewayConfig();
645
- if (runtimeGateway.url) return true;
646
- const tokenCandidates = getSlackTokenCandidates(undefined, {
647
- includeEnvTokens: true,
648
- includeTokenStore: true,
649
- });
650
- return tokenCandidates.length > 0;
651
- }
652
-
653
- async function runAutoOnboardingIfPossible() {
654
- if (!AUTO_ONBOARD_ENABLED) return false;
655
- if (!isInteractiveTerminal()) return false;
656
- if (hasAnyLocalAuthMaterial()) return false;
657
-
658
- if (AUTO_ONBOARD_URL) {
659
- const opened = openExternalUrl(AUTO_ONBOARD_URL);
660
- if (!opened) {
661
- console.log(`[auto-onboard] Open this URL in browser:\n${AUTO_ONBOARD_URL}`);
662
- } else {
663
- console.log("[auto-onboard] Browser opened for onboarding.");
664
- }
665
- return true;
666
- }
667
-
668
- if (AUTO_ONBOARD_GATEWAY && AUTO_ONBOARD_TOKEN) {
669
- const args = ["--gateway", AUTO_ONBOARD_GATEWAY, "--token", AUTO_ONBOARD_TOKEN];
670
- if (AUTO_ONBOARD_PROFILE) args.push("--profile", AUTO_ONBOARD_PROFILE);
671
- await runOnboardStart(args);
672
- return true;
673
- }
674
-
675
- if (AUTO_ONBOARD_GATEWAY) {
676
- const args = ["--gateway", AUTO_ONBOARD_GATEWAY];
677
- if (AUTO_ONBOARD_PROFILE) {
678
- args.push("--profile", AUTO_ONBOARD_PROFILE);
679
- } else if (AUTO_ONBOARD_PROFILE_PREFIX) {
680
- args.push("--profile", createAutoOnboardProfileName(AUTO_ONBOARD_PROFILE_PREFIX));
681
- }
682
- await runOnboardStart(args);
683
- return true;
684
- }
685
-
686
- return false;
687
- }
688
-
689
419
  function printOauthHelp() {
690
420
  const lines = [
691
421
  "Slack Max OAuth helper",
@@ -1013,117 +743,8 @@ async function runOauthCli(args) {
1013
743
  throw new Error(`Unknown oauth command: ${subcommand}`);
1014
744
  }
1015
745
 
1016
- function printOnboardHelp() {
1017
- const lines = [
1018
- "Slack Max onboarding helper",
1019
- "",
1020
- "Usage:",
1021
- " slack-max-api-mcp onboard run --gateway https://gateway.example.com [--token <invite_token>]",
1022
- " [--profile NAME] [--team T123] [--scope a,b] [--user-scope c,d]",
1023
- " slack-max-api-mcp onboard quick --gateway https://gateway.example.com",
1024
- " slack-max-api-mcp onboard help",
1025
- "",
1026
- "If --token is omitted, it uses gateway public onboarding endpoint (/onboard/bootstrap).",
1027
- "This command writes local client config and opens the Slack OAuth approval page automatically.",
1028
- ];
1029
- console.log(lines.join("\n"));
1030
- }
1031
-
1032
- async function runOnboardStart(args) {
1033
- const { options } = parseCliArgs(args);
1034
- const gateway = String(options.gateway || options.url || "").replace(/\/+$/, "");
1035
- const token = String(options.token || "");
1036
- if (!gateway) {
1037
- throw new Error(
1038
- "Usage: slack-max-api-mcp onboard run --gateway <url> [--token <invite_token>] [--profile <name>]"
1039
- );
1040
- }
1041
-
1042
- const requestedProfile =
1043
- String(options.profile || "").trim() || createAutoOnboardProfileName(AUTO_ONBOARD_PROFILE_PREFIX);
1044
- const requestedTeam = String(options.team || "").trim();
1045
- const requestedScope = parseScopeList(options.scope || "").join(",");
1046
- const requestedUserScope = parseScopeList(options["user-scope"] || options.user_scope || "").join(",");
1047
-
1048
- const onboardingUrl = token
1049
- ? `${gateway}/onboard/resolve?token=${encodeURIComponent(token)}`
1050
- : (() => {
1051
- const params = new URLSearchParams();
1052
- if (requestedProfile) params.set("profile", requestedProfile);
1053
- if (requestedTeam) params.set("team", requestedTeam);
1054
- if (requestedScope) params.set("scope", requestedScope);
1055
- if (requestedUserScope) params.set("user_scope", requestedUserScope);
1056
- const query = params.toString();
1057
- return `${gateway}/onboard/bootstrap${query ? `?${query}` : ""}`;
1058
- })();
1059
-
1060
- const response = await fetch(onboardingUrl, {
1061
- method: "GET",
1062
- headers: { Accept: "application/json" },
1063
- });
1064
-
1065
- const text = await response.text();
1066
- let data;
1067
- try {
1068
- data = JSON.parse(text);
1069
- } catch {
1070
- throw new Error(`Onboarding response was non-JSON (HTTP ${response.status}).`);
1071
- }
1072
-
1073
- if (!response.ok || !data?.ok) {
1074
- if (!token && response.status === 404) {
1075
- throw new Error("Onboarding failed: public onboarding is disabled on gateway (enable SLACK_GATEWAY_PUBLIC_ONBOARD=true).");
1076
- }
1077
- throw new Error(`Onboarding failed: ${data?.error || `http_${response.status}`}`);
1078
- }
1079
-
1080
- const resolvedGatewayUrl = String(data.gateway_url || gateway).replace(/\/+$/, "");
1081
- const resolvedApiKey = String(data.gateway_api_key || "");
1082
- const profile = String(data.profile || requestedProfile || "");
1083
- const oauthStartUrl = String(data.oauth_start_url || "");
1084
-
1085
- if (data.requires_gateway_api_key && !resolvedApiKey) {
1086
- throw new Error(
1087
- "Gateway requires API key but onboarding response did not provide one. Enable public gateway access or set SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY."
1088
- );
1089
- }
1090
-
1091
- saveClientConfig({
1092
- version: 1,
1093
- gateway_url: resolvedGatewayUrl,
1094
- gateway_api_key: resolvedApiKey,
1095
- profile,
1096
- updated_at: new Date().toISOString(),
1097
- });
1098
-
1099
- if (oauthStartUrl) {
1100
- const opened = openExternalUrl(oauthStartUrl);
1101
- if (!opened) {
1102
- console.log(`[onboard] Open this URL in browser:\n${oauthStartUrl}`);
1103
- }
1104
- }
1105
-
1106
- console.log(`[onboard] client config saved: ${CLIENT_CONFIG_PATH}`);
1107
- console.log(`[onboard] gateway: ${resolvedGatewayUrl}`);
1108
- if (profile) console.log(`[onboard] profile: ${profile}`);
1109
- if (data.mode === "public_onboard") {
1110
- console.log("[onboard] mode: public_onboard (tokenless)");
1111
- }
1112
- console.log("[onboard] Next: approve in browser, then use Codex MCP as usual.");
1113
- }
1114
-
1115
- async function runOnboardCli(args) {
1116
- const subcommand = (args[0] || "help").toLowerCase();
1117
- const rest = args.slice(1);
1118
- if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1119
- printOnboardHelp();
1120
- return;
1121
- }
1122
- if (subcommand === "run" || subcommand === "start" || subcommand === "quick") {
1123
- await runOnboardStart(rest);
1124
- return;
1125
- }
1126
- throw new Error(`Unknown onboard command: ${subcommand}`);
746
+ function sleep(ms) {
747
+ return new Promise((resolve) => setTimeout(resolve, ms));
1127
748
  }
1128
749
 
1129
750
  function sendText(res, statusCode, text) {
@@ -1136,355 +757,242 @@ function sendJson(res, statusCode, payload) {
1136
757
  res.end(JSON.stringify(payload, null, 2));
1137
758
  }
1138
759
 
1139
- async function readRequestText(req, maxBytes = 1024 * 1024) {
1140
- return new Promise((resolve, reject) => {
1141
- const chunks = [];
1142
- let total = 0;
1143
- req.on("data", (chunk) => {
1144
- total += chunk.length;
1145
- if (total > maxBytes) {
1146
- reject(new Error("Request body too large."));
1147
- req.destroy();
1148
- return;
1149
- }
1150
- chunks.push(chunk);
1151
- });
1152
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1153
- req.on("error", (error) => reject(error));
1154
- });
1155
- }
1156
-
1157
- async function readRequestJson(req, maxBytes) {
1158
- const text = await readRequestText(req, maxBytes);
1159
- if (!text.trim()) return {};
1160
- try {
1161
- return JSON.parse(text);
1162
- } catch {
1163
- throw new Error("Invalid JSON body.");
1164
- }
760
+ function isClaimSessionExpired(session) {
761
+ return !session || Date.now() > Number(session.expires_at || 0);
1165
762
  }
1166
763
 
1167
- function getRequestApiKey(req) {
1168
- const authHeader = req.headers.authorization || "";
1169
- if (authHeader.toLowerCase().startsWith("bearer ")) {
1170
- 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);
1171
769
  }
1172
- const xApiKey = req.headers["x-api-key"];
1173
- return typeof xApiKey === "string" ? xApiKey.trim() : "";
1174
- }
1175
-
1176
- function isGatewayAuthorized(req) {
1177
- if (GATEWAY_ALLOW_PUBLIC) return true;
1178
- const allowedKeys = [GATEWAY_SHARED_SECRET, GATEWAY_CLIENT_API_KEY].filter(Boolean);
1179
- if (allowedKeys.length === 0) return false;
1180
- const provided = getRequestApiKey(req);
1181
- return Boolean(provided && allowedKeys.includes(provided));
1182
770
  }
1183
771
 
1184
- function requireGatewayClientCredentials() {
1185
- const clientId = process.env.SLACK_CLIENT_ID || "";
1186
- const clientSecret = process.env.SLACK_CLIENT_SECRET || "";
1187
- if (!clientId || !clientSecret) {
1188
- 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}).`);
1189
780
  }
1190
- return { clientId, clientSecret };
1191
- }
1192
-
1193
- function profileSummariesFromStore(store) {
1194
- const summaries = [];
1195
- for (const [key, profile] of Object.entries(store.profiles || {})) {
1196
- summaries.push({
1197
- key,
1198
- profile_name: profile.profile_name || "",
1199
- team_id: profile.team_id || "",
1200
- team_name: profile.team_name || "",
1201
- authed_user_id: profile.authed_user_id || "",
1202
- has_bot_token: Boolean(profile.bot_token),
1203
- has_user_token: Boolean(profile.user_token),
1204
- updated_at: profile.updated_at || null,
1205
- is_default: store.default_profile === key,
1206
- });
781
+ if (!response.ok || !data?.ok) {
782
+ throw new Error(`${label} failed: ${data?.error || `http_${response.status}`}`);
1207
783
  }
1208
- return summaries;
1209
- }
1210
-
1211
- function buildGatewayRedirectUri() {
1212
- const url = new URL(OAUTH_CALLBACK_PATH, `${GATEWAY_PUBLIC_BASE_URL.replace(/\/+$/, "")}/`);
1213
- return url.toString();
784
+ return data;
1214
785
  }
1215
786
 
1216
- function parseScopesFromQuery(searchParams, key, fallback) {
1217
- const value = searchParams.get(key);
1218
- 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"));
1219
803
  }
1220
804
 
1221
- function buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload) {
1222
- const params = new URLSearchParams();
1223
- if (payload.profile) params.set("profile", payload.profile);
1224
- if (payload.team) params.set("team", payload.team);
1225
- if (payload.scope) params.set("scope", payload.scope);
1226
- if (payload.user_scope) params.set("user_scope", payload.user_scope);
1227
- 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"));
1228
825
  }
1229
826
 
1230
- function buildPublicOnboardPayload(gatewayBaseUrl, params = {}) {
1231
- const profile = String(params.profile || "").trim() || createAutoOnboardProfileName(GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX);
1232
- const team = String(params.team || process.env.SLACK_OAUTH_TEAM_ID || "").trim();
1233
- const scope = parseScopeList(params.scope || DEFAULT_OAUTH_BOT_SCOPES).join(",");
1234
- const userScope = parseScopeList(params.user_scope || DEFAULT_OAUTH_USER_SCOPES).join(",");
1235
- const payload = {
1236
- gateway_url: gatewayBaseUrl,
1237
- gateway_api_key: "",
1238
- profile,
1239
- team,
1240
- scope,
1241
- user_scope: userScope,
1242
- };
1243
- if (GATEWAY_ALLOW_PUBLIC) {
1244
- payload.gateway_api_key = "";
1245
- } else if (GATEWAY_PUBLIC_ONBOARD_API_KEY) {
1246
- payload.gateway_api_key = GATEWAY_PUBLIC_ONBOARD_API_KEY;
1247
- } else if (GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY) {
1248
- 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.");
1249
832
  }
1250
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1251
- return {
1252
- ok: true,
1253
- mode: "public_onboard",
1254
- gateway_url: payload.gateway_url,
1255
- gateway_api_key: payload.gateway_api_key,
1256
- profile: payload.profile,
1257
- oauth_start_url: oauthStartUrl,
1258
- requires_gateway_api_key: !GATEWAY_ALLOW_PUBLIC,
1259
- };
1260
- }
1261
833
 
1262
- function buildOnboardPowerShellScript({ gatewayBaseUrl, token, profile, team, scope, userScope }) {
1263
- const safeGateway = String(gatewayBaseUrl || "").replace(/'/g, "''");
1264
- const safeToken = String(token || "").replace(/'/g, "''");
1265
- const safeProfile = String(profile || "").replace(/'/g, "''");
1266
- const safeTeam = String(team || "").replace(/'/g, "''");
1267
- const safeScope = String(scope || "").replace(/'/g, "''");
1268
- const safeUserScope = String(userScope || "").replace(/'/g, "''");
1269
- const safePackageSpec = String(ONBOARD_PACKAGE_SPEC || "").replace(/'/g, "''");
1270
- const onboardCommandParts = [`npx -y '${safePackageSpec}' onboard run --gateway '${safeGateway}'`];
1271
- if (safeToken) onboardCommandParts.push(`--token '${safeToken}'`);
1272
- if (safeProfile) onboardCommandParts.push(`--profile '${safeProfile}'`);
1273
- if (safeTeam) onboardCommandParts.push(`--team '${safeTeam}'`);
1274
- if (safeScope) onboardCommandParts.push(`--scope '${safeScope}'`);
1275
- 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
+ );
1276
851
 
1277
- const lines = [
1278
- "$ErrorActionPreference = 'Stop'",
1279
- "if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { throw 'npx is required. Install Node.js first.' }",
1280
- ];
1281
- if (ONBOARD_SKIP_TLS_VERIFY) {
1282
- 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.");
1283
856
  }
1284
- lines.push(onboardCommandParts.join(" "));
1285
- if (ONBOARD_SKIP_TLS_VERIFY) {
1286
- 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.");
1287
863
  }
1288
- return lines.join("\r\n");
1289
- }
1290
864
 
1291
- function createGatewayInviteTokenFromOptions(options = {}) {
1292
- const secret = requireGatewayInviteSecret();
1293
- const profile = String(options.profile || "").trim();
1294
- const team = String(options.team || "").trim();
1295
- const scope = parseScopeList(options.scope || DEFAULT_OAUTH_BOT_SCOPES).join(",");
1296
- const userScope = parseScopeList(options["user-scope"] || options.user_scope || DEFAULT_OAUTH_USER_SCOPES).join(
1297
- ","
1298
- );
1299
- const ttlDays = Math.max(1, Number(options.days || INVITE_TOKEN_DEFAULT_DAYS));
1300
- const gatewayUrl = String(options.gateway || options.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1301
-
1302
- const payload = {
1303
- v: 1,
1304
- exp: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
1305
- gateway_url: gatewayUrl,
1306
- gateway_api_key: String(options["client-api-key"] || options.client_api_key || GATEWAY_CLIENT_API_KEY || ""),
1307
- profile,
1308
- team,
1309
- scope,
1310
- user_scope: userScope,
1311
- };
1312
- const token = createSignedInviteToken(payload, secret);
1313
- return { token, payload };
1314
- }
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)}`;
1315
869
 
1316
- async function proxySlackHttpRequest(payload) {
1317
- const tokenCandidate = requireSlackTokenCandidate(payload.token_override, {
1318
- profileSelector: payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1319
- preferredTokenType: payload.preferred_token_type || process.env.SLACK_DEFAULT_TOKEN_TYPE || undefined,
1320
- });
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
+ );
1321
877
 
1322
- const method = payload.http_method || "GET";
1323
- const endpoint = new URL(payload.url);
1324
- for (const [k, v] of Object.entries(toRecordObject(payload.query))) {
1325
- if (v === undefined || v === null) continue;
1326
- endpoint.searchParams.set(k, String(v));
1327
- }
878
+ if (claimData.status === "pending") {
879
+ continue;
880
+ }
1328
881
 
1329
- const reqHeaders = {
1330
- Authorization: `Bearer ${tokenCandidate.token}`,
1331
- ...toRecordObject(payload.headers),
1332
- };
882
+ if (claimData.status !== "ready") {
883
+ throw new Error(`Unexpected onboard claim status: ${claimData.status || "unknown"}`);
884
+ }
1333
885
 
1334
- let body;
1335
- const formBody = toRecordObject(payload.form_body);
1336
- const jsonBody = toRecordObject(payload.json_body);
1337
- if (Object.keys(formBody).length > 0) {
1338
- reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1339
- body = toUrlEncodedBody(formBody);
1340
- } else if (Object.keys(jsonBody).length > 0) {
1341
- reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1342
- body = JSON.stringify(jsonBody);
1343
- }
886
+ const oauthResponse = claimData.oauth_response;
887
+ if (!oauthResponse || typeof oauthResponse !== "object") {
888
+ throw new Error("Onboard claim result is missing oauth_response.");
889
+ }
1344
890
 
1345
- const res = await fetch(endpoint.toString(), { method, headers: reqHeaders, body });
1346
- const text = await res.text();
1347
- let parsedBody = text;
1348
- try {
1349
- parsedBody = JSON.parse(text);
1350
- } catch {
1351
- // 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;
1352
897
  }
1353
898
 
1354
- return {
1355
- url: endpoint.toString(),
1356
- status: res.status,
1357
- ok: res.ok,
1358
- headers: Object.fromEntries(res.headers.entries()),
1359
- body: parsedBody,
1360
- token_source: tokenCandidate.source,
1361
- };
899
+ throw new Error("Timed out waiting for central onboarding completion.");
1362
900
  }
1363
901
 
1364
- async function startGatewayServer() {
1365
- const pendingStates = new Map();
1366
- const callbackPath = OAUTH_CALLBACK_PATH;
1367
- const redirectUri = buildGatewayRedirectUri();
1368
- 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();
1369
919
 
1370
920
  const server = http.createServer(async (req, res) => {
1371
921
  try {
922
+ cleanupExpiredClaimSessions(claimSessions, stateToClaim);
1372
923
  const method = req.method || "GET";
1373
- const requestUrl = new URL(req.url || "/", `http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
924
+ const requestUrl = new URL(req.url || "/", `http://${host}:${port}`);
1374
925
 
1375
926
  if (method === "GET" && requestUrl.pathname === "/health") {
1376
927
  sendJson(res, 200, {
1377
928
  ok: true,
1378
929
  service: SERVER_NAME,
1379
- mode: "gateway",
1380
- token_store_path: TOKEN_STORE_PATH,
1381
- client_config_path: CLIENT_CONFIG_PATH,
1382
- callback_url: redirectUri,
930
+ mode: "onboard_server",
931
+ public_base_url: publicBaseUrl,
932
+ callback_path: callbackPath,
1383
933
  });
1384
934
  return;
1385
935
  }
1386
936
 
1387
- if (method === "GET" && requestUrl.pathname === "/onboard.ps1") {
1388
- const token = requestUrl.searchParams.get("token") || "";
1389
- let script = "";
1390
- if (token) {
1391
- const secret = requireGatewayInviteSecret();
1392
- const payload = parseAndVerifyInviteToken(token, secret);
1393
- script = buildOnboardPowerShellScript({
1394
- gatewayBaseUrl: payload.gateway_url || gatewayBaseUrl,
1395
- token,
1396
- });
1397
- } else {
1398
- if (!GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1399
- sendJson(res, 404, { ok: false, error: "public_onboard_disabled" });
1400
- return;
1401
- }
1402
- const profile =
1403
- requestUrl.searchParams.get("profile") ||
1404
- createAutoOnboardProfileName(GATEWAY_PUBLIC_ONBOARD_PROFILE_PREFIX);
1405
- const team = requestUrl.searchParams.get("team") || "";
1406
- const scope = requestUrl.searchParams.get("scope") || "";
1407
- const userScope = requestUrl.searchParams.get("user_scope") || "";
1408
- script = buildOnboardPowerShellScript({
1409
- gatewayBaseUrl,
1410
- profile,
1411
- team,
1412
- scope,
1413
- userScope,
1414
- });
1415
- }
1416
- res.writeHead(200, {
1417
- "Content-Type": "text/plain; charset=utf-8",
1418
- "Cache-Control": "no-store",
1419
- });
1420
- res.end(script);
1421
- return;
1422
- }
1423
-
1424
937
  if (method === "GET" && requestUrl.pathname === "/onboard/bootstrap") {
1425
- if (!GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1426
- 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" });
1427
947
  return;
1428
948
  }
1429
- const payload = buildPublicOnboardPayload(gatewayBaseUrl, {
1430
- profile: requestUrl.searchParams.get("profile") || "",
1431
- team: requestUrl.searchParams.get("team") || "",
1432
- scope: requestUrl.searchParams.get("scope") || "",
1433
- 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,
1434
961
  });
1435
- sendJson(res, 200, payload);
1436
- return;
1437
- }
1438
962
 
1439
- if (method === "GET" && requestUrl.pathname === "/onboard/resolve") {
1440
- const token = requestUrl.searchParams.get("token") || "";
1441
- const secret = requireGatewayInviteSecret();
1442
- const payload = parseAndVerifyInviteToken(token, secret);
1443
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
963
+ const startParams = new URLSearchParams();
964
+ startParams.set("claim", claimToken);
965
+ const startUrl = `${publicBaseUrl}/onboard/start?${startParams.toString()}`;
966
+
1444
967
  sendJson(res, 200, {
1445
968
  ok: true,
1446
- mode: "invite_token",
1447
- gateway_url: payload.gateway_url || gatewayBaseUrl,
1448
- gateway_api_key: payload.gateway_api_key || "",
1449
- profile: payload.profile || "",
1450
- oauth_start_url: oauthStartUrl,
1451
- 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(),
1452
973
  });
1453
974
  return;
1454
975
  }
1455
976
 
1456
- if (method === "GET" && requestUrl.pathname === "/oauth/start") {
1457
- const { clientId } = requireGatewayClientCredentials();
1458
- const profileName = requestUrl.searchParams.get("profile") || "";
1459
- const teamId = requestUrl.searchParams.get("team") || process.env.SLACK_OAUTH_TEAM_ID || "";
1460
- const botScopes = parseScopesFromQuery(requestUrl.searchParams, "scope", DEFAULT_OAUTH_BOT_SCOPES);
1461
- const userScopes = parseScopesFromQuery(
1462
- requestUrl.searchParams,
1463
- "user_scope",
1464
- DEFAULT_OAUTH_USER_SCOPES
1465
- );
1466
-
1467
- if (botScopes.length === 0 && userScopes.length === 0) {
1468
- 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.");
1469
982
  return;
1470
983
  }
1471
984
 
1472
985
  const state = crypto.randomBytes(24).toString("hex");
1473
- pendingStates.set(state, {
1474
- created_at: Date.now(),
1475
- profile_name: profileName,
1476
- team_id: teamId,
1477
- bot_scopes: botScopes,
1478
- user_scopes: userScopes,
1479
- });
986
+ session.state = state;
987
+ stateToClaim.set(state, claimToken);
1480
988
 
1481
989
  const authorizeUrl = buildOauthAuthorizeUrl({
1482
990
  clientId,
1483
991
  state,
1484
992
  redirectUri,
1485
- botScopes,
1486
- userScopes,
1487
- teamId,
993
+ botScopes: session.bot_scopes,
994
+ userScopes: session.user_scopes,
995
+ teamId: session.team,
1488
996
  });
1489
997
 
1490
998
  res.writeHead(302, { Location: authorizeUrl });
@@ -1493,7 +1001,6 @@ async function startGatewayServer() {
1493
1001
  }
1494
1002
 
1495
1003
  if (method === "GET" && requestUrl.pathname === callbackPath) {
1496
- const { clientId, clientSecret } = requireGatewayClientCredentials();
1497
1004
  const receivedError = requestUrl.searchParams.get("error");
1498
1005
  if (receivedError) {
1499
1006
  sendText(res, 400, `Slack OAuth failed: ${receivedError}`);
@@ -1507,106 +1014,45 @@ async function startGatewayServer() {
1507
1014
  return;
1508
1015
  }
1509
1016
 
1510
- const pending = pendingStates.get(state);
1511
- pendingStates.delete(state);
1512
- if (!pending) {
1513
- sendText(res, 400, "Invalid or expired OAuth state.");
1017
+ const claimToken = stateToClaim.get(state);
1018
+ if (!claimToken) {
1019
+ sendText(res, 400, "Invalid OAuth state.");
1514
1020
  return;
1515
1021
  }
1516
- if (Date.now() - pending.created_at > GATEWAY_STATE_TTL_MS) {
1517
- 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.");
1518
1026
  return;
1519
1027
  }
1520
1028
 
1521
1029
  const oauthResponse = await exchangeOauthCode({ clientId, clientSecret, code, redirectUri });
1522
- const { key, profile } = upsertOauthProfile(oauthResponse, pending.profile_name);
1523
- sendText(
1524
- res,
1525
- 200,
1526
- [
1527
- "Slack OAuth authorization completed.",
1528
- `Saved profile: ${profile.profile_name || key}`,
1529
- `Profile key: ${key}`,
1530
- "You can close this tab.",
1531
- ].join("\n")
1532
- );
1533
- return;
1534
- }
1535
-
1536
- if (method === "GET" && requestUrl.pathname === "/oauth/link") {
1537
- const params = new URLSearchParams();
1538
- const profile = requestUrl.searchParams.get("profile") || "";
1539
- const team = requestUrl.searchParams.get("team") || "";
1540
- const scope = requestUrl.searchParams.get("scope") || "";
1541
- const userScope = requestUrl.searchParams.get("user_scope") || "";
1542
- if (profile) params.set("profile", profile);
1543
- if (team) params.set("team", team);
1544
- if (scope) params.set("scope", scope);
1545
- if (userScope) params.set("user_scope", userScope);
1546
- sendJson(res, 200, {
1547
- ok: true,
1548
- url: `${gatewayBaseUrl}/oauth/start${params.toString() ? `?${params.toString()}` : ""}`,
1549
- });
1550
- return;
1551
- }
1552
-
1553
- if (method === "GET" && requestUrl.pathname === "/profiles") {
1554
- if (!isGatewayAuthorized(req)) {
1555
- sendJson(res, 401, { ok: false, error: "unauthorized" });
1556
- return;
1557
- }
1558
- const tokenStore = loadTokenStore();
1559
- sendJson(res, 200, {
1560
- ok: true,
1561
- default_profile: tokenStore.default_profile,
1562
- profiles: profileSummariesFromStore(tokenStore),
1563
- });
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.");
1564
1033
  return;
1565
1034
  }
1566
1035
 
1567
- if (method === "POST" && requestUrl.pathname === "/api/slack/call") {
1568
- if (!isGatewayAuthorized(req)) {
1569
- 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" });
1570
1041
  return;
1571
1042
  }
1572
1043
 
1573
- const payload = await readRequestJson(req, 1024 * 1024);
1574
- const methodName = payload.method;
1575
- if (!methodName || typeof methodName !== "string") {
1576
- sendJson(res, 400, { ok: false, error: "missing_method" });
1044
+ if (!session.oauth_response) {
1045
+ sendJson(res, 200, { ok: true, status: "pending" });
1577
1046
  return;
1578
1047
  }
1579
1048
 
1580
- const candidates = getSlackTokenCandidates(payload.token_override, {
1581
- profileSelector:
1582
- payload.profile_selector || process.env.SLACK_PROFILE || GATEWAY_PROFILE || undefined,
1583
- preferredTokenType:
1584
- 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,
1585
1054
  });
1586
- if (candidates.length === 0) {
1587
- sendJson(res, 400, { ok: false, error: "missing_token" });
1588
- return;
1589
- }
1590
-
1591
- const data = await callSlackApiWithCandidates(methodName, payload.params || {}, candidates);
1592
- sendJson(res, 200, { ok: true, data });
1593
- return;
1594
- }
1595
-
1596
- if (method === "POST" && requestUrl.pathname === "/api/slack/http") {
1597
- if (!isGatewayAuthorized(req)) {
1598
- sendJson(res, 401, { ok: false, error: "unauthorized" });
1599
- return;
1600
- }
1601
-
1602
- const payload = await readRequestJson(req, 1024 * 1024);
1603
- if (!payload.url || typeof payload.url !== "string") {
1604
- sendJson(res, 400, { ok: false, error: "missing_url" });
1605
- return;
1606
- }
1607
-
1608
- const data = await proxySlackHttpRequest(payload);
1609
- sendJson(res, 200, { ok: true, data });
1055
+ claimSessions.delete(claimToken);
1610
1056
  return;
1611
1057
  }
1612
1058
 
@@ -1615,102 +1061,50 @@ async function startGatewayServer() {
1615
1061
  sendJson(res, 500, {
1616
1062
  ok: false,
1617
1063
  error: error instanceof Error ? error.message : String(error),
1618
- slack_error: error?.slack_error || null,
1619
- needed: error?.needed || null,
1620
- provided: error?.provided || null,
1621
- token_source: error?.token_source || null,
1622
1064
  });
1623
- } finally {
1624
- for (const [state, value] of pendingStates.entries()) {
1625
- if (Date.now() - value.created_at > GATEWAY_STATE_TTL_MS) {
1626
- pendingStates.delete(state);
1627
- }
1628
- }
1629
1065
  }
1630
1066
  });
1631
1067
 
1632
1068
  await new Promise((resolve, reject) => {
1633
1069
  server.once("error", reject);
1634
- server.listen(GATEWAY_PORT, GATEWAY_HOST, resolve);
1070
+ server.listen(port, host, resolve);
1635
1071
  });
1636
1072
 
1637
- console.error(
1638
- `[${SERVER_NAME}] gateway listening at http://${GATEWAY_HOST}:${GATEWAY_PORT} | public_base=${gatewayBaseUrl}`
1639
- );
1640
- console.error(`[${SERVER_NAME}] oauth start URL: ${gatewayBaseUrl}/oauth/start`);
1641
- console.error(`[${SERVER_NAME}] profile list URL: ${gatewayBaseUrl}/profiles`);
1642
- if (GATEWAY_PUBLIC_ONBOARD_ENABLED) {
1643
- console.error(`[${SERVER_NAME}] public onboard URL: ${gatewayBaseUrl}/onboard/bootstrap`);
1644
- }
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`);
1645
1076
  }
1646
1077
 
1647
- function printGatewayHelp() {
1648
- const lines = [
1649
- "Slack Max Gateway helper",
1650
- "",
1651
- "Usage:",
1652
- " slack-max-api-mcp gateway start",
1653
- " slack-max-api-mcp gateway invite --profile woobin --team T123",
1654
- " # tokenless onboarding endpoint (when enabled):",
1655
- " # https://gateway.example.com/onboard/bootstrap",
1656
- " slack-max-api-mcp gateway help",
1657
- "",
1658
- "Gateway env vars (server-side):",
1659
- " SLACK_CLIENT_ID, SLACK_CLIENT_SECRET",
1660
- " SLACK_GATEWAY_HOST, SLACK_GATEWAY_PORT, SLACK_GATEWAY_PUBLIC_BASE_URL",
1661
- " SLACK_GATEWAY_SHARED_SECRET (recommended)",
1662
- " SLACK_GATEWAY_CLIENT_API_KEY (optional, defaults to shared secret)",
1663
- " SLACK_GATEWAY_PUBLIC_ONBOARD=true # allow tokenless onboarding endpoint",
1664
- " SLACK_GATEWAY_PUBLIC_ONBOARD_API_KEY=<client key> # optional, used when gateway is not fully public",
1665
- " SLACK_GATEWAY_PUBLIC_ONBOARD_EXPOSE_API_KEY=true # fallback: expose client key as-is",
1666
- " SLACK_OAUTH_BOT_SCOPES, SLACK_OAUTH_USER_SCOPES",
1667
- "",
1668
- "Client env vars (mcp caller-side):",
1669
- " SLACK_GATEWAY_URL, SLACK_GATEWAY_API_KEY",
1670
- " SLACK_PROFILE or SLACK_GATEWAY_PROFILE",
1671
- ];
1672
- console.log(lines.join("\n"));
1673
- }
1078
+ async function runOnboardCli(args) {
1079
+ const subcommand = (args[0] || "help").toLowerCase();
1080
+ const rest = args.slice(1);
1674
1081
 
1675
- function runGatewayInvite(args) {
1676
- const { options } = parseCliArgs(args);
1677
- const { token, payload } = createGatewayInviteTokenFromOptions(options);
1678
- const gatewayBaseUrl = String(payload.gateway_url || GATEWAY_PUBLIC_BASE_URL).replace(/\/+$/, "");
1679
- const onboardScriptUrl = `${gatewayBaseUrl}/onboard.ps1?token=${encodeURIComponent(token)}`;
1680
- const oauthStartUrl = buildOauthStartUrlFromInvitePayload(gatewayBaseUrl, payload);
1681
- const command = `powershell -ExecutionPolicy Bypass -Command "irm '${onboardScriptUrl}' | iex"`;
1682
- const commandCurlFallback = [
1683
- `$tmp = Join-Path $env:TEMP 'slack-onboard.ps1'`,
1684
- `curl.exe -k -sS '${onboardScriptUrl}' -o $tmp`,
1685
- `powershell -ExecutionPolicy Bypass -File $tmp`,
1686
- `Remove-Item $tmp -Force`,
1687
- ].join("; ");
1688
-
1689
- console.log("[gateway] invite token created");
1690
- console.log(`[gateway] expires_at: ${new Date(Number(payload.exp)).toISOString()}`);
1691
- console.log(`[gateway] onboarding_script: ${onboardScriptUrl}`);
1692
- console.log(`[gateway] oauth_start_url: ${oauthStartUrl}`);
1693
- console.log("[gateway] one-click command for team member:");
1694
- console.log(command);
1695
- console.log("[gateway] fallback command (self-signed TLS):");
1696
- 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}`);
1697
1092
  }
1698
1093
 
1699
- async function runGatewayCli(args) {
1094
+ async function runOnboardServerCli(args) {
1700
1095
  const subcommand = (args[0] || "help").toLowerCase();
1096
+ const rest = args.slice(1);
1097
+
1701
1098
  if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
1702
- printGatewayHelp();
1099
+ printOnboardServerHelp();
1703
1100
  return;
1704
1101
  }
1705
1102
  if (subcommand === "start") {
1706
- await startGatewayServer();
1707
- return;
1708
- }
1709
- if (subcommand === "invite") {
1710
- runGatewayInvite(args.slice(1));
1103
+ await runOnboardServerStart(rest);
1711
1104
  return;
1712
1105
  }
1713
- throw new Error(`Unknown gateway command: ${subcommand}`);
1106
+
1107
+ throw new Error(`Unknown onboard-server command: ${subcommand}`);
1714
1108
  }
1715
1109
 
1716
1110
  function loadCatalog() {
@@ -1727,21 +1121,301 @@ function loadCatalog() {
1727
1121
  }
1728
1122
  }
1729
1123
 
1730
- function toolNameFromMethod(method, usedNames) {
1731
- const base = `${METHOD_TOOL_PREFIX}_${method.replace(/[^a-zA-Z0-9]/g, "_")}`;
1732
- if (!usedNames.has(base)) {
1733
- 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);
1734
1128
  return base;
1735
1129
  }
1736
1130
 
1737
1131
  let idx = 2;
1738
1132
  while (usedNames.has(`${base}_${idx}`)) idx += 1;
1739
1133
  const name = `${base}_${idx}`;
1740
- usedNames.add(name);
1741
- return name;
1742
- }
1743
-
1744
- 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) {
1745
1419
  server.registerTool(
1746
1420
  "slack_api_call",
1747
1421
  {
@@ -1779,65 +1453,17 @@ function registerCoreTools(server) {
1779
1453
  },
1780
1454
  async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
1781
1455
  safeToolRun(async () => {
1782
- const runtimeGateway = getRuntimeGatewayConfig();
1783
- if (runtimeGateway.url) {
1784
- return slackHttpViaGateway({
1785
- url,
1786
- http_method,
1787
- query,
1788
- json_body,
1789
- form_body,
1790
- headers,
1791
- token_override,
1792
- });
1793
- }
1794
-
1795
- const tokenCandidate = requireSlackTokenCandidate(token_override);
1796
- const method = http_method || "GET";
1797
-
1798
- const endpoint = new URL(url);
1799
- for (const [k, v] of Object.entries(toRecordObject(query))) {
1800
- if (v === undefined || v === null) continue;
1801
- endpoint.searchParams.set(k, String(v));
1802
- }
1803
-
1804
- const reqHeaders = {
1805
- Authorization: `Bearer ${tokenCandidate.token}`,
1806
- ...(headers || {}),
1807
- };
1808
-
1809
- let body;
1810
- if (form_body && Object.keys(form_body).length > 0) {
1811
- reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
1812
- body = toUrlEncodedBody(form_body);
1813
- } else if (json_body && Object.keys(json_body).length > 0) {
1814
- reqHeaders["Content-Type"] = "application/json; charset=utf-8";
1815
- body = JSON.stringify(json_body);
1816
- }
1817
-
1818
- const res = await fetch(endpoint.toString(), {
1819
- method,
1820
- headers: reqHeaders,
1821
- body,
1822
- });
1823
-
1824
- const text = await res.text();
1825
- let parsedBody = text;
1826
- try {
1827
- parsedBody = JSON.parse(text);
1828
- } catch {
1829
- // Keep plain text when response is not JSON.
1830
- }
1831
-
1832
- return {
1833
- url: endpoint.toString(),
1834
- status: res.status,
1835
- ok: res.ok,
1836
- headers: Object.fromEntries(res.headers.entries()),
1837
- body: parsedBody,
1838
- };
1839
- })
1840
- );
1456
+ return executeSlackHttpRequest({
1457
+ url,
1458
+ http_method,
1459
+ query,
1460
+ json_body,
1461
+ form_body,
1462
+ headers,
1463
+ token_override,
1464
+ });
1465
+ })
1466
+ );
1841
1467
 
1842
1468
  server.registerTool(
1843
1469
  "search_messages_files",
@@ -2205,10 +1831,12 @@ function registerCoreTools(server) {
2205
1831
  profile = info.user?.profile || null;
2206
1832
  }
2207
1833
 
2208
- return { user: info.user || null, profile };
2209
- })
2210
- );
2211
- }
1834
+ return { user: info.user || null, profile };
1835
+ })
1836
+ );
1837
+
1838
+ return { registered: 12 };
1839
+ }
2212
1840
 
2213
1841
  function registerCatalogMethodTools(server, catalog) {
2214
1842
  if (!ENABLE_METHOD_TOOLS) return { registered: 0 };
@@ -2260,17 +1888,15 @@ function registerCatalogMethodTools(server, catalog) {
2260
1888
  safeToolRun(async () => {
2261
1889
  const tokenStore = loadTokenStore();
2262
1890
  const activeProfile = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
2263
- const clientConfig = loadClientConfig();
2264
- const runtimeGateway = getRuntimeGatewayConfig();
2265
1891
  return {
2266
1892
  catalog_path: CATALOG_PATH,
1893
+ execution_mode: "local",
2267
1894
  method_tools_enabled: ENABLE_METHOD_TOOLS,
2268
1895
  max_method_tools: MAX_METHOD_TOOLS,
2269
1896
  methods_in_catalog: methods.length,
2270
1897
  method_tools_registered: registered,
2271
1898
  method_tool_prefix: METHOD_TOOL_PREFIX,
2272
1899
  token_store_path: TOKEN_STORE_PATH,
2273
- client_config_path: CLIENT_CONFIG_PATH,
2274
1900
  active_profile: activeProfile
2275
1901
  ? {
2276
1902
  key: activeProfile.key,
@@ -2278,14 +1904,11 @@ function registerCatalogMethodTools(server, catalog) {
2278
1904
  team_id: activeProfile.profile?.team_id || "",
2279
1905
  }
2280
1906
  : null,
2281
- client_profile: clientConfig.profile || "",
2282
1907
  env_tokens_present: {
2283
1908
  bot: Boolean(process.env.SLACK_BOT_TOKEN),
2284
1909
  user: Boolean(process.env.SLACK_USER_TOKEN),
2285
1910
  generic: Boolean(process.env.SLACK_TOKEN),
2286
1911
  },
2287
- gateway_mode: Boolean(runtimeGateway.url),
2288
- gateway_url: runtimeGateway.url || null,
2289
1912
  env_example_fallback_enabled: ALLOW_ENV_EXAMPLE_FALLBACK,
2290
1913
  };
2291
1914
  })
@@ -2299,13 +1922,25 @@ async function startMcpServer() {
2299
1922
  { name: SERVER_NAME, version: SERVER_VERSION },
2300
1923
  { capabilities: { logging: {} } }
2301
1924
  );
2302
-
2303
- registerCoreTools(server);
2304
- const catalog = loadCatalog();
2305
- const methodStats = registerCatalogMethodTools(server, catalog);
2306
-
2307
- const transport = new StdioServerTransport();
2308
- 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);
2309
1944
 
2310
1945
  const catalogCount =
2311
1946
  catalog && catalog.totals && typeof catalog.totals.methods === "number"
@@ -2315,7 +1950,7 @@ async function startMcpServer() {
2315
1950
  : 0;
2316
1951
 
2317
1952
  console.error(
2318
- `[${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}`
2319
1954
  );
2320
1955
  }
2321
1956
 
@@ -2326,17 +1961,26 @@ async function runEntryPoint() {
2326
1961
  await runOauthCli(rest);
2327
1962
  return;
2328
1963
  }
2329
- if (command === "gateway") {
2330
- await runGatewayCli(rest);
2331
- return;
2332
- }
2333
1964
  if (command === "onboard") {
2334
1965
  await runOnboardCli(rest);
2335
1966
  return;
2336
1967
  }
2337
- if (!command) {
2338
- const onboarded = await runAutoOnboardingIfPossible();
2339
- 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
+ );
2340
1984
  }
2341
1985
  await startMcpServer();
2342
1986
  }