slack-max-api-mcp 1.0.5 → 1.0.7
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 +59 -4
- package/README.md +223 -77
- package/data/slack-catalog.json +3700 -3700
- package/package.json +32 -28
- package/src/slack-mcp-server.js +2209 -580
package/src/slack-mcp-server.js
CHANGED
|
@@ -1,38 +1,89 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
3
|
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const http = require("node:http");
|
|
4
6
|
const path = require("node:path");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const { spawn } = require("node:child_process");
|
|
5
9
|
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
10
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
11
|
const { z } = require("zod");
|
|
8
|
-
|
|
9
|
-
const SERVER_NAME = "slack-max-api-mcp";
|
|
10
|
-
const SERVER_VERSION = "2.0.0";
|
|
11
|
-
|
|
12
|
-
const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
const SERVER_NAME = "slack-max-api-mcp";
|
|
14
|
+
const SERVER_VERSION = "2.0.0";
|
|
15
|
+
|
|
16
|
+
const SLACK_API_BASE_URL = process.env.SLACK_API_BASE_URL || "https://slack.com/api";
|
|
17
|
+
|
|
14
18
|
const CATALOG_PATH =
|
|
15
19
|
process.env.SLACK_CATALOG_PATH || path.join(process.cwd(), "data", "slack-catalog.json");
|
|
16
20
|
const METHOD_TOOL_PREFIX = process.env.SLACK_METHOD_TOOL_PREFIX || "slack_method";
|
|
17
21
|
const ENABLE_METHOD_TOOLS = process.env.SLACK_ENABLE_METHOD_TOOLS !== "false";
|
|
18
22
|
const MAX_METHOD_TOOLS = Number(process.env.SLACK_MAX_METHOD_TOOLS || 0);
|
|
19
23
|
const ENV_EXAMPLE_PATH = path.join(process.cwd(), ".env.example");
|
|
20
|
-
|
|
24
|
+
const TOKEN_STORE_PATH =
|
|
25
|
+
process.env.SLACK_TOKEN_STORE_PATH ||
|
|
26
|
+
path.join(os.homedir(), ".slack-max-api-mcp", "tokens.json");
|
|
27
|
+
const CLIENT_CONFIG_PATH =
|
|
28
|
+
process.env.SLACK_CLIENT_CONFIG_PATH ||
|
|
29
|
+
path.join(os.homedir(), ".slack-max-api-mcp", "client.json");
|
|
30
|
+
const ALLOW_ENV_EXAMPLE_FALLBACK = process.env.SLACK_ALLOW_ENV_EXAMPLE_FALLBACK === "true";
|
|
31
|
+
const OAUTH_CALLBACK_HOST = process.env.SLACK_OAUTH_CALLBACK_HOST || "127.0.0.1";
|
|
32
|
+
const OAUTH_CALLBACK_PORT = Number(process.env.SLACK_OAUTH_CALLBACK_PORT || 8787);
|
|
33
|
+
const OAUTH_CALLBACK_PATH = process.env.SLACK_OAUTH_CALLBACK_PATH || "/slack/oauth/callback";
|
|
34
|
+
const OAUTH_TIMEOUT_MS = Number(process.env.SLACK_OAUTH_TIMEOUT_MS || 5 * 60 * 1000);
|
|
35
|
+
const DEFAULT_OAUTH_BOT_SCOPES =
|
|
36
|
+
process.env.SLACK_OAUTH_BOT_SCOPES || "chat:write,channels:read,groups:read,users:read";
|
|
37
|
+
const DEFAULT_OAUTH_USER_SCOPES =
|
|
38
|
+
process.env.SLACK_OAUTH_USER_SCOPES ||
|
|
39
|
+
"search:read,channels:read,groups:read,channels:history,groups:history";
|
|
40
|
+
const RETRYABLE_TOKEN_ERRORS = new Set(["not_allowed_token_type", "missing_scope"]);
|
|
41
|
+
const GATEWAY_API_KEY = process.env.SLACK_GATEWAY_API_KEY || "";
|
|
42
|
+
const GATEWAY_PROFILE = process.env.SLACK_GATEWAY_PROFILE || "";
|
|
43
|
+
const GATEWAY_HOST = process.env.SLACK_GATEWAY_HOST || "127.0.0.1";
|
|
44
|
+
const GATEWAY_PORT = Number(process.env.SLACK_GATEWAY_PORT || 8790);
|
|
45
|
+
const GATEWAY_PUBLIC_BASE_URL =
|
|
46
|
+
process.env.SLACK_GATEWAY_PUBLIC_BASE_URL || `http://${GATEWAY_HOST}:${GATEWAY_PORT}`;
|
|
47
|
+
const GATEWAY_ALLOW_PUBLIC = process.env.SLACK_GATEWAY_ALLOW_PUBLIC === "true";
|
|
48
|
+
const GATEWAY_SHARED_SECRET = process.env.SLACK_GATEWAY_SHARED_SECRET || GATEWAY_API_KEY;
|
|
49
|
+
const GATEWAY_CLIENT_API_KEY =
|
|
50
|
+
process.env.SLACK_GATEWAY_CLIENT_API_KEY || GATEWAY_API_KEY || GATEWAY_SHARED_SECRET;
|
|
51
|
+
const GATEWAY_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
|
+
|
|
21
72
|
function parseSimpleEnvFile(filePath) {
|
|
22
73
|
if (!fs.existsSync(filePath)) return {};
|
|
23
|
-
|
|
24
|
-
const out = {};
|
|
25
|
-
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
26
|
-
for (const line of lines) {
|
|
27
|
-
const trimmed = line.trim();
|
|
28
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
29
|
-
const eqIndex = trimmed.indexOf("=");
|
|
30
|
-
if (eqIndex <= 0) continue;
|
|
31
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
32
|
-
const value = trimmed.slice(eqIndex + 1).trim();
|
|
33
|
-
if (!key) continue;
|
|
34
|
-
out[key] = value;
|
|
35
|
-
}
|
|
74
|
+
|
|
75
|
+
const out = {};
|
|
76
|
+
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
80
|
+
const eqIndex = trimmed.indexOf("=");
|
|
81
|
+
if (eqIndex <= 0) continue;
|
|
82
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
83
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
84
|
+
if (!key) continue;
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
36
87
|
return out;
|
|
37
88
|
}
|
|
38
89
|
|
|
@@ -40,68 +91,360 @@ const ENV_EXAMPLE_VALUES = parseSimpleEnvFile(ENV_EXAMPLE_PATH);
|
|
|
40
91
|
const FIXED_BOT_TOKEN = ENV_EXAMPLE_VALUES.SLACK_BOT_TOKEN || "";
|
|
41
92
|
const FIXED_USER_TOKEN = ENV_EXAMPLE_VALUES.SLACK_USER_TOKEN || "";
|
|
42
93
|
const FIXED_GENERIC_TOKEN = ENV_EXAMPLE_VALUES.SLACK_TOKEN || "";
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
|
|
95
|
+
function parseScopeList(raw) {
|
|
96
|
+
if (!raw) return [];
|
|
97
|
+
return [...new Set(String(raw).split(",").map((part) => part.trim()).filter(Boolean))];
|
|
98
|
+
}
|
|
99
|
+
|
|
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";
|
|
57
116
|
}
|
|
58
|
-
|
|
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);
|
|
59
123
|
}
|
|
60
124
|
|
|
61
|
-
function
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
125
|
+
function ensureParentDirectory(filePath) {
|
|
126
|
+
const dirPath = path.dirname(filePath);
|
|
127
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
128
|
+
}
|
|
65
129
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
130
|
+
function emptyTokenStore() {
|
|
131
|
+
return { version: 1, default_profile: null, profiles: {} };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeTokenStore(value) {
|
|
135
|
+
if (!value || typeof value !== "object") return emptyTokenStore();
|
|
136
|
+
const out = { ...emptyTokenStore(), ...value };
|
|
137
|
+
if (!out.profiles || typeof out.profiles !== "object" || Array.isArray(out.profiles)) {
|
|
138
|
+
out.profiles = {};
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function loadTokenStore() {
|
|
144
|
+
if (!fs.existsSync(TOKEN_STORE_PATH)) return emptyTokenStore();
|
|
70
145
|
|
|
71
|
-
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(fs.readFileSync(TOKEN_STORE_PATH, "utf8"));
|
|
148
|
+
return normalizeTokenStore(parsed);
|
|
149
|
+
} catch {
|
|
150
|
+
return emptyTokenStore();
|
|
72
151
|
}
|
|
73
|
-
return search.toString();
|
|
74
152
|
}
|
|
75
153
|
|
|
76
|
-
function
|
|
77
|
-
|
|
154
|
+
function saveTokenStore(store) {
|
|
155
|
+
ensureParentDirectory(TOKEN_STORE_PATH);
|
|
156
|
+
fs.writeFileSync(TOKEN_STORE_PATH, JSON.stringify(normalizeTokenStore(store), null, 2), "utf8");
|
|
157
|
+
}
|
|
158
|
+
|
|
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();
|
|
78
176
|
try {
|
|
79
|
-
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(CLIENT_CONFIG_PATH, "utf8"));
|
|
178
|
+
return normalizeClientConfig(parsed);
|
|
80
179
|
} catch {
|
|
81
|
-
return
|
|
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
|
+
function resolveTokenStoreProfileBySelector(store, selector) {
|
|
204
|
+
const profiles = store?.profiles || {};
|
|
205
|
+
const keys = Object.keys(profiles);
|
|
206
|
+
if (keys.length === 0) return null;
|
|
207
|
+
|
|
208
|
+
const selected = selector || store.default_profile || keys[0];
|
|
209
|
+
if (selected && profiles[selected]) {
|
|
210
|
+
return { key: selected, profile: profiles[selected] };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const byName = keys.find((key) => profiles[key]?.profile_name === selected);
|
|
214
|
+
if (byName) return { key: byName, profile: profiles[byName] };
|
|
215
|
+
|
|
216
|
+
const byTeamId = keys.filter((key) => profiles[key]?.team_id === selected);
|
|
217
|
+
if (byTeamId.length === 1) {
|
|
218
|
+
const key = byTeamId[0];
|
|
219
|
+
return { key, profile: profiles[key] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getPreferredTokenKinds(preferredTokenType) {
|
|
226
|
+
const preferred = (preferredTokenType || process.env.SLACK_DEFAULT_TOKEN_TYPE || "bot").toLowerCase();
|
|
227
|
+
if (preferred === "user") return ["user", "bot", "generic"];
|
|
228
|
+
if (preferred === "generic") return ["generic", "bot", "user"];
|
|
229
|
+
if (preferred === "auto") return ["user", "bot", "generic"];
|
|
230
|
+
return ["bot", "user", "generic"];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function appendCandidateTokens(candidates, source, tokenMap, preferredKinds, seen) {
|
|
234
|
+
for (const kind of preferredKinds) {
|
|
235
|
+
const token = tokenMap[kind];
|
|
236
|
+
if (!token || seen.has(token)) continue;
|
|
237
|
+
seen.add(token);
|
|
238
|
+
candidates.push({ token, source, kind });
|
|
82
239
|
}
|
|
83
240
|
}
|
|
84
241
|
|
|
242
|
+
function getSlackTokenCandidates(tokenOverride, options = {}) {
|
|
243
|
+
if (tokenOverride) {
|
|
244
|
+
return [{ token: tokenOverride, source: "token_override", kind: "override" }];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const preferredKinds = getPreferredTokenKinds(options.preferredTokenType);
|
|
248
|
+
const candidates = [];
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
|
|
251
|
+
if (options.includeEnvTokens !== false) {
|
|
252
|
+
appendCandidateTokens(
|
|
253
|
+
candidates,
|
|
254
|
+
"env",
|
|
255
|
+
{
|
|
256
|
+
bot: process.env.SLACK_BOT_TOKEN || "",
|
|
257
|
+
user: process.env.SLACK_USER_TOKEN || "",
|
|
258
|
+
generic: process.env.SLACK_TOKEN || "",
|
|
259
|
+
},
|
|
260
|
+
preferredKinds,
|
|
261
|
+
seen
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (options.includeTokenStore !== false) {
|
|
266
|
+
const tokenStore = loadTokenStore();
|
|
267
|
+
const activeProfile = resolveTokenStoreProfileBySelector(
|
|
268
|
+
tokenStore,
|
|
269
|
+
options.profileSelector || process.env.SLACK_PROFILE || GATEWAY_PROFILE
|
|
270
|
+
);
|
|
271
|
+
if (activeProfile) {
|
|
272
|
+
appendCandidateTokens(
|
|
273
|
+
candidates,
|
|
274
|
+
`token_store:${activeProfile.key}`,
|
|
275
|
+
{
|
|
276
|
+
bot: activeProfile.profile?.bot_token || "",
|
|
277
|
+
user: activeProfile.profile?.user_token || "",
|
|
278
|
+
generic: "",
|
|
279
|
+
},
|
|
280
|
+
preferredKinds,
|
|
281
|
+
seen
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (ALLOW_ENV_EXAMPLE_FALLBACK) {
|
|
287
|
+
appendCandidateTokens(
|
|
288
|
+
candidates,
|
|
289
|
+
"env_example",
|
|
290
|
+
{
|
|
291
|
+
bot: FIXED_BOT_TOKEN,
|
|
292
|
+
user: FIXED_USER_TOKEN,
|
|
293
|
+
generic: FIXED_GENERIC_TOKEN,
|
|
294
|
+
},
|
|
295
|
+
preferredKinds,
|
|
296
|
+
seen
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return candidates;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requireSlackTokenCandidate(tokenOverride, options = {}) {
|
|
304
|
+
const candidates = getSlackTokenCandidates(tokenOverride, options);
|
|
305
|
+
if (candidates.length === 0) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
"Slack token is missing. Set SLACK_BOT_TOKEN/SLACK_USER_TOKEN/SLACK_TOKEN or run `slack-max-api-mcp oauth login`."
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return candidates[0];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function toUrlEncodedBody(params = {}) {
|
|
314
|
+
const search = new URLSearchParams();
|
|
315
|
+
for (const [key, value] of Object.entries(params)) {
|
|
316
|
+
if (value === undefined || value === null) continue;
|
|
317
|
+
|
|
318
|
+
if (Array.isArray(value) || (typeof value === "object" && !(value instanceof Date))) {
|
|
319
|
+
search.append(key, JSON.stringify(value));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
search.append(key, String(value));
|
|
324
|
+
}
|
|
325
|
+
return search.toString();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function parseJsonMaybe(value) {
|
|
329
|
+
if (typeof value !== "string") return value;
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(value);
|
|
332
|
+
} catch {
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
85
337
|
function toRecordObject(value) {
|
|
86
338
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
87
339
|
return value;
|
|
88
340
|
}
|
|
89
341
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
+
}
|
|
93
350
|
|
|
94
|
-
|
|
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`, {
|
|
95
358
|
method: "POST",
|
|
96
|
-
headers:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}),
|
|
101
367
|
});
|
|
102
368
|
|
|
103
369
|
const text = await response.text();
|
|
104
|
-
let
|
|
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
|
+
async function callSlackApiWithToken(method, params = {}, token, tokenSource) {
|
|
435
|
+
const url = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/${method}`;
|
|
436
|
+
|
|
437
|
+
const response = await fetch(url, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: {
|
|
440
|
+
Authorization: `Bearer ${token}`,
|
|
441
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
442
|
+
},
|
|
443
|
+
body: toUrlEncodedBody(params),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const text = await response.text();
|
|
447
|
+
let data;
|
|
105
448
|
try {
|
|
106
449
|
data = JSON.parse(text);
|
|
107
450
|
} catch {
|
|
@@ -109,31 +452,85 @@ async function callSlackApi(method, params = {}, tokenOverride) {
|
|
|
109
452
|
}
|
|
110
453
|
|
|
111
454
|
if (!response.ok) {
|
|
112
|
-
|
|
455
|
+
const error = new Error(
|
|
456
|
+
`Slack API HTTP ${response.status} for ${method}: ${data.error || "unknown_error"}`
|
|
457
|
+
);
|
|
458
|
+
error.http_status = response.status;
|
|
459
|
+
error.slack_error = data.error || "unknown_error";
|
|
460
|
+
error.needed = data.needed;
|
|
461
|
+
error.provided = data.provided;
|
|
462
|
+
error.token_source = tokenSource;
|
|
463
|
+
throw error;
|
|
113
464
|
}
|
|
114
465
|
|
|
115
466
|
if (!data.ok) {
|
|
116
|
-
|
|
467
|
+
const extraParts = [];
|
|
468
|
+
if (data.needed) extraParts.push(`needed=${data.needed}`);
|
|
469
|
+
if (data.provided) extraParts.push(`provided=${data.provided}`);
|
|
470
|
+
if (tokenSource) extraParts.push(`token_source=${tokenSource}`);
|
|
471
|
+
|
|
472
|
+
const extra = extraParts.length ? ` (${extraParts.join(", ")})` : "";
|
|
473
|
+
const error = new Error(`Slack method ${method} failed: ${data.error || "unknown_error"}${extra}`);
|
|
474
|
+
error.slack_error = data.error || "unknown_error";
|
|
475
|
+
error.needed = data.needed;
|
|
476
|
+
error.provided = data.provided;
|
|
477
|
+
error.token_source = tokenSource;
|
|
478
|
+
throw error;
|
|
117
479
|
}
|
|
118
480
|
|
|
119
481
|
return data;
|
|
120
482
|
}
|
|
121
483
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
484
|
+
async function callSlackApiWithCandidates(method, params, candidates) {
|
|
485
|
+
let lastError = null;
|
|
486
|
+
|
|
487
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
488
|
+
const candidate = candidates[i];
|
|
489
|
+
try {
|
|
490
|
+
return await callSlackApiWithToken(method, params, candidate.token, candidate.source);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
lastError = error;
|
|
493
|
+
const canRetry = i < candidates.length - 1 && RETRYABLE_TOKEN_ERRORS.has(error.slack_error);
|
|
494
|
+
if (!canRetry) {
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
128
499
|
|
|
129
|
-
|
|
130
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
-
return {
|
|
132
|
-
isError: true,
|
|
133
|
-
content: [{ type: "text", text: message }],
|
|
134
|
-
};
|
|
500
|
+
throw lastError || new Error(`Slack method ${method} failed.`);
|
|
135
501
|
}
|
|
136
502
|
|
|
503
|
+
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
|
+
const candidates = getSlackTokenCandidates(tokenOverride, options);
|
|
510
|
+
if (candidates.length === 0) {
|
|
511
|
+
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`."
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return callSlackApiWithCandidates(method, params, candidates);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function successResult(payload) {
|
|
520
|
+
return {
|
|
521
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
522
|
+
structuredContent: payload,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function errorResult(error) {
|
|
527
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
528
|
+
return {
|
|
529
|
+
isError: true,
|
|
530
|
+
content: [{ type: "text", text: message }],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
137
534
|
async function safeToolRun(executor) {
|
|
138
535
|
try {
|
|
139
536
|
const result = await executor();
|
|
@@ -143,576 +540,1808 @@ async function safeToolRun(executor) {
|
|
|
143
540
|
}
|
|
144
541
|
}
|
|
145
542
|
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
543
|
+
function parseCliArgs(argv) {
|
|
544
|
+
const options = {};
|
|
545
|
+
const positionals = [];
|
|
546
|
+
|
|
547
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
548
|
+
const arg = argv[i];
|
|
549
|
+
if (!arg.startsWith("--")) {
|
|
550
|
+
positionals.push(arg);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const withoutPrefix = arg.slice(2);
|
|
555
|
+
const [rawKey, rawInlineValue] = withoutPrefix.split("=", 2);
|
|
556
|
+
const key = rawKey.trim();
|
|
557
|
+
if (!key) continue;
|
|
558
|
+
|
|
559
|
+
if (rawInlineValue !== undefined) {
|
|
560
|
+
options[key] = rawInlineValue;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const maybeValue = argv[i + 1];
|
|
565
|
+
if (maybeValue && !maybeValue.startsWith("--")) {
|
|
566
|
+
options[key] = maybeValue;
|
|
567
|
+
i += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
options[key] = true;
|
|
149
572
|
}
|
|
150
573
|
|
|
574
|
+
return { options, positionals };
|
|
575
|
+
}
|
|
576
|
+
|
|
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;
|
|
151
618
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
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.");
|
|
157
625
|
}
|
|
626
|
+
if (!payload.exp || Number(payload.exp) < Date.now()) {
|
|
627
|
+
throw new Error("Invite token expired.");
|
|
628
|
+
}
|
|
629
|
+
return payload;
|
|
158
630
|
}
|
|
159
631
|
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
usedNames.add(base);
|
|
164
|
-
return base;
|
|
632
|
+
function requireGatewayInviteSecret() {
|
|
633
|
+
if (!GATEWAY_SHARED_SECRET) {
|
|
634
|
+
throw new Error("Set SLACK_GATEWAY_SHARED_SECRET before using gateway invite/onboarding.");
|
|
165
635
|
}
|
|
636
|
+
return GATEWAY_SHARED_SECRET;
|
|
637
|
+
}
|
|
166
638
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const name = `${base}_${idx}`;
|
|
170
|
-
usedNames.add(name);
|
|
171
|
-
return name;
|
|
639
|
+
function isInteractiveTerminal() {
|
|
640
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
172
641
|
}
|
|
173
642
|
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.regex(/^[a-z][a-zA-Z0-9_.]+$/, "Method must look like chat.postMessage"),
|
|
184
|
-
params: z.record(z.string(), z.any()).optional(),
|
|
185
|
-
token_override: z.string().optional(),
|
|
186
|
-
},
|
|
187
|
-
},
|
|
188
|
-
async ({ method, params, token_override }) =>
|
|
189
|
-
safeToolRun(async () => {
|
|
190
|
-
const data = await callSlackApi(method, params || {}, token_override);
|
|
191
|
-
return { method, data };
|
|
192
|
-
})
|
|
193
|
-
);
|
|
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
|
+
}
|
|
194
652
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
},
|
|
210
|
-
async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
|
|
211
|
-
safeToolRun(async () => {
|
|
212
|
-
const token = requireSlackToken(token_override);
|
|
213
|
-
const method = http_method || "GET";
|
|
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
|
+
}
|
|
214
667
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
674
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
}
|
|
225
685
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
reqHeaders["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
|
|
229
|
-
body = toUrlEncodedBody(form_body);
|
|
230
|
-
} else if (json_body && Object.keys(json_body).length > 0) {
|
|
231
|
-
reqHeaders["Content-Type"] = "application/json; charset=utf-8";
|
|
232
|
-
body = JSON.stringify(json_body);
|
|
233
|
-
}
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
234
688
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
689
|
+
function printOauthHelp() {
|
|
690
|
+
const lines = [
|
|
691
|
+
"Slack Max OAuth helper",
|
|
692
|
+
"",
|
|
693
|
+
"Usage:",
|
|
694
|
+
" slack-max-api-mcp oauth login [--profile NAME] [--team T123] [--scope a,b] [--user-scope c,d]",
|
|
695
|
+
" slack-max-api-mcp oauth list",
|
|
696
|
+
" slack-max-api-mcp oauth use <profile_key_or_name>",
|
|
697
|
+
" slack-max-api-mcp oauth current",
|
|
698
|
+
"",
|
|
699
|
+
"Required env vars for login:",
|
|
700
|
+
" SLACK_CLIENT_ID",
|
|
701
|
+
" SLACK_CLIENT_SECRET",
|
|
702
|
+
"",
|
|
703
|
+
"Optional env vars:",
|
|
704
|
+
" SLACK_OAUTH_BOT_SCOPES, SLACK_OAUTH_USER_SCOPES",
|
|
705
|
+
" SLACK_OAUTH_CALLBACK_HOST, SLACK_OAUTH_CALLBACK_PORT, SLACK_OAUTH_CALLBACK_PATH",
|
|
706
|
+
" SLACK_PROFILE, SLACK_TOKEN_STORE_PATH",
|
|
707
|
+
];
|
|
708
|
+
console.log(lines.join("\n"));
|
|
709
|
+
}
|
|
240
710
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
711
|
+
function openExternalUrl(url) {
|
|
712
|
+
try {
|
|
713
|
+
if (process.platform === "win32") {
|
|
714
|
+
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
|
|
715
|
+
child.unref();
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (process.platform === "darwin") {
|
|
719
|
+
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
720
|
+
child.unref();
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
724
|
+
child.unref();
|
|
725
|
+
return true;
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
248
730
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
);
|
|
731
|
+
function buildOauthAuthorizeUrl({
|
|
732
|
+
clientId,
|
|
733
|
+
state,
|
|
734
|
+
redirectUri,
|
|
735
|
+
botScopes,
|
|
736
|
+
userScopes,
|
|
737
|
+
teamId,
|
|
738
|
+
}) {
|
|
739
|
+
const endpoint = new URL("https://slack.com/oauth/v2/authorize");
|
|
740
|
+
endpoint.searchParams.set("client_id", clientId);
|
|
741
|
+
endpoint.searchParams.set("state", state);
|
|
742
|
+
endpoint.searchParams.set("redirect_uri", redirectUri);
|
|
743
|
+
if (botScopes.length > 0) endpoint.searchParams.set("scope", botScopes.join(","));
|
|
744
|
+
if (userScopes.length > 0) endpoint.searchParams.set("user_scope", userScopes.join(","));
|
|
745
|
+
if (teamId) endpoint.searchParams.set("team", teamId);
|
|
746
|
+
return endpoint.toString();
|
|
747
|
+
}
|
|
258
748
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
749
|
+
async function waitForOauthCode({ host, port, callbackPath, state, timeoutMs }) {
|
|
750
|
+
return new Promise((resolve, reject) => {
|
|
751
|
+
let settled = false;
|
|
752
|
+
let timeout = null;
|
|
753
|
+
const server = http.createServer((req, res) => {
|
|
754
|
+
const requestUrl = new URL(req.url || "/", `http://${host}:${port}`);
|
|
755
|
+
if (requestUrl.pathname !== callbackPath) {
|
|
756
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
757
|
+
res.end("Not found");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const receivedError = requestUrl.searchParams.get("error");
|
|
762
|
+
if (receivedError) {
|
|
763
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
764
|
+
res.end(`Slack OAuth failed: ${receivedError}`);
|
|
765
|
+
settle(new Error(`Slack OAuth failed: ${receivedError}`));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const receivedState = requestUrl.searchParams.get("state");
|
|
770
|
+
if (!receivedState || receivedState !== state) {
|
|
771
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
772
|
+
res.end("Invalid OAuth state.");
|
|
773
|
+
settle(new Error("Slack OAuth state mismatch."));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const code = requestUrl.searchParams.get("code");
|
|
778
|
+
if (!code) {
|
|
779
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
780
|
+
res.end("Missing authorization code.");
|
|
781
|
+
settle(new Error("OAuth callback did not include `code`."));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
786
|
+
res.end("Slack OAuth authorization completed. You can close this tab.");
|
|
787
|
+
settle(null, code);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
function settle(error, code) {
|
|
791
|
+
if (settled) return;
|
|
792
|
+
settled = true;
|
|
793
|
+
if (timeout) clearTimeout(timeout);
|
|
794
|
+
server.close(() => {
|
|
795
|
+
if (error) reject(error);
|
|
796
|
+
else resolve(code);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
295
799
|
|
|
296
|
-
|
|
297
|
-
|
|
800
|
+
server.on("error", (error) => {
|
|
801
|
+
settle(new Error(`Failed to listen on ${host}:${port}: ${error.message}`));
|
|
802
|
+
});
|
|
298
803
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
804
|
+
server.listen(port, host, () => {
|
|
805
|
+
timeout = setTimeout(() => {
|
|
806
|
+
settle(new Error("Timed out waiting for OAuth callback."));
|
|
807
|
+
}, timeoutMs);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
}
|
|
305
811
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
812
|
+
async function exchangeOauthCode({ clientId, clientSecret, code, redirectUri }) {
|
|
813
|
+
const endpoint = `${SLACK_API_BASE_URL.replace(/\/+$/, "")}/oauth.v2.access`;
|
|
814
|
+
const response = await fetch(endpoint, {
|
|
815
|
+
method: "POST",
|
|
816
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" },
|
|
817
|
+
body: toUrlEncodedBody({
|
|
818
|
+
client_id: clientId,
|
|
819
|
+
client_secret: clientSecret,
|
|
820
|
+
code,
|
|
821
|
+
redirect_uri: redirectUri,
|
|
822
|
+
}),
|
|
823
|
+
});
|
|
313
824
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
limit: z.number().int().min(1).max(1000).optional(),
|
|
322
|
-
include_locale: z.boolean().optional(),
|
|
323
|
-
include_deleted: z.boolean().optional(),
|
|
324
|
-
include_bots: z.boolean().optional(),
|
|
325
|
-
cursor: z.string().optional(),
|
|
326
|
-
token_override: z.string().optional(),
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
async ({
|
|
330
|
-
query,
|
|
331
|
-
limit,
|
|
332
|
-
include_locale,
|
|
333
|
-
include_deleted,
|
|
334
|
-
include_bots,
|
|
335
|
-
cursor,
|
|
336
|
-
token_override,
|
|
337
|
-
}) =>
|
|
338
|
-
safeToolRun(async () => {
|
|
339
|
-
const listData = await callSlackApi(
|
|
340
|
-
"users.list",
|
|
341
|
-
{
|
|
342
|
-
limit: limit ?? 200,
|
|
343
|
-
include_locale: include_locale ?? true,
|
|
344
|
-
cursor,
|
|
345
|
-
},
|
|
346
|
-
token_override
|
|
347
|
-
);
|
|
825
|
+
const text = await response.text();
|
|
826
|
+
let data;
|
|
827
|
+
try {
|
|
828
|
+
data = JSON.parse(text);
|
|
829
|
+
} catch {
|
|
830
|
+
throw new Error(`Slack OAuth token exchange returned non-JSON (HTTP ${response.status}).`);
|
|
831
|
+
}
|
|
348
832
|
|
|
349
|
-
|
|
350
|
-
|
|
833
|
+
if (!response.ok) {
|
|
834
|
+
throw new Error(`Slack OAuth token exchange failed (HTTP ${response.status}): ${data.error || "unknown_error"}`);
|
|
835
|
+
}
|
|
351
836
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (include_bots !== true) {
|
|
356
|
-
users = users.filter((u) => !u.is_bot && !u.is_app_user);
|
|
357
|
-
}
|
|
358
|
-
if (normalizedQuery) {
|
|
359
|
-
users = users.filter((u) => {
|
|
360
|
-
const candidates = [
|
|
361
|
-
u.id,
|
|
362
|
-
u.name,
|
|
363
|
-
u.real_name,
|
|
364
|
-
u.profile?.display_name,
|
|
365
|
-
u.profile?.real_name,
|
|
366
|
-
u.profile?.email,
|
|
367
|
-
]
|
|
368
|
-
.filter((v) => typeof v === "string")
|
|
369
|
-
.map((v) => v.toLowerCase());
|
|
370
|
-
return candidates.some((value) => value.includes(normalizedQuery));
|
|
371
|
-
});
|
|
372
|
-
}
|
|
837
|
+
if (!data.ok) {
|
|
838
|
+
throw new Error(`Slack OAuth token exchange failed: ${data.error || "unknown_error"}`);
|
|
839
|
+
}
|
|
373
840
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
841
|
+
return data;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function normalizeProfileName(rawName, fallback) {
|
|
845
|
+
const name = (rawName || "").trim();
|
|
846
|
+
if (name) return name;
|
|
847
|
+
return fallback;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function upsertOauthProfile(oauthResponse, preferredProfileName) {
|
|
851
|
+
const teamId = oauthResponse.team?.id || "unknown_team";
|
|
852
|
+
const teamName = oauthResponse.team?.name || teamId;
|
|
853
|
+
const authedUserId = oauthResponse.authed_user?.id || oauthResponse.bot_user_id || "unknown_user";
|
|
854
|
+
const profileKey = `${teamId}:${authedUserId}`;
|
|
855
|
+
|
|
856
|
+
const tokenStore = loadTokenStore();
|
|
857
|
+
const existing = tokenStore.profiles[profileKey] || {};
|
|
858
|
+
const now = new Date().toISOString();
|
|
859
|
+
|
|
860
|
+
tokenStore.profiles[profileKey] = {
|
|
861
|
+
...existing,
|
|
862
|
+
profile_name: normalizeProfileName(preferredProfileName, `${teamName}-${authedUserId}`),
|
|
863
|
+
team_id: teamId,
|
|
864
|
+
team_name: teamName,
|
|
865
|
+
app_id: oauthResponse.app_id || existing.app_id || "",
|
|
866
|
+
token_type: oauthResponse.token_type || existing.token_type || "",
|
|
867
|
+
bot_user_id: oauthResponse.bot_user_id || existing.bot_user_id || "",
|
|
868
|
+
bot_scope: oauthResponse.scope || existing.bot_scope || "",
|
|
869
|
+
user_scope: oauthResponse.authed_user?.scope || existing.user_scope || "",
|
|
870
|
+
bot_token: oauthResponse.access_token || existing.bot_token || "",
|
|
871
|
+
user_token: oauthResponse.authed_user?.access_token || existing.user_token || "",
|
|
872
|
+
authed_user_id: oauthResponse.authed_user?.id || existing.authed_user_id || "",
|
|
873
|
+
incoming_webhook_url: oauthResponse.incoming_webhook?.url || existing.incoming_webhook_url || "",
|
|
874
|
+
created_at: existing.created_at || now,
|
|
875
|
+
updated_at: now,
|
|
876
|
+
};
|
|
877
|
+
tokenStore.default_profile = profileKey;
|
|
878
|
+
|
|
879
|
+
saveTokenStore(tokenStore);
|
|
880
|
+
return { key: profileKey, profile: tokenStore.profiles[profileKey] };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function formatTokenProfileSummary(key, profile, isDefault) {
|
|
884
|
+
const flags = [];
|
|
885
|
+
if (isDefault) flags.push("default");
|
|
886
|
+
if (profile.bot_token) flags.push("bot");
|
|
887
|
+
if (profile.user_token) flags.push("user");
|
|
888
|
+
|
|
889
|
+
return [
|
|
890
|
+
`${isDefault ? "*" : " "} ${key} (${profile.profile_name || "unnamed"})`,
|
|
891
|
+
` team=${profile.team_name || profile.team_id || "unknown"} | user=${profile.authed_user_id || "unknown"} | tokens=${flags.join(", ") || "none"}`,
|
|
892
|
+
].join("\n");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function runOauthLogin(args) {
|
|
896
|
+
const { options } = parseCliArgs(args);
|
|
897
|
+
const clientId = options["client-id"] || process.env.SLACK_CLIENT_ID;
|
|
898
|
+
const clientSecret = options["client-secret"] || process.env.SLACK_CLIENT_SECRET;
|
|
899
|
+
if (!clientId || !clientSecret) {
|
|
900
|
+
throw new Error("Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET. Set env vars or pass --client-id/--client-secret.");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const host = options.host || OAUTH_CALLBACK_HOST;
|
|
904
|
+
const port = Number(options.port || OAUTH_CALLBACK_PORT);
|
|
905
|
+
const callbackPath = options["callback-path"] || OAUTH_CALLBACK_PATH;
|
|
906
|
+
const redirectUri = options["redirect-uri"] || `http://${host}:${port}${callbackPath}`;
|
|
907
|
+
const timeoutMs = Number(options["timeout-ms"] || OAUTH_TIMEOUT_MS);
|
|
908
|
+
const botScopes = parseScopeList(options.scope || DEFAULT_OAUTH_BOT_SCOPES);
|
|
909
|
+
const userScopes = parseScopeList(options["user-scope"] || DEFAULT_OAUTH_USER_SCOPES);
|
|
910
|
+
const teamId = options.team || process.env.SLACK_OAUTH_TEAM_ID || "";
|
|
911
|
+
|
|
912
|
+
if (botScopes.length === 0 && userScopes.length === 0) {
|
|
913
|
+
throw new Error("At least one scope is required. Set --scope or --user-scope.");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const state = crypto.randomBytes(24).toString("hex");
|
|
917
|
+
const authorizeUrl = buildOauthAuthorizeUrl({
|
|
918
|
+
clientId,
|
|
919
|
+
state,
|
|
920
|
+
redirectUri,
|
|
921
|
+
botScopes,
|
|
922
|
+
userScopes,
|
|
923
|
+
teamId,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
console.log(`[oauth] callback listening on http://${host}:${port}${callbackPath}`);
|
|
927
|
+
console.log(`[oauth] authorize URL:\n${authorizeUrl}`);
|
|
928
|
+
|
|
929
|
+
if (!options["no-open"]) {
|
|
930
|
+
const opened = openExternalUrl(authorizeUrl);
|
|
931
|
+
if (!opened) {
|
|
932
|
+
console.log("[oauth] Could not auto-open browser. Open the URL above manually.");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const code = await waitForOauthCode({ host, port, callbackPath, state, timeoutMs });
|
|
937
|
+
const oauthResponse = await exchangeOauthCode({ clientId, clientSecret, code, redirectUri });
|
|
938
|
+
const { key, profile } = upsertOauthProfile(oauthResponse, options.profile);
|
|
939
|
+
|
|
940
|
+
console.log(`[oauth] saved profile: ${profile.profile_name} (${key})`);
|
|
941
|
+
console.log(`[oauth] token store path: ${TOKEN_STORE_PATH}`);
|
|
942
|
+
console.log("[oauth] Next step for MCP clients:");
|
|
943
|
+
console.log(` setx SLACK_PROFILE \"${profile.profile_name}\"`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function runOauthList() {
|
|
947
|
+
const tokenStore = loadTokenStore();
|
|
948
|
+
const keys = Object.keys(tokenStore.profiles);
|
|
949
|
+
if (keys.length === 0) {
|
|
950
|
+
console.log(`[oauth] no saved profiles in ${TOKEN_STORE_PATH}`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
console.log(`[oauth] profiles in ${TOKEN_STORE_PATH}`);
|
|
955
|
+
for (const key of keys) {
|
|
956
|
+
console.log(formatTokenProfileSummary(key, tokenStore.profiles[key], tokenStore.default_profile === key));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function runOauthUse(args) {
|
|
961
|
+
const { positionals } = parseCliArgs(args);
|
|
962
|
+
const selector = positionals[0];
|
|
963
|
+
if (!selector) {
|
|
964
|
+
throw new Error("Usage: slack-max-api-mcp oauth use <profile_key_or_name>");
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const tokenStore = loadTokenStore();
|
|
968
|
+
const resolved = resolveTokenStoreProfileBySelector(tokenStore, selector);
|
|
969
|
+
if (!resolved) {
|
|
970
|
+
throw new Error(`Profile not found: ${selector}`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
tokenStore.default_profile = resolved.key;
|
|
974
|
+
saveTokenStore(tokenStore);
|
|
975
|
+
console.log(`[oauth] default profile set to ${resolved.key} (${resolved.profile.profile_name || "unnamed"})`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function runOauthCurrent() {
|
|
979
|
+
const tokenStore = loadTokenStore();
|
|
980
|
+
const resolved = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
|
|
981
|
+
if (!resolved) {
|
|
982
|
+
console.log("[oauth] no active profile");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
console.log(formatTokenProfileSummary(resolved.key, resolved.profile, tokenStore.default_profile === resolved.key));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function runOauthCli(args) {
|
|
989
|
+
const subcommand = (args[0] || "help").toLowerCase();
|
|
990
|
+
const rest = args.slice(1);
|
|
991
|
+
|
|
992
|
+
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
993
|
+
printOauthHelp();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (subcommand === "login") {
|
|
997
|
+
await runOauthLogin(rest);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (subcommand === "list") {
|
|
1001
|
+
runOauthList();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (subcommand === "use") {
|
|
1005
|
+
runOauthUse(rest);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (subcommand === "current") {
|
|
1009
|
+
runOauthCurrent();
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
throw new Error(`Unknown oauth command: ${subcommand}`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
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}`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function sendText(res, statusCode, text) {
|
|
1130
|
+
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1131
|
+
res.end(text);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function sendJson(res, statusCode, payload) {
|
|
1135
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
1136
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
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
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function getRequestApiKey(req) {
|
|
1168
|
+
const authHeader = req.headers.authorization || "";
|
|
1169
|
+
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1170
|
+
return authHeader.slice(7).trim();
|
|
1171
|
+
}
|
|
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
|
+
}
|
|
1183
|
+
|
|
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.");
|
|
1189
|
+
}
|
|
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
|
+
});
|
|
1207
|
+
}
|
|
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();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function parseScopesFromQuery(searchParams, key, fallback) {
|
|
1217
|
+
const value = searchParams.get(key);
|
|
1218
|
+
return parseScopeList(value || fallback);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
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()}` : ""}`;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
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 || "";
|
|
1249
|
+
}
|
|
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
|
+
|
|
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}'`);
|
|
1276
|
+
|
|
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'");
|
|
1283
|
+
}
|
|
1284
|
+
lines.push(onboardCommandParts.join(" "));
|
|
1285
|
+
if (ONBOARD_SKIP_TLS_VERIFY) {
|
|
1286
|
+
lines.push("Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue");
|
|
1287
|
+
}
|
|
1288
|
+
return lines.join("\r\n");
|
|
1289
|
+
}
|
|
1290
|
+
|
|
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
|
+
","
|
|
380
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
|
+
}
|
|
381
1315
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
async ({ query, types, exclude_archived, limit, cursor, token_override }) =>
|
|
397
|
-
safeToolRun(async () => {
|
|
398
|
-
const data = await callSlackApi(
|
|
399
|
-
"conversations.list",
|
|
400
|
-
{
|
|
401
|
-
types: types || "public_channel,private_channel",
|
|
402
|
-
exclude_archived: exclude_archived ?? true,
|
|
403
|
-
limit: limit ?? 200,
|
|
404
|
-
cursor,
|
|
405
|
-
},
|
|
406
|
-
token_override
|
|
407
|
-
);
|
|
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
|
+
});
|
|
1321
|
+
|
|
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
|
+
}
|
|
408
1328
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
1329
|
+
const reqHeaders = {
|
|
1330
|
+
Authorization: `Bearer ${tokenCandidate.token}`,
|
|
1331
|
+
...toRecordObject(payload.headers),
|
|
1332
|
+
};
|
|
1333
|
+
|
|
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
|
+
}
|
|
1344
|
+
|
|
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
|
|
1352
|
+
}
|
|
1353
|
+
|
|
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
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
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(/\/+$/, "")}`;
|
|
1369
|
+
|
|
1370
|
+
const server = http.createServer(async (req, res) => {
|
|
1371
|
+
try {
|
|
1372
|
+
const method = req.method || "GET";
|
|
1373
|
+
const requestUrl = new URL(req.url || "/", `http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
|
|
1374
|
+
|
|
1375
|
+
if (method === "GET" && requestUrl.pathname === "/health") {
|
|
1376
|
+
sendJson(res, 200, {
|
|
1377
|
+
ok: true,
|
|
1378
|
+
service: SERVER_NAME,
|
|
1379
|
+
mode: "gateway",
|
|
1380
|
+
token_store_path: TOKEN_STORE_PATH,
|
|
1381
|
+
client_config_path: CLIENT_CONFIG_PATH,
|
|
1382
|
+
callback_url: redirectUri,
|
|
1383
|
+
});
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
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,
|
|
417
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
|
+
if (method === "GET" && requestUrl.pathname === "/onboard/bootstrap") {
|
|
1425
|
+
if (!GATEWAY_PUBLIC_ONBOARD_ENABLED) {
|
|
1426
|
+
sendJson(res, 404, { ok: false, error: "public_onboard_disabled" });
|
|
1427
|
+
return;
|
|
418
1428
|
}
|
|
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") || "",
|
|
1434
|
+
});
|
|
1435
|
+
sendJson(res, 200, payload);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
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);
|
|
1444
|
+
sendJson(res, 200, {
|
|
1445
|
+
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(),
|
|
1452
|
+
});
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
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
|
+
);
|
|
419
1466
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
};
|
|
425
|
-
})
|
|
426
|
-
);
|
|
1467
|
+
if (botScopes.length === 0 && userScopes.length === 0) {
|
|
1468
|
+
sendJson(res, 400, { ok: false, error: "missing_scope" });
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
427
1471
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
blocks: z.any().optional(),
|
|
437
|
-
mrkdwn: z.boolean().optional(),
|
|
438
|
-
unfurl_links: z.boolean().optional(),
|
|
439
|
-
unfurl_media: z.boolean().optional(),
|
|
440
|
-
token_override: z.string().optional(),
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
async ({ channel, text, thread_ts, blocks, mrkdwn, unfurl_links, unfurl_media, token_override }) =>
|
|
444
|
-
safeToolRun(async () => {
|
|
445
|
-
const data = await callSlackApi(
|
|
446
|
-
"chat.postMessage",
|
|
447
|
-
{
|
|
448
|
-
channel,
|
|
449
|
-
text,
|
|
450
|
-
thread_ts,
|
|
451
|
-
blocks: parseJsonMaybe(blocks),
|
|
452
|
-
mrkdwn,
|
|
453
|
-
unfurl_links,
|
|
454
|
-
unfurl_media,
|
|
455
|
-
},
|
|
456
|
-
token_override
|
|
457
|
-
);
|
|
1472
|
+
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
|
+
});
|
|
458
1480
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1481
|
+
const authorizeUrl = buildOauthAuthorizeUrl({
|
|
1482
|
+
clientId,
|
|
1483
|
+
state,
|
|
1484
|
+
redirectUri,
|
|
1485
|
+
botScopes,
|
|
1486
|
+
userScopes,
|
|
1487
|
+
teamId,
|
|
1488
|
+
});
|
|
466
1489
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
async ({ channel, limit, cursor, oldest, latest, inclusive, token_override }) =>
|
|
482
|
-
safeToolRun(async () => {
|
|
483
|
-
const data = await callSlackApi(
|
|
484
|
-
"conversations.history",
|
|
485
|
-
{
|
|
486
|
-
channel,
|
|
487
|
-
limit: limit ?? 50,
|
|
488
|
-
cursor,
|
|
489
|
-
oldest,
|
|
490
|
-
latest,
|
|
491
|
-
inclusive,
|
|
492
|
-
},
|
|
493
|
-
token_override
|
|
494
|
-
);
|
|
1490
|
+
res.writeHead(302, { Location: authorizeUrl });
|
|
1491
|
+
res.end();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (method === "GET" && requestUrl.pathname === callbackPath) {
|
|
1496
|
+
const { clientId, clientSecret } = requireGatewayClientCredentials();
|
|
1497
|
+
const receivedError = requestUrl.searchParams.get("error");
|
|
1498
|
+
if (receivedError) {
|
|
1499
|
+
sendText(res, 400, `Slack OAuth failed: ${receivedError}`);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
495
1502
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
})
|
|
503
|
-
);
|
|
1503
|
+
const state = requestUrl.searchParams.get("state");
|
|
1504
|
+
const code = requestUrl.searchParams.get("code");
|
|
1505
|
+
if (!state || !code) {
|
|
1506
|
+
sendText(res, 400, "Missing state/code.");
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
504
1509
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
limit: limit ?? 50,
|
|
528
|
-
cursor,
|
|
529
|
-
oldest,
|
|
530
|
-
latest,
|
|
531
|
-
inclusive,
|
|
532
|
-
},
|
|
533
|
-
token_override
|
|
1510
|
+
const pending = pendingStates.get(state);
|
|
1511
|
+
pendingStates.delete(state);
|
|
1512
|
+
if (!pending) {
|
|
1513
|
+
sendText(res, 400, "Invalid or expired OAuth state.");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (Date.now() - pending.created_at > GATEWAY_STATE_TTL_MS) {
|
|
1517
|
+
sendText(res, 400, "Expired OAuth state.");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
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")
|
|
534
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
|
+
}
|
|
535
1552
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
});
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
545
1566
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
params: z.record(z.string(), z.any()),
|
|
552
|
-
token_override: z.string().optional(),
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
|
-
async ({ params, token_override }) =>
|
|
556
|
-
safeToolRun(async () => {
|
|
557
|
-
const data = await callSlackApi("canvases.create", params, token_override);
|
|
558
|
-
return data;
|
|
559
|
-
})
|
|
560
|
-
);
|
|
1567
|
+
if (method === "POST" && requestUrl.pathname === "/api/slack/call") {
|
|
1568
|
+
if (!isGatewayAuthorized(req)) {
|
|
1569
|
+
sendJson(res, 401, { ok: false, error: "unauthorized" });
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
561
1572
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
token_override: z.string().optional(),
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
async ({ params, token_override }) =>
|
|
572
|
-
safeToolRun(async () => {
|
|
573
|
-
const data = await callSlackApi("canvases.edit", params, token_override);
|
|
574
|
-
return data;
|
|
575
|
-
})
|
|
576
|
-
);
|
|
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" });
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
577
1579
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
safeToolRun(async () => {
|
|
589
|
-
const data = await callSlackApi("canvases.sections.lookup", params, token_override);
|
|
590
|
-
return data;
|
|
591
|
-
})
|
|
592
|
-
);
|
|
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,
|
|
1585
|
+
});
|
|
1586
|
+
if (candidates.length === 0) {
|
|
1587
|
+
sendJson(res, 400, { ok: false, error: "missing_token" });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
593
1590
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
inputSchema: {
|
|
599
|
-
user: z.string().min(1),
|
|
600
|
-
include_locale: z.boolean().optional(),
|
|
601
|
-
include_labels: z.boolean().optional(),
|
|
602
|
-
token_override: z.string().optional(),
|
|
603
|
-
},
|
|
604
|
-
},
|
|
605
|
-
async ({ user, include_locale, include_labels, token_override }) =>
|
|
606
|
-
safeToolRun(async () => {
|
|
607
|
-
const info = await callSlackApi(
|
|
608
|
-
"users.info",
|
|
609
|
-
{ user, include_locale: include_locale ?? true },
|
|
610
|
-
token_override
|
|
611
|
-
);
|
|
1591
|
+
const data = await callSlackApiWithCandidates(methodName, payload.params || {}, candidates);
|
|
1592
|
+
sendJson(res, 200, { ok: true, data });
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
612
1595
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
{ user, include_labels: include_labels ?? true },
|
|
618
|
-
token_override
|
|
619
|
-
);
|
|
620
|
-
profile = profileData.profile || null;
|
|
621
|
-
} catch {
|
|
622
|
-
profile = info.user?.profile || null;
|
|
1596
|
+
if (method === "POST" && requestUrl.pathname === "/api/slack/http") {
|
|
1597
|
+
if (!isGatewayAuthorized(req)) {
|
|
1598
|
+
sendJson(res, 401, { ok: false, error: "unauthorized" });
|
|
1599
|
+
return;
|
|
623
1600
|
}
|
|
624
1601
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
+
}
|
|
629
1607
|
|
|
630
|
-
|
|
631
|
-
|
|
1608
|
+
const data = await proxySlackHttpRequest(payload);
|
|
1609
|
+
sendJson(res, 200, { ok: true, data });
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
sendJson(res, 404, { ok: false, error: "not_found" });
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
sendJson(res, 500, {
|
|
1616
|
+
ok: false,
|
|
1617
|
+
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
|
+
});
|
|
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
|
+
}
|
|
1630
|
+
});
|
|
632
1631
|
|
|
633
|
-
|
|
634
|
-
|
|
1632
|
+
await new Promise((resolve, reject) => {
|
|
1633
|
+
server.once("error", reject);
|
|
1634
|
+
server.listen(GATEWAY_PORT, GATEWAY_HOST, resolve);
|
|
1635
|
+
});
|
|
635
1636
|
|
|
636
|
-
|
|
637
|
-
|
|
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
|
+
}
|
|
1645
|
+
}
|
|
638
1646
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
+
}
|
|
642
1674
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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);
|
|
1697
|
+
}
|
|
651
1698
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
async ({ params, token_override }) =>
|
|
662
|
-
safeToolRun(async () => {
|
|
663
|
-
const data = await callSlackApi(method, params || {}, token_override);
|
|
664
|
-
return { method, data };
|
|
665
|
-
})
|
|
666
|
-
);
|
|
667
|
-
registered += 1;
|
|
1699
|
+
async function runGatewayCli(args) {
|
|
1700
|
+
const subcommand = (args[0] || "help").toLowerCase();
|
|
1701
|
+
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
1702
|
+
printGatewayHelp();
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (subcommand === "start") {
|
|
1706
|
+
await startGatewayServer();
|
|
1707
|
+
return;
|
|
668
1708
|
}
|
|
1709
|
+
if (subcommand === "invite") {
|
|
1710
|
+
runGatewayInvite(args.slice(1));
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
throw new Error(`Unknown gateway command: ${subcommand}`);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function loadCatalog() {
|
|
1717
|
+
if (!fs.existsSync(CATALOG_PATH)) {
|
|
1718
|
+
return { methods: [], scopes: [], totals: {} };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
try {
|
|
1722
|
+
const raw = fs.readFileSync(CATALOG_PATH, "utf8");
|
|
1723
|
+
const parsed = JSON.parse(raw);
|
|
1724
|
+
return parsed;
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
throw new Error(`Failed to load catalog at ${CATALOG_PATH}: ${error}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
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);
|
|
1734
|
+
return base;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
let idx = 2;
|
|
1738
|
+
while (usedNames.has(`${base}_${idx}`)) idx += 1;
|
|
1739
|
+
const name = `${base}_${idx}`;
|
|
1740
|
+
usedNames.add(name);
|
|
1741
|
+
return name;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function registerCoreTools(server) {
|
|
1745
|
+
server.registerTool(
|
|
1746
|
+
"slack_api_call",
|
|
1747
|
+
{
|
|
1748
|
+
description: "Call any Slack Web API method directly.",
|
|
1749
|
+
inputSchema: {
|
|
1750
|
+
method: z
|
|
1751
|
+
.string()
|
|
1752
|
+
.min(3)
|
|
1753
|
+
.regex(/^[a-z][a-zA-Z0-9_.]+$/, "Method must look like chat.postMessage"),
|
|
1754
|
+
params: z.record(z.string(), z.any()).optional(),
|
|
1755
|
+
token_override: z.string().optional(),
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
async ({ method, params, token_override }) =>
|
|
1759
|
+
safeToolRun(async () => {
|
|
1760
|
+
const data = await callSlackApi(method, params || {}, token_override);
|
|
1761
|
+
return { method, data };
|
|
1762
|
+
})
|
|
1763
|
+
);
|
|
1764
|
+
|
|
1765
|
+
server.registerTool(
|
|
1766
|
+
"slack_http_api_call",
|
|
1767
|
+
{
|
|
1768
|
+
description:
|
|
1769
|
+
"Generic HTTP call for Slack APIs outside standard Web API methods (SCIM/Audit/Legal Holds).",
|
|
1770
|
+
inputSchema: {
|
|
1771
|
+
url: z.string().url(),
|
|
1772
|
+
http_method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
|
|
1773
|
+
query: z.record(z.string(), z.any()).optional(),
|
|
1774
|
+
json_body: z.record(z.string(), z.any()).optional(),
|
|
1775
|
+
form_body: z.record(z.string(), z.any()).optional(),
|
|
1776
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
1777
|
+
token_override: z.string().optional(),
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
async ({ url, http_method, query, json_body, form_body, headers, token_override }) =>
|
|
1781
|
+
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
|
+
}
|
|
669
1794
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
+
);
|
|
1841
|
+
|
|
1842
|
+
server.registerTool(
|
|
1843
|
+
"search_messages_files",
|
|
1844
|
+
{
|
|
1845
|
+
description:
|
|
1846
|
+
"Search messages and files. Uses search.messages and search.files and returns both.",
|
|
1847
|
+
inputSchema: {
|
|
1848
|
+
query: z.string().min(1),
|
|
1849
|
+
count: z.number().int().min(1).max(100).optional(),
|
|
1850
|
+
page: z.number().int().min(1).optional(),
|
|
1851
|
+
sort: z.enum(["score", "timestamp"]).optional(),
|
|
1852
|
+
sort_dir: z.enum(["asc", "desc"]).optional(),
|
|
1853
|
+
include_messages: z.boolean().optional(),
|
|
1854
|
+
include_files: z.boolean().optional(),
|
|
1855
|
+
token_override: z.string().optional(),
|
|
1856
|
+
},
|
|
1857
|
+
},
|
|
1858
|
+
async ({
|
|
1859
|
+
query,
|
|
1860
|
+
count,
|
|
1861
|
+
page,
|
|
1862
|
+
sort,
|
|
1863
|
+
sort_dir,
|
|
1864
|
+
include_messages,
|
|
1865
|
+
include_files,
|
|
1866
|
+
token_override,
|
|
1867
|
+
}) =>
|
|
1868
|
+
safeToolRun(async () => {
|
|
1869
|
+
const shouldSearchMessages = include_messages !== false;
|
|
1870
|
+
const shouldSearchFiles = include_files !== false;
|
|
1871
|
+
const sharedParams = {
|
|
1872
|
+
query,
|
|
1873
|
+
count: count ?? 20,
|
|
1874
|
+
page: page ?? 1,
|
|
1875
|
+
sort,
|
|
1876
|
+
sort_dir,
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
let messages = null;
|
|
1880
|
+
let files = null;
|
|
1881
|
+
|
|
1882
|
+
if (shouldSearchMessages) {
|
|
1883
|
+
messages = await callSlackApi("search.messages", sharedParams, token_override);
|
|
1884
|
+
}
|
|
1885
|
+
if (shouldSearchFiles) {
|
|
1886
|
+
files = await callSlackApi("search.files", sharedParams, token_override);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
return {
|
|
1890
|
+
query,
|
|
1891
|
+
messages: messages ? messages.messages : null,
|
|
1892
|
+
files: files ? files.files : null,
|
|
1893
|
+
};
|
|
1894
|
+
})
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
server.registerTool(
|
|
1898
|
+
"search_users",
|
|
1899
|
+
{
|
|
1900
|
+
description:
|
|
1901
|
+
"Find users by partial match on id/name/display_name/email using users.list and local filtering.",
|
|
1902
|
+
inputSchema: {
|
|
1903
|
+
query: z.string().optional(),
|
|
1904
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
1905
|
+
include_locale: z.boolean().optional(),
|
|
1906
|
+
include_deleted: z.boolean().optional(),
|
|
1907
|
+
include_bots: z.boolean().optional(),
|
|
1908
|
+
cursor: z.string().optional(),
|
|
1909
|
+
token_override: z.string().optional(),
|
|
1910
|
+
},
|
|
1911
|
+
},
|
|
1912
|
+
async ({
|
|
1913
|
+
query,
|
|
1914
|
+
limit,
|
|
1915
|
+
include_locale,
|
|
1916
|
+
include_deleted,
|
|
1917
|
+
include_bots,
|
|
1918
|
+
cursor,
|
|
1919
|
+
token_override,
|
|
1920
|
+
}) =>
|
|
1921
|
+
safeToolRun(async () => {
|
|
1922
|
+
const listData = await callSlackApi(
|
|
1923
|
+
"users.list",
|
|
1924
|
+
{
|
|
1925
|
+
limit: limit ?? 200,
|
|
1926
|
+
include_locale: include_locale ?? true,
|
|
1927
|
+
cursor,
|
|
1928
|
+
},
|
|
1929
|
+
token_override
|
|
1930
|
+
);
|
|
1931
|
+
|
|
1932
|
+
const normalizedQuery = query ? query.toLowerCase() : null;
|
|
1933
|
+
let users = Array.isArray(listData.members) ? listData.members : [];
|
|
1934
|
+
|
|
1935
|
+
if (include_deleted !== true) {
|
|
1936
|
+
users = users.filter((u) => !u.deleted);
|
|
1937
|
+
}
|
|
1938
|
+
if (include_bots !== true) {
|
|
1939
|
+
users = users.filter((u) => !u.is_bot && !u.is_app_user);
|
|
1940
|
+
}
|
|
1941
|
+
if (normalizedQuery) {
|
|
1942
|
+
users = users.filter((u) => {
|
|
1943
|
+
const candidates = [
|
|
1944
|
+
u.id,
|
|
1945
|
+
u.name,
|
|
1946
|
+
u.real_name,
|
|
1947
|
+
u.profile?.display_name,
|
|
1948
|
+
u.profile?.real_name,
|
|
1949
|
+
u.profile?.email,
|
|
1950
|
+
]
|
|
1951
|
+
.filter((v) => typeof v === "string")
|
|
1952
|
+
.map((v) => v.toLowerCase());
|
|
1953
|
+
return candidates.some((value) => value.includes(normalizedQuery));
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
return {
|
|
1958
|
+
users,
|
|
1959
|
+
next_cursor: listData.response_metadata?.next_cursor || null,
|
|
1960
|
+
count: users.length,
|
|
1961
|
+
};
|
|
1962
|
+
})
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
server.registerTool(
|
|
1966
|
+
"search_channels",
|
|
1967
|
+
{
|
|
1968
|
+
description:
|
|
1969
|
+
"Find channels by partial match on name/topic/purpose using conversations.list and local filtering.",
|
|
1970
|
+
inputSchema: {
|
|
1971
|
+
query: z.string().optional(),
|
|
1972
|
+
types: z.string().optional(),
|
|
1973
|
+
exclude_archived: z.boolean().optional(),
|
|
1974
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
1975
|
+
cursor: z.string().optional(),
|
|
1976
|
+
token_override: z.string().optional(),
|
|
1977
|
+
},
|
|
1978
|
+
},
|
|
1979
|
+
async ({ query, types, exclude_archived, limit, cursor, token_override }) =>
|
|
1980
|
+
safeToolRun(async () => {
|
|
1981
|
+
const data = await callSlackApi(
|
|
1982
|
+
"conversations.list",
|
|
1983
|
+
{
|
|
1984
|
+
types: types || "public_channel,private_channel",
|
|
1985
|
+
exclude_archived: exclude_archived ?? true,
|
|
1986
|
+
limit: limit ?? 200,
|
|
1987
|
+
cursor,
|
|
1988
|
+
},
|
|
1989
|
+
token_override
|
|
1990
|
+
);
|
|
1991
|
+
|
|
1992
|
+
let channels = Array.isArray(data.channels) ? data.channels : [];
|
|
1993
|
+
if (query) {
|
|
1994
|
+
const normalizedQuery = query.toLowerCase();
|
|
1995
|
+
channels = channels.filter((channel) => {
|
|
1996
|
+
const candidates = [channel.id, channel.name, channel.purpose?.value, channel.topic?.value]
|
|
1997
|
+
.filter((v) => typeof v === "string")
|
|
1998
|
+
.map((v) => v.toLowerCase());
|
|
1999
|
+
return candidates.some((value) => value.includes(normalizedQuery));
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
return {
|
|
2004
|
+
channels,
|
|
2005
|
+
next_cursor: data.response_metadata?.next_cursor || null,
|
|
2006
|
+
count: channels.length,
|
|
2007
|
+
};
|
|
2008
|
+
})
|
|
2009
|
+
);
|
|
2010
|
+
|
|
2011
|
+
server.registerTool(
|
|
2012
|
+
"send_message",
|
|
2013
|
+
{
|
|
2014
|
+
description: "Send a message using chat.postMessage.",
|
|
2015
|
+
inputSchema: {
|
|
2016
|
+
channel: z.string().min(1),
|
|
2017
|
+
text: z.string().min(1),
|
|
2018
|
+
thread_ts: z.string().optional(),
|
|
2019
|
+
blocks: z.any().optional(),
|
|
2020
|
+
mrkdwn: z.boolean().optional(),
|
|
2021
|
+
unfurl_links: z.boolean().optional(),
|
|
2022
|
+
unfurl_media: z.boolean().optional(),
|
|
2023
|
+
token_override: z.string().optional(),
|
|
2024
|
+
},
|
|
2025
|
+
},
|
|
2026
|
+
async ({ channel, text, thread_ts, blocks, mrkdwn, unfurl_links, unfurl_media, token_override }) =>
|
|
2027
|
+
safeToolRun(async () => {
|
|
2028
|
+
const data = await callSlackApi(
|
|
2029
|
+
"chat.postMessage",
|
|
2030
|
+
{
|
|
2031
|
+
channel,
|
|
2032
|
+
text,
|
|
2033
|
+
thread_ts,
|
|
2034
|
+
blocks: parseJsonMaybe(blocks),
|
|
2035
|
+
mrkdwn,
|
|
2036
|
+
unfurl_links,
|
|
2037
|
+
unfurl_media,
|
|
2038
|
+
},
|
|
2039
|
+
token_override
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
return {
|
|
2043
|
+
channel: data.channel,
|
|
2044
|
+
ts: data.ts,
|
|
2045
|
+
message: data.message,
|
|
2046
|
+
};
|
|
2047
|
+
})
|
|
2048
|
+
);
|
|
2049
|
+
|
|
2050
|
+
server.registerTool(
|
|
2051
|
+
"read_channel",
|
|
2052
|
+
{
|
|
2053
|
+
description: "Read channel history with conversations.history.",
|
|
2054
|
+
inputSchema: {
|
|
2055
|
+
channel: z.string().min(1),
|
|
2056
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
2057
|
+
cursor: z.string().optional(),
|
|
2058
|
+
oldest: z.string().optional(),
|
|
2059
|
+
latest: z.string().optional(),
|
|
2060
|
+
inclusive: z.boolean().optional(),
|
|
2061
|
+
token_override: z.string().optional(),
|
|
2062
|
+
},
|
|
2063
|
+
},
|
|
2064
|
+
async ({ channel, limit, cursor, oldest, latest, inclusive, token_override }) =>
|
|
2065
|
+
safeToolRun(async () => {
|
|
2066
|
+
const data = await callSlackApi(
|
|
2067
|
+
"conversations.history",
|
|
2068
|
+
{
|
|
2069
|
+
channel,
|
|
2070
|
+
limit: limit ?? 50,
|
|
2071
|
+
cursor,
|
|
2072
|
+
oldest,
|
|
2073
|
+
latest,
|
|
2074
|
+
inclusive,
|
|
2075
|
+
},
|
|
2076
|
+
token_override
|
|
2077
|
+
);
|
|
2078
|
+
|
|
2079
|
+
return {
|
|
2080
|
+
channel,
|
|
2081
|
+
messages: data.messages || [],
|
|
2082
|
+
has_more: Boolean(data.has_more),
|
|
2083
|
+
next_cursor: data.response_metadata?.next_cursor || null,
|
|
2084
|
+
};
|
|
2085
|
+
})
|
|
2086
|
+
);
|
|
2087
|
+
|
|
2088
|
+
server.registerTool(
|
|
2089
|
+
"read_thread",
|
|
2090
|
+
{
|
|
2091
|
+
description: "Read a thread using conversations.replies.",
|
|
2092
|
+
inputSchema: {
|
|
2093
|
+
channel: z.string().min(1),
|
|
2094
|
+
thread_ts: z.string().min(1),
|
|
2095
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
2096
|
+
cursor: z.string().optional(),
|
|
2097
|
+
oldest: z.string().optional(),
|
|
2098
|
+
latest: z.string().optional(),
|
|
2099
|
+
inclusive: z.boolean().optional(),
|
|
2100
|
+
token_override: z.string().optional(),
|
|
2101
|
+
},
|
|
2102
|
+
},
|
|
2103
|
+
async ({ channel, thread_ts, limit, cursor, oldest, latest, inclusive, token_override }) =>
|
|
2104
|
+
safeToolRun(async () => {
|
|
2105
|
+
const data = await callSlackApi(
|
|
2106
|
+
"conversations.replies",
|
|
2107
|
+
{
|
|
2108
|
+
channel,
|
|
2109
|
+
ts: thread_ts,
|
|
2110
|
+
limit: limit ?? 50,
|
|
2111
|
+
cursor,
|
|
2112
|
+
oldest,
|
|
2113
|
+
latest,
|
|
2114
|
+
inclusive,
|
|
2115
|
+
},
|
|
2116
|
+
token_override
|
|
2117
|
+
);
|
|
2118
|
+
|
|
2119
|
+
return {
|
|
2120
|
+
channel,
|
|
2121
|
+
thread_ts,
|
|
2122
|
+
messages: data.messages || [],
|
|
2123
|
+
has_more: Boolean(data.has_more),
|
|
2124
|
+
next_cursor: data.response_metadata?.next_cursor || null,
|
|
2125
|
+
};
|
|
2126
|
+
})
|
|
2127
|
+
);
|
|
2128
|
+
|
|
2129
|
+
server.registerTool(
|
|
2130
|
+
"create_canvas",
|
|
2131
|
+
{
|
|
2132
|
+
description: "Create a canvas using canvases.create. Pass Slack params in `params`.",
|
|
2133
|
+
inputSchema: {
|
|
2134
|
+
params: z.record(z.string(), z.any()),
|
|
2135
|
+
token_override: z.string().optional(),
|
|
2136
|
+
},
|
|
2137
|
+
},
|
|
2138
|
+
async ({ params, token_override }) =>
|
|
2139
|
+
safeToolRun(async () => {
|
|
2140
|
+
const data = await callSlackApi("canvases.create", params, token_override);
|
|
2141
|
+
return data;
|
|
2142
|
+
})
|
|
2143
|
+
);
|
|
2144
|
+
|
|
2145
|
+
server.registerTool(
|
|
2146
|
+
"update_canvas",
|
|
2147
|
+
{
|
|
2148
|
+
description: "Update a canvas using canvases.edit. Pass Slack params in `params`.",
|
|
2149
|
+
inputSchema: {
|
|
2150
|
+
params: z.record(z.string(), z.any()),
|
|
2151
|
+
token_override: z.string().optional(),
|
|
2152
|
+
},
|
|
2153
|
+
},
|
|
2154
|
+
async ({ params, token_override }) =>
|
|
2155
|
+
safeToolRun(async () => {
|
|
2156
|
+
const data = await callSlackApi("canvases.edit", params, token_override);
|
|
2157
|
+
return data;
|
|
2158
|
+
})
|
|
2159
|
+
);
|
|
2160
|
+
|
|
2161
|
+
server.registerTool(
|
|
2162
|
+
"read_canvas",
|
|
2163
|
+
{
|
|
2164
|
+
description: "Read canvas content using canvases.sections.lookup. Pass Slack params in `params`.",
|
|
2165
|
+
inputSchema: {
|
|
2166
|
+
params: z.record(z.string(), z.any()),
|
|
2167
|
+
token_override: z.string().optional(),
|
|
2168
|
+
},
|
|
2169
|
+
},
|
|
2170
|
+
async ({ params, token_override }) =>
|
|
2171
|
+
safeToolRun(async () => {
|
|
2172
|
+
const data = await callSlackApi("canvases.sections.lookup", params, token_override);
|
|
2173
|
+
return data;
|
|
2174
|
+
})
|
|
2175
|
+
);
|
|
2176
|
+
|
|
2177
|
+
server.registerTool(
|
|
2178
|
+
"read_user_profile",
|
|
2179
|
+
{
|
|
2180
|
+
description: "Read user info/profile using users.info plus users.profile.get (best effort).",
|
|
2181
|
+
inputSchema: {
|
|
2182
|
+
user: z.string().min(1),
|
|
2183
|
+
include_locale: z.boolean().optional(),
|
|
2184
|
+
include_labels: z.boolean().optional(),
|
|
2185
|
+
token_override: z.string().optional(),
|
|
2186
|
+
},
|
|
2187
|
+
},
|
|
2188
|
+
async ({ user, include_locale, include_labels, token_override }) =>
|
|
2189
|
+
safeToolRun(async () => {
|
|
2190
|
+
const info = await callSlackApi(
|
|
2191
|
+
"users.info",
|
|
2192
|
+
{ user, include_locale: include_locale ?? true },
|
|
2193
|
+
token_override
|
|
2194
|
+
);
|
|
2195
|
+
|
|
2196
|
+
let profile = null;
|
|
2197
|
+
try {
|
|
2198
|
+
const profileData = await callSlackApi(
|
|
2199
|
+
"users.profile.get",
|
|
2200
|
+
{ user, include_labels: include_labels ?? true },
|
|
2201
|
+
token_override
|
|
2202
|
+
);
|
|
2203
|
+
profile = profileData.profile || null;
|
|
2204
|
+
} catch {
|
|
2205
|
+
profile = info.user?.profile || null;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
return { user: info.user || null, profile };
|
|
2209
|
+
})
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function registerCatalogMethodTools(server, catalog) {
|
|
2214
|
+
if (!ENABLE_METHOD_TOOLS) return { registered: 0 };
|
|
2215
|
+
|
|
2216
|
+
const methods = Array.isArray(catalog.methods) ? catalog.methods : [];
|
|
2217
|
+
const limited = MAX_METHOD_TOOLS > 0 ? methods.slice(0, MAX_METHOD_TOOLS) : methods;
|
|
2218
|
+
|
|
2219
|
+
const usedNames = new Set();
|
|
2220
|
+
let registered = 0;
|
|
2221
|
+
|
|
2222
|
+
for (const methodInfo of limited) {
|
|
2223
|
+
const method = methodInfo?.method;
|
|
2224
|
+
if (!method || typeof method !== "string") continue;
|
|
2225
|
+
|
|
2226
|
+
const toolName = toolNameFromMethod(method, usedNames);
|
|
2227
|
+
const descriptionParts = [
|
|
2228
|
+
`Slack Web API method wrapper for ${method}.`,
|
|
2229
|
+
methodInfo.description ? `Official: ${methodInfo.description}` : "",
|
|
2230
|
+
Array.isArray(methodInfo.scopes) && methodInfo.scopes.length
|
|
2231
|
+
? `Scopes: ${methodInfo.scopes.join(", ")}`
|
|
2232
|
+
: "",
|
|
2233
|
+
].filter(Boolean);
|
|
2234
|
+
|
|
2235
|
+
server.registerTool(
|
|
2236
|
+
toolName,
|
|
2237
|
+
{
|
|
2238
|
+
description: descriptionParts.join(" "),
|
|
2239
|
+
inputSchema: {
|
|
2240
|
+
params: z.record(z.string(), z.any()).optional(),
|
|
2241
|
+
token_override: z.string().optional(),
|
|
2242
|
+
},
|
|
2243
|
+
},
|
|
2244
|
+
async ({ params, token_override }) =>
|
|
2245
|
+
safeToolRun(async () => {
|
|
2246
|
+
const data = await callSlackApi(method, params || {}, token_override);
|
|
2247
|
+
return { method, data };
|
|
2248
|
+
})
|
|
2249
|
+
);
|
|
2250
|
+
registered += 1;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
server.registerTool(
|
|
2254
|
+
"slack_method_tools_info",
|
|
2255
|
+
{
|
|
2256
|
+
description: "Return summary for catalog-driven method tools currently loaded.",
|
|
2257
|
+
inputSchema: {},
|
|
675
2258
|
},
|
|
676
2259
|
async () =>
|
|
677
|
-
safeToolRun(async () =>
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
2260
|
+
safeToolRun(async () => {
|
|
2261
|
+
const tokenStore = loadTokenStore();
|
|
2262
|
+
const activeProfile = resolveTokenStoreProfileBySelector(tokenStore, process.env.SLACK_PROFILE);
|
|
2263
|
+
const clientConfig = loadClientConfig();
|
|
2264
|
+
const runtimeGateway = getRuntimeGatewayConfig();
|
|
2265
|
+
return {
|
|
2266
|
+
catalog_path: CATALOG_PATH,
|
|
2267
|
+
method_tools_enabled: ENABLE_METHOD_TOOLS,
|
|
2268
|
+
max_method_tools: MAX_METHOD_TOOLS,
|
|
2269
|
+
methods_in_catalog: methods.length,
|
|
2270
|
+
method_tools_registered: registered,
|
|
2271
|
+
method_tool_prefix: METHOD_TOOL_PREFIX,
|
|
2272
|
+
token_store_path: TOKEN_STORE_PATH,
|
|
2273
|
+
client_config_path: CLIENT_CONFIG_PATH,
|
|
2274
|
+
active_profile: activeProfile
|
|
2275
|
+
? {
|
|
2276
|
+
key: activeProfile.key,
|
|
2277
|
+
profile_name: activeProfile.profile?.profile_name || "",
|
|
2278
|
+
team_id: activeProfile.profile?.team_id || "",
|
|
2279
|
+
}
|
|
2280
|
+
: null,
|
|
2281
|
+
client_profile: clientConfig.profile || "",
|
|
2282
|
+
env_tokens_present: {
|
|
2283
|
+
bot: Boolean(process.env.SLACK_BOT_TOKEN),
|
|
2284
|
+
user: Boolean(process.env.SLACK_USER_TOKEN),
|
|
2285
|
+
generic: Boolean(process.env.SLACK_TOKEN),
|
|
2286
|
+
},
|
|
2287
|
+
gateway_mode: Boolean(runtimeGateway.url),
|
|
2288
|
+
gateway_url: runtimeGateway.url || null,
|
|
2289
|
+
env_example_fallback_enabled: ALLOW_ENV_EXAMPLE_FALLBACK,
|
|
2290
|
+
};
|
|
2291
|
+
})
|
|
685
2292
|
);
|
|
686
|
-
|
|
687
|
-
return { registered };
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
async function
|
|
2293
|
+
|
|
2294
|
+
return { registered };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async function startMcpServer() {
|
|
691
2298
|
const server = new McpServer(
|
|
692
2299
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
693
2300
|
{ capabilities: { logging: {} } }
|
|
694
2301
|
);
|
|
695
|
-
|
|
696
|
-
registerCoreTools(server);
|
|
697
|
-
const catalog = loadCatalog();
|
|
698
|
-
const methodStats = registerCatalogMethodTools(server, catalog);
|
|
699
|
-
|
|
700
|
-
const transport = new StdioServerTransport();
|
|
701
|
-
await server.connect(transport);
|
|
702
|
-
|
|
703
|
-
const catalogCount =
|
|
704
|
-
catalog && catalog.totals && typeof catalog.totals.methods === "number"
|
|
705
|
-
? catalog.totals.methods
|
|
706
|
-
: Array.isArray(catalog.methods)
|
|
707
|
-
? catalog.methods.length
|
|
708
|
-
: 0;
|
|
709
|
-
|
|
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);
|
|
2309
|
+
|
|
2310
|
+
const catalogCount =
|
|
2311
|
+
catalog && catalog.totals && typeof catalog.totals.methods === "number"
|
|
2312
|
+
? catalog.totals.methods
|
|
2313
|
+
: Array.isArray(catalog.methods)
|
|
2314
|
+
? catalog.methods.length
|
|
2315
|
+
: 0;
|
|
2316
|
+
|
|
710
2317
|
console.error(
|
|
711
2318
|
`[${SERVER_NAME}] connected via stdio | catalog_methods=${catalogCount} | method_tools_registered=${methodStats.registered}`
|
|
712
2319
|
);
|
|
713
2320
|
}
|
|
714
2321
|
|
|
715
|
-
|
|
2322
|
+
async function runEntryPoint() {
|
|
2323
|
+
const [firstArg, ...rest] = process.argv.slice(2);
|
|
2324
|
+
const command = (firstArg || "").toLowerCase();
|
|
2325
|
+
if (command === "oauth") {
|
|
2326
|
+
await runOauthCli(rest);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (command === "gateway") {
|
|
2330
|
+
await runGatewayCli(rest);
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
if (command === "onboard") {
|
|
2334
|
+
await runOnboardCli(rest);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (!command) {
|
|
2338
|
+
const onboarded = await runAutoOnboardingIfPossible();
|
|
2339
|
+
if (onboarded) return;
|
|
2340
|
+
}
|
|
2341
|
+
await startMcpServer();
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
runEntryPoint().catch((error) => {
|
|
716
2345
|
console.error(`[${SERVER_NAME}] fatal error:`, error);
|
|
717
2346
|
process.exit(1);
|
|
718
2347
|
});
|