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