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.
- package/.env.example +20 -41
- package/README.md +53 -207
- package/package.json +2 -3
- package/src/slack-mcp-server.js +616 -1020
package/src/slack-mcp-server.js
CHANGED
|
@@ -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
|
|
23
|
-
const
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
process.env.
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
|
1064
|
-
|
|
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
|
-
|
|
1188
|
-
return
|
|
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
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
|
1233
|
-
const
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1265
|
-
const
|
|
1266
|
-
|
|
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
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
|
1279
|
-
const
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1311
|
-
const
|
|
1312
|
-
const
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
if (
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1340
|
-
const
|
|
1341
|
-
const
|
|
1342
|
-
const
|
|
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
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
};
|
|
882
|
+
if (claimData.status !== "ready") {
|
|
883
|
+
throw new Error(`Unexpected onboard claim status: ${claimData.status || "unknown"}`);
|
|
884
|
+
}
|
|
1381
885
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
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
|
|
1413
|
-
const
|
|
1414
|
-
const
|
|
1415
|
-
const
|
|
1416
|
-
|
|
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://${
|
|
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: "
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
const
|
|
1490
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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 === "/
|
|
1505
|
-
const
|
|
1506
|
-
const
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
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
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
1565
|
-
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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 === "/
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1049
|
+
sendJson(res, 200, {
|
|
1050
|
+
ok: true,
|
|
1051
|
+
status: "ready",
|
|
1052
|
+
profile: session.profile || "",
|
|
1053
|
+
oauth_response: session.oauth_response,
|
|
1633
1054
|
});
|
|
1634
|
-
|
|
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(
|
|
1070
|
+
server.listen(port, host, resolve);
|
|
1683
1071
|
});
|
|
1684
1072
|
|
|
1685
|
-
console.error(
|
|
1686
|
-
|
|
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
|
|
1696
|
-
const
|
|
1697
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
|
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
|
-
|
|
1099
|
+
printOnboardServerHelp();
|
|
1751
1100
|
return;
|
|
1752
1101
|
}
|
|
1753
1102
|
if (subcommand === "start") {
|
|
1754
|
-
await
|
|
1103
|
+
await runOnboardServerStart(rest);
|
|
1755
1104
|
return;
|
|
1756
1105
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
|
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
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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 (
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
}
|