yt-slack-mcp 0.6.0 → 0.7.0
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/LICENSE +21 -21
- package/README.md +14 -7
- package/index.js +355 -9
- package/package.json +2 -2
- package/skills/slack-mcp/SKILL.md +151 -134
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Maksim Korinets
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maksim Korinets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -7,12 +7,13 @@ A host-agnostic [Model Context Protocol](https://modelcontextprotocol.io) server
|
|
|
7
7
|
- Read Slack channel history and thread replies.
|
|
8
8
|
- Read message permalinks from channels, DMs, and group DMs.
|
|
9
9
|
- Return image attachments inline, extract text from PDFs, and return `text/*` attachment contents inline.
|
|
10
|
+
- Save any attachment to a local path from a message permalink, file permalink, or file ID, preserving the original bytes and reporting the saved path, filename, and MIME type.
|
|
10
11
|
- Retrieve private attachment contents through the authenticated Slack browser session.
|
|
11
12
|
- Reply to a thread, add or remove a reaction, edit an authored message or thread reply, or upload local files only in explicitly allowed channel IDs.
|
|
12
13
|
- Have no plain top-level text post: a reply needs an existing thread, and a file upload with an `initial_comment` is the only way to start a top-level message.
|
|
13
|
-
- Prefix every message the agent posts with `🤖:` — or `🤖 <model>:` when the call names the model — so agent replies stand apart from the account's own messages; an edit can opt out with `agent_authored: false`.
|
|
14
|
+
- Prefix every message the agent posts with `🤖:` — or `🤖 <backend model ID>:` when the call names the model — so agent replies stand apart from the account's own messages; an edit can opt out with `agent_authored: false`.
|
|
14
15
|
|
|
15
|
-
Attachment limits: images and text files are limited to 10 MB; text attachments are truncated at 100,000 characters. PDFs are limited to 20 MB, 50 pages, and 100,000 extracted characters.
|
|
16
|
+
Attachment limits: images and text files are limited to 10 MB; text attachments are truncated at 100,000 characters. PDFs are limited to 20 MB, 50 pages, and 100,000 extracted characters. These limits apply to content returned inline; `slack_save_file` streams the attachment unchanged to disk.
|
|
16
17
|
|
|
17
18
|
## Tools
|
|
18
19
|
|
|
@@ -21,11 +22,13 @@ Attachment limits: images and text files are limited to 10 MB; text attachments
|
|
|
21
22
|
| `slack_read_permalink` | Read a message or thread from a channel, DM, or group-DM permalink, including supported attachments. |
|
|
22
23
|
| `slack_get_replies` | Read replies for a thread by channel ID and timestamp. |
|
|
23
24
|
| `slack_get_history` | Read recent channel history by channel ID (up to 200 messages). |
|
|
24
|
-
| `
|
|
25
|
+
| `slack_get_history` | Read recent channel history by channel ID (up to 200 messages). |
|
|
26
|
+
| `slack_save_file` | Save a file attached to a message to a local path from a Slack message permalink, file permalink, or file ID, preserving the original bytes; the destination's parent directory must exist, and a destination that is an existing directory keeps the attachment's filename (replacing characters the local filesystem rejects) and refuses to overwrite an existing file — pass an explicit file path to overwrite; returns the saved path, filename, and MIME type. |
|
|
27
|
+
| `slack_reply_to_thread` | Reply to a thread, with `model` set to the backend model ID when known, in a `🤖:`-prefixed message; restricted to allowed channels. |
|
|
25
28
|
| `slack_add_reaction` | Add a reaction; restricted to allowed channels. |
|
|
26
29
|
| `slack_remove_reaction` | Remove a reaction added by this account; restricted to allowed channels. |
|
|
27
|
-
| `slack_upload_files` | Upload up to 10 local files (10 MB each) to a channel or thread, with an optional `initial_comment` prefixed with `🤖:` and
|
|
28
|
-
| `slack_edit_message` | Edit an authored message or thread reply by channel ID and timestamp; replaces the message text with `🤖:`-prefixed text and
|
|
30
|
+
| `slack_upload_files` | Upload up to 10 local files (10 MB each) to a channel or thread, with an optional `initial_comment` prefixed with `🤖:` and `model` set to the backend model ID when known (only used with a comment); returns file and message permalinks; restricted to allowed channels. |
|
|
31
|
+
| `slack_edit_message` | Edit an authored message or thread reply by channel ID and timestamp; replaces the message text with `🤖:`-prefixed text and `model` set to the backend model ID when known, or with the text as given when `agent_authored` is `false` (Slack removes any existing Block Kit blocks) and returns the updated timestamp and permalink; restricted to allowed channels. |
|
|
29
32
|
|
|
30
33
|
## Agent message prefix
|
|
31
34
|
|
|
@@ -37,13 +40,17 @@ text of every posted message is prefixed with `🤖:` by default:
|
|
|
37
40
|
🤖: deployed revision 4f2c1a9
|
|
38
41
|
```
|
|
39
42
|
|
|
40
|
-
The MCP transport does not report which model is calling, so a write
|
|
41
|
-
|
|
43
|
+
The MCP transport does not report which model is calling, so a write call passes
|
|
44
|
+
`model` with the backend model ID it is running — for example
|
|
45
|
+
`claude-sonnet-4-5`, not a display name — and the prefix then names it:
|
|
42
46
|
|
|
43
47
|
```text
|
|
44
48
|
🤖 claude-sonnet-4-5: deployed revision 4f2c1a9
|
|
45
49
|
```
|
|
46
50
|
|
|
51
|
+
The parameter stays optional for a client that cannot determine its own model
|
|
52
|
+
ID; without it the prefix is the bare `🤖:`.
|
|
53
|
+
|
|
47
54
|
The prefix is added once: text that already starts with `🤖` is sent unchanged,
|
|
48
55
|
so editing a prefixed message does not stack prefixes. A file upload without an
|
|
49
56
|
`initial_comment` — or with only whitespace — gets no comment rather than a bare
|
package/index.js
CHANGED
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
15
|
+
import { randomUUID } from "crypto";
|
|
15
16
|
import fs from "fs";
|
|
16
17
|
import os from "os";
|
|
17
18
|
import path from "path";
|
|
19
|
+
import { Readable } from "stream";
|
|
20
|
+
import { pipeline } from "stream/promises";
|
|
18
21
|
import { fileURLToPath } from "url";
|
|
19
22
|
|
|
20
23
|
|
|
@@ -30,6 +33,8 @@ const maxPdfTextCharacters = 100_000;
|
|
|
30
33
|
const maxTextFileBytes = 10 * 1024 * 1024;
|
|
31
34
|
const maxTextFileCharacters = 100_000;
|
|
32
35
|
const maxUploadBytes = 10 * 1024 * 1024;
|
|
36
|
+
const threadPageSize = 200;
|
|
37
|
+
const maxThreadPages = 10;
|
|
33
38
|
|
|
34
39
|
function expandHome(filePath) {
|
|
35
40
|
if (!filePath) {
|
|
@@ -94,6 +99,30 @@ function loadConfig(input = DEFAULT_CONFIG_PATH) {
|
|
|
94
99
|
return { configPath, secretsDir, xoxcPath, xoxdPath, allowedWriteChannels, userAgent };
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
function legacyEnvDiagnostic(env = process.env) {
|
|
103
|
+
const mappings = [
|
|
104
|
+
["SLACK_MCP_SECRETS_DIR", "secretsDir"],
|
|
105
|
+
["SLACK_MCP_XOXC_PATH", "xoxcPath"],
|
|
106
|
+
["SLACK_MCP_XOXD_PATH", "xoxdPath"],
|
|
107
|
+
["SLACK_MCP_ALLOWED_WRITE_CHANNELS", "allowedWriteChannels"],
|
|
108
|
+
["SLACK_MCP_USER_AGENT", "userAgent"],
|
|
109
|
+
]
|
|
110
|
+
.filter(([key]) => typeof env[key] === "string" && env[key].trim() !== "")
|
|
111
|
+
.map(([key, configKey]) => ` ${key} -> "${configKey}"`);
|
|
112
|
+
if (typeof env.SLACK_MCP_CONFIG === "string" && env.SLACK_MCP_CONFIG.trim() !== "") {
|
|
113
|
+
mappings.push(` SLACK_MCP_CONFIG -> ignored; the config path is fixed at ${DEFAULT_CONFIG_PATH}`);
|
|
114
|
+
}
|
|
115
|
+
if (mappings.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
"Warning: obsolete Slack MCP environment configuration detected. Environment-variable configuration has been removed and is ignored.",
|
|
120
|
+
`Move these settings into ${DEFAULT_CONFIG_PATH} and remove the variables from your MCP host environment:`,
|
|
121
|
+
...mappings,
|
|
122
|
+
"Write tools stay deny-by-default until allowedWriteChannels is configured in the config file.",
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
function readSecret(filePath, label) {
|
|
98
127
|
if (!fs.existsSync(filePath)) {
|
|
99
128
|
throw new Error(`missing ${label} (looked at ${filePath})`);
|
|
@@ -248,6 +277,86 @@ function createSlackClient({ xoxcToken, xoxdToken, userAgent = "Mozilla/5.0" })
|
|
|
248
277
|
return text;
|
|
249
278
|
};
|
|
250
279
|
|
|
280
|
+
const resolveMessageFile = async ({ channelId, timestamp, threadTs }) => {
|
|
281
|
+
const rootTs = threadTs || timestamp;
|
|
282
|
+
let cursor;
|
|
283
|
+
let pages = 0;
|
|
284
|
+
const seenCursors = new Set();
|
|
285
|
+
for (;;) {
|
|
286
|
+
pages += 1;
|
|
287
|
+
let response;
|
|
288
|
+
try {
|
|
289
|
+
response = await slackApi("conversations.replies", {
|
|
290
|
+
channel: channelId,
|
|
291
|
+
ts: rootTs,
|
|
292
|
+
limit: String(threadPageSize),
|
|
293
|
+
cursor,
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const reason = String(error?.message ?? error);
|
|
297
|
+
if (/ratelimit|rate_limited/i.test(reason)) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`${reason}. Retry later, or pass a Slack file ID or file permalink to skip the thread lookup.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
const message = (response.messages || []).find((item) => item.ts === timestamp);
|
|
305
|
+
if (message) {
|
|
306
|
+
const files = message.files || [];
|
|
307
|
+
if (!files.length) {
|
|
308
|
+
throw new Error(`no files attached to message ${timestamp} in ${channelId}`);
|
|
309
|
+
}
|
|
310
|
+
if (files.length > 1) {
|
|
311
|
+
const candidates = files
|
|
312
|
+
.map((file) => `${file.id || "unknown id"} (${file.name || file.title || "untitled"})`)
|
|
313
|
+
.join(", ");
|
|
314
|
+
throw new Error(
|
|
315
|
+
`message ${timestamp} has ${files.length} files; pass a file ID or file permalink to choose one: ${candidates}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return files[0];
|
|
319
|
+
}
|
|
320
|
+
const nextCursor = response.response_metadata?.next_cursor;
|
|
321
|
+
if (!nextCursor || seenCursors.has(nextCursor)) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
if (pages >= maxThreadPages) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`message ${timestamp} is beyond the first ${maxThreadPages * threadPageSize} messages of thread ${rootTs} in ${channelId}; pass a Slack file ID or file permalink to choose the attachment directly`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
seenCursors.add(nextCursor);
|
|
330
|
+
cursor = nextCursor;
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`message ${timestamp} not found in thread ${rootTs} in ${channelId}`);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Writes the attachment exactly as Slack stores it: the response body is
|
|
336
|
+
// streamed to a temporary file beside the destination and renamed into
|
|
337
|
+
// place, so binary formats such as SVG are never decoded, large attachments
|
|
338
|
+
// are not buffered in memory, and a failed download leaves no partial file.
|
|
339
|
+
const saveSlackFile = async ({ reference, destinationPath }) => {
|
|
340
|
+
const target = parseFileReference(reference);
|
|
341
|
+
const file = target.message ? await resolveMessageFile(target.message) : { id: target.fileId };
|
|
342
|
+
const fileData = await getSlackFileData(file);
|
|
343
|
+
const downloadUrl = fileData.url_private_download || fileData.url_private;
|
|
344
|
+
if (!downloadUrl) {
|
|
345
|
+
throw new Error("Slack did not provide a private download URL for this file");
|
|
346
|
+
}
|
|
347
|
+
const filename = safeFilename(fileData.name || fileData.title || fileData.id);
|
|
348
|
+
const destination = resolveDestinationPath(destinationPath, filename);
|
|
349
|
+
const response = await fetch(downloadUrl, { headers: slackHeaders() });
|
|
350
|
+
if (!response.ok) {
|
|
351
|
+
throw new Error(`Slack file download failed: ${response.status}`);
|
|
352
|
+
}
|
|
353
|
+
const mimeType = (response.headers.get("content-type") || fileData.mimetype || "application/octet-stream")
|
|
354
|
+
.split(";", 1)[0]
|
|
355
|
+
.trim();
|
|
356
|
+
await writeResponseToFile(response, destination.path, { replaceExisting: destination.replaceExisting });
|
|
357
|
+
return { path: destination.path, filename: path.basename(destination.path), mimeType };
|
|
358
|
+
};
|
|
359
|
+
|
|
251
360
|
const uploadSlackFiles = async ({ filePaths, channelId, threadTs, initialComment }) => {
|
|
252
361
|
const localFiles = filePaths.map((filePath) => {
|
|
253
362
|
const fileStats = fs.statSync(filePath);
|
|
@@ -364,7 +473,7 @@ function createSlackClient({ xoxcToken, xoxdToken, userAgent = "Mozilla/5.0" })
|
|
|
364
473
|
return content;
|
|
365
474
|
};
|
|
366
475
|
|
|
367
|
-
return { slackApi, formatMessages, uploadSlackFiles };
|
|
476
|
+
return { slackApi, formatMessages, uploadSlackFiles, saveSlackFile };
|
|
368
477
|
}
|
|
369
478
|
|
|
370
479
|
function parsePermalink(url) {
|
|
@@ -372,7 +481,13 @@ function parsePermalink(url) {
|
|
|
372
481
|
if (!match) {
|
|
373
482
|
throw new Error("invalid Slack permalink");
|
|
374
483
|
}
|
|
375
|
-
|
|
484
|
+
const parsed = { channelId: match[1], timestamp: `${match[2]}.${match[3]}` };
|
|
485
|
+
// Reply permalinks carry the thread's parent timestamp in the query string.
|
|
486
|
+
const threadTs = url.match(/[?&]thread_ts=(\d+\.\d+)/)?.[1];
|
|
487
|
+
if (threadTs) {
|
|
488
|
+
parsed.threadTs = threadTs;
|
|
489
|
+
}
|
|
490
|
+
return parsed;
|
|
376
491
|
}
|
|
377
492
|
|
|
378
493
|
function requireString(value, name) {
|
|
@@ -382,6 +497,189 @@ function requireString(value, name) {
|
|
|
382
497
|
return value.trim();
|
|
383
498
|
}
|
|
384
499
|
|
|
500
|
+
// Streams a Slack download into a temporary file beside the destination and
|
|
501
|
+
// moves it into place, so a failed transfer never leaves a partial attachment
|
|
502
|
+
// and a large file never has to fit in memory.
|
|
503
|
+
async function writeResponseToFile(response, destinationPath, { replaceExisting = false } = {}) {
|
|
504
|
+
if (!response.body) {
|
|
505
|
+
throw new Error("Slack returned an empty file response");
|
|
506
|
+
}
|
|
507
|
+
const target = replaceExisting ? resolveSymlinkTarget(destinationPath) : destinationPath;
|
|
508
|
+
const tempPath = path.join(path.dirname(target), `.slack-save-${randomUUID()}.part`);
|
|
509
|
+
try {
|
|
510
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(tempPath, { flags: "wx" }));
|
|
511
|
+
} catch (error) {
|
|
512
|
+
fs.rmSync(tempPath, { force: true });
|
|
513
|
+
throw new Error(`could not save Slack file: ${error.message}`);
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
if (replaceExisting) {
|
|
517
|
+
// Replacing is allowed only for a path the caller named, and a
|
|
518
|
+
// symlink there means write the target rather than remove the link.
|
|
519
|
+
// Renaming creates a new inode, so carry the replaced file's mode
|
|
520
|
+
// over; ownership, ACLs, and xattrs are not preserved.
|
|
521
|
+
const mode = existingFileMode(target);
|
|
522
|
+
if (mode !== undefined) {
|
|
523
|
+
fs.chmodSync(tempPath, mode);
|
|
524
|
+
}
|
|
525
|
+
fs.renameSync(tempPath, target);
|
|
526
|
+
} else {
|
|
527
|
+
placeWithoutReplacing(tempPath, destinationPath);
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
fs.rmSync(tempPath, { force: true });
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Links the finished temp file into place instead of renaming it, because link
|
|
536
|
+
// fails with EEXIST when the destination appeared while the download ran.
|
|
537
|
+
// Filesystems without hard-link support fall back to a checked rename.
|
|
538
|
+
function placeWithoutReplacing(tempPath, destinationPath) {
|
|
539
|
+
try {
|
|
540
|
+
fs.linkSync(tempPath, destinationPath);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error.code === "EEXIST" || fs.existsSync(destinationPath)) {
|
|
543
|
+
throw new Error(`destination file already exists: ${destinationPath}; pass an explicit file path to overwrite it`);
|
|
544
|
+
}
|
|
545
|
+
fs.renameSync(tempPath, destinationPath);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
fs.rmSync(tempPath, { force: true });
|
|
550
|
+
} catch {
|
|
551
|
+
// The destination link is in place; a leftover temp file is harmless.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Follows a chain of symlinks so a save through a link writes the link's
|
|
556
|
+
// target, including a target that does not exist yet. The returned path shares
|
|
557
|
+
// a directory with the temp file, so cross-volume links are not renamed across
|
|
558
|
+
// filesystems.
|
|
559
|
+
function resolveSymlinkTarget(destinationPath) {
|
|
560
|
+
let current = destinationPath;
|
|
561
|
+
const seen = new Set();
|
|
562
|
+
for (;;) {
|
|
563
|
+
let stats;
|
|
564
|
+
try {
|
|
565
|
+
stats = fs.lstatSync(current);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
|
568
|
+
return current;
|
|
569
|
+
}
|
|
570
|
+
throw new Error(`could not access ${current}: ${error.code || error.message}`);
|
|
571
|
+
}
|
|
572
|
+
if (!stats.isSymbolicLink()) {
|
|
573
|
+
return current;
|
|
574
|
+
}
|
|
575
|
+
if (seen.has(current)) {
|
|
576
|
+
throw new Error(`could not resolve symlink ${destinationPath}: link loop`);
|
|
577
|
+
}
|
|
578
|
+
seen.add(current);
|
|
579
|
+
let linkTarget;
|
|
580
|
+
try {
|
|
581
|
+
linkTarget = fs.readlinkSync(current);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
throw new Error(`could not access ${current}: ${error.code || error.message}`);
|
|
584
|
+
}
|
|
585
|
+
current = path.resolve(path.dirname(current), linkTarget);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Returns the mode of an existing regular file so a replacement keeps it, or
|
|
590
|
+
// undefined for a path that does not exist (or is not a file).
|
|
591
|
+
function existingFileMode(filePath) {
|
|
592
|
+
try {
|
|
593
|
+
const stats = fs.statSync(filePath);
|
|
594
|
+
return stats.isFile() ? stats.mode & 0o777 : undefined;
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const WINDOWS_RESERVED_NAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
|
604
|
+
|
|
605
|
+
// A saved attachment keeps the name Slack reports, but a path separator must
|
|
606
|
+
// not let the name escape the destination directory, and Windows interprets
|
|
607
|
+
// names such as "Q3: plan.pdf" as an alternate data stream on "Q3" while
|
|
608
|
+
// reserved device names are unusable as files. Such characters are replaced on
|
|
609
|
+
// Windows; other platforms keep the original name.
|
|
610
|
+
function safeFilename(name, platform = process.platform) {
|
|
611
|
+
let value = String(name ?? "").trim().replace(/[\\/]+/g, "_");
|
|
612
|
+
if (platform === "win32") {
|
|
613
|
+
value = value.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/, "");
|
|
614
|
+
if (WINDOWS_RESERVED_NAME.test(value)) {
|
|
615
|
+
value = `_${value}`;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return value && value !== "." && value !== ".." ? value : "slack-file";
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function statDestinationPath(candidate) {
|
|
622
|
+
try {
|
|
623
|
+
return fs.statSync(candidate);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
throw new Error(`could not access ${candidate}: ${error.code || error.message}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Resolves a destination that is either an existing directory (keep Slack's
|
|
633
|
+
// filename, refusing to replace a file already there) or the full path of the
|
|
634
|
+
// file to write. The parent directory must already exist, so a typo fails
|
|
635
|
+
// before the attachment is downloaded.
|
|
636
|
+
function resolveDestinationPath(destinationPath, filename) {
|
|
637
|
+
const value = requireString(destinationPath, "destination_path");
|
|
638
|
+
const resolved = path.resolve(expandHome(value));
|
|
639
|
+
const stats = statDestinationPath(resolved);
|
|
640
|
+
if (stats?.isDirectory()) {
|
|
641
|
+
const target = path.join(resolved, filename);
|
|
642
|
+
if (fs.existsSync(target)) {
|
|
643
|
+
throw new Error(`destination file already exists: ${target}; pass an explicit file path to overwrite it`);
|
|
644
|
+
}
|
|
645
|
+
return { path: target, replaceExisting: false };
|
|
646
|
+
}
|
|
647
|
+
if (stats && !stats.isFile()) {
|
|
648
|
+
throw new Error(`destination is not a regular file or directory: ${resolved}`);
|
|
649
|
+
}
|
|
650
|
+
if (!stats && /[\\/]$/.test(value)) {
|
|
651
|
+
throw new Error(`destination directory does not exist: ${resolved}`);
|
|
652
|
+
}
|
|
653
|
+
const directory = path.dirname(resolved);
|
|
654
|
+
const directoryStats = statDestinationPath(directory);
|
|
655
|
+
if (!directoryStats) {
|
|
656
|
+
throw new Error(`destination directory does not exist: ${directory}`);
|
|
657
|
+
}
|
|
658
|
+
if (!directoryStats.isDirectory()) {
|
|
659
|
+
throw new Error(`destination parent is not a directory: ${directory}`);
|
|
660
|
+
}
|
|
661
|
+
return { path: resolved, replaceExisting: true };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Accepts a Slack file ID (F…), a file permalink (/files/F…), or a message
|
|
665
|
+
// permalink (/archives/C…/p…). Anything else is rejected rather than guessed.
|
|
666
|
+
function parseFileReference(reference) {
|
|
667
|
+
const value = requireString(reference, "file");
|
|
668
|
+
const filePermalink = value.match(/\/files\/(?:[A-Z0-9]+\/)?(F[A-Z0-9]+)/i);
|
|
669
|
+
if (filePermalink) {
|
|
670
|
+
return { fileId: filePermalink[1].toUpperCase() };
|
|
671
|
+
}
|
|
672
|
+
if (/^F[A-Z0-9]+$/i.test(value)) {
|
|
673
|
+
return { fileId: value.toUpperCase() };
|
|
674
|
+
}
|
|
675
|
+
if (value.includes("/archives/")) {
|
|
676
|
+
return { message: parsePermalink(value) };
|
|
677
|
+
}
|
|
678
|
+
throw new Error(
|
|
679
|
+
`invalid Slack file reference: ${value} (expected a Slack permalink or a file ID such as F0123ABCD)`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
385
683
|
function assertAllowedWriteChannel(channelId, allowed) {
|
|
386
684
|
if (!allowed.has(channelId)) {
|
|
387
685
|
throw new Error(`writes are not allowed for channel ${channelId}`);
|
|
@@ -413,7 +711,9 @@ function withAgentPrefix(text, model) {
|
|
|
413
711
|
const AgentModelField = z
|
|
414
712
|
.string()
|
|
415
713
|
.optional()
|
|
416
|
-
.describe(
|
|
714
|
+
.describe(
|
|
715
|
+
"Backend model ID you are running, for example claude-sonnet-4-5, not a display name; shown in the 🤖 prefix. Always pass it on an agent-authored post, and omit it only when your client cannot determine its model ID."
|
|
716
|
+
);
|
|
417
717
|
|
|
418
718
|
const ReadPermalinkSchema = z.object({
|
|
419
719
|
url: z.string().describe("Slack permalink, e.g. https://<workspace>.slack.com/archives/C.../p..."),
|
|
@@ -451,6 +751,19 @@ const UploadFilesSchema = z.object({
|
|
|
451
751
|
model: AgentModelField,
|
|
452
752
|
});
|
|
453
753
|
|
|
754
|
+
const SaveFileSchema = z.object({
|
|
755
|
+
file: z
|
|
756
|
+
.string()
|
|
757
|
+
.min(1)
|
|
758
|
+
.describe("Slack permalink for the message or file, or a Slack file ID such as F0123ABCD."),
|
|
759
|
+
destination_path: z
|
|
760
|
+
.string()
|
|
761
|
+
.min(1)
|
|
762
|
+
.describe(
|
|
763
|
+
"Local file path to write, or an existing directory that keeps the attachment's filename. An existing directory refuses to replace a file already there; pass an explicit file path to overwrite."
|
|
764
|
+
),
|
|
765
|
+
});
|
|
766
|
+
|
|
454
767
|
const EditMessageSchema = z.object({
|
|
455
768
|
channel_id: z.string(),
|
|
456
769
|
timestamp: z.string().describe("Timestamp of the message or thread reply to edit."),
|
|
@@ -483,7 +796,7 @@ const TOOLS = [
|
|
|
483
796
|
},
|
|
484
797
|
{
|
|
485
798
|
name: "slack_reply_to_thread",
|
|
486
|
-
description: "Reply to a Slack thread. The posted text is prefixed with `🤖:` — or `🤖 <model>:` when model is given — so readers can tell it came from an agent; pass model
|
|
799
|
+
description: "Reply to a Slack thread. The posted text is prefixed with `🤖:` — or `🤖 <model>:` when model is given — so readers can tell it came from an agent; always pass model with the backend model ID you are running when you know it, and omit it only when you cannot determine it. Restricted to allowed channels.",
|
|
487
800
|
inputSchema: zodToJsonSchema(ReplyToThreadSchema),
|
|
488
801
|
},
|
|
489
802
|
{
|
|
@@ -498,19 +811,25 @@ const TOOLS = [
|
|
|
498
811
|
},
|
|
499
812
|
{
|
|
500
813
|
name: "slack_upload_files",
|
|
501
|
-
description: "Upload one or more local files to a channel or thread. A given initial_comment is prefixed with `🤖:` — or `🤖 <model>:` when model is given — so readers can tell it came from an agent; pass model
|
|
814
|
+
description: "Upload one or more local files to a channel or thread. A given initial_comment is prefixed with `🤖:` — or `🤖 <model>:` when model is given — so readers can tell it came from an agent; always pass model with the backend model ID you are running when you know it (it has no effect without initial_comment), and omit it only when you cannot determine it. Restricted to allowed channels.",
|
|
502
815
|
inputSchema: zodToJsonSchema(UploadFilesSchema),
|
|
503
816
|
},
|
|
817
|
+
{
|
|
818
|
+
name: "slack_save_file",
|
|
819
|
+
description:
|
|
820
|
+
"Save a file attached to a Slack message to a local path, keeping the original bytes. Accepts a message or file permalink, or a file ID. Returns the saved path, filename, and MIME type. The destination's parent directory must exist; an existing directory keeps the attachment's filename, with characters the local filesystem rejects replaced, and refuses to overwrite an existing file.",
|
|
821
|
+
inputSchema: zodToJsonSchema(SaveFileSchema),
|
|
822
|
+
},
|
|
504
823
|
{
|
|
505
824
|
name: "slack_edit_message",
|
|
506
|
-
description: "Edit an authored Slack message or thread reply by channel id and timestamp. Replaces the message text, prefixed with `🤖:` — or `🤖 <model>:` when model is given — unless it already carries the prefix; pass model
|
|
825
|
+
description: "Edit an authored Slack message or thread reply by channel id and timestamp. Replaces the message text, prefixed with `🤖:` — or `🤖 <model>:` when model is given — unless it already carries the prefix; always pass model with the backend model ID you are running when you know it, and omit it only when you cannot determine it. Set agent_authored to false when the new text is not agent-written. Any existing Block Kit blocks are removed by Slack. Restricted to allowed channels.",
|
|
507
826
|
inputSchema: zodToJsonSchema(EditMessageSchema),
|
|
508
827
|
},
|
|
509
828
|
];
|
|
510
829
|
|
|
511
830
|
// ── Execute factory (exported for tests) ─────────────────────────────────────
|
|
512
831
|
|
|
513
|
-
function createExecuteToolCall({ slackApi, formatMessages, uploadSlackFiles, allowedWriteChannels }) {
|
|
832
|
+
function createExecuteToolCall({ slackApi, formatMessages, uploadSlackFiles, saveSlackFile, allowedWriteChannels }) {
|
|
514
833
|
const allowed = new Set(allowedWriteChannels);
|
|
515
834
|
return async function executeToolCall(name, args) {
|
|
516
835
|
switch (name) {
|
|
@@ -598,6 +917,22 @@ function createExecuteToolCall({ slackApi, formatMessages, uploadSlackFiles, all
|
|
|
598
917
|
};
|
|
599
918
|
}
|
|
600
919
|
|
|
920
|
+
case "slack_save_file": {
|
|
921
|
+
const a = SaveFileSchema.parse(args);
|
|
922
|
+
const saved = await saveSlackFile({
|
|
923
|
+
reference: requireString(a.file, "file"),
|
|
924
|
+
destinationPath: requireString(a.destination_path, "destination_path"),
|
|
925
|
+
});
|
|
926
|
+
return {
|
|
927
|
+
content: [
|
|
928
|
+
{
|
|
929
|
+
type: "text",
|
|
930
|
+
text: `saved file: ${saved.path}\nfilename: ${saved.filename}\nmime type: ${saved.mimeType}`,
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
601
936
|
case "slack_edit_message": {
|
|
602
937
|
const a = EditMessageSchema.parse(args);
|
|
603
938
|
const channelId = requireString(a.channel_id, "channel_id");
|
|
@@ -643,6 +978,11 @@ function createExecuteToolCall({ slackApi, formatMessages, uploadSlackFiles, all
|
|
|
643
978
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
644
979
|
|
|
645
980
|
async function main() {
|
|
981
|
+
const legacyDiagnostic = legacyEnvDiagnostic();
|
|
982
|
+
if (legacyDiagnostic) {
|
|
983
|
+
console.error(legacyDiagnostic);
|
|
984
|
+
}
|
|
985
|
+
|
|
646
986
|
let config;
|
|
647
987
|
try {
|
|
648
988
|
config = loadConfig();
|
|
@@ -660,14 +1000,14 @@ async function main() {
|
|
|
660
1000
|
process.exit(1);
|
|
661
1001
|
}
|
|
662
1002
|
|
|
663
|
-
const { slackApi, formatMessages, uploadSlackFiles } = createSlackClient({ xoxcToken, xoxdToken, userAgent: config.userAgent });
|
|
1003
|
+
const { slackApi, formatMessages, uploadSlackFiles, saveSlackFile } = createSlackClient({ xoxcToken, xoxdToken, userAgent: config.userAgent });
|
|
664
1004
|
|
|
665
1005
|
const server = new Server(
|
|
666
1006
|
SERVER_INFO,
|
|
667
1007
|
{
|
|
668
1008
|
capabilities: { tools: {} },
|
|
669
1009
|
instructions:
|
|
670
|
-
"MCP server for Slack. Read with slack_read_permalink, slack_get_replies, slack_get_history; write with slack_reply_to_thread, slack_add_reaction, slack_remove_reaction, slack_upload_files, slack_edit_message (restricted to allowed channels). Messages sent by the write tools are prefixed with `🤖:` by default, and slack_edit_message can opt out with agent_authored false; pass model with the model you are running
|
|
1010
|
+
"MCP server for Slack. Read with slack_read_permalink, slack_get_replies, slack_get_history; save an attachment with slack_save_file; write with slack_reply_to_thread, slack_add_reaction, slack_remove_reaction, slack_upload_files, slack_edit_message (restricted to allowed channels). Messages sent by the write tools are prefixed with `🤖:` by default, and slack_edit_message can opt out with agent_authored false; always pass model with the backend model ID you are running when you know it, for example claude-sonnet-4-5, so the prefix names the model — omit it only when you cannot determine it.",
|
|
671
1011
|
}
|
|
672
1012
|
);
|
|
673
1013
|
|
|
@@ -677,6 +1017,7 @@ async function main() {
|
|
|
677
1017
|
slackApi,
|
|
678
1018
|
formatMessages,
|
|
679
1019
|
uploadSlackFiles,
|
|
1020
|
+
saveSlackFile,
|
|
680
1021
|
allowedWriteChannels: config.allowedWriteChannels,
|
|
681
1022
|
});
|
|
682
1023
|
|
|
@@ -718,12 +1059,17 @@ export {
|
|
|
718
1059
|
createExecuteToolCall,
|
|
719
1060
|
createSlackClient,
|
|
720
1061
|
loadConfig,
|
|
1062
|
+
legacyEnvDiagnostic,
|
|
721
1063
|
expandHome,
|
|
1064
|
+
parseFileReference,
|
|
1065
|
+
safeFilename,
|
|
1066
|
+
resolveDestinationPath,
|
|
722
1067
|
ReadPermalinkSchema,
|
|
723
1068
|
GetRepliesSchema,
|
|
724
1069
|
GetHistorySchema,
|
|
725
1070
|
ReplyToThreadSchema,
|
|
726
1071
|
AddReactionSchema,
|
|
727
1072
|
UploadFilesSchema,
|
|
1073
|
+
SaveFileSchema,
|
|
728
1074
|
EditMessageSchema,
|
|
729
1075
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-slack-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for Slack — read permalinks, threads, and channel history; reply to threads; add or remove reactions; edit messages.",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "MCP server for Slack — read permalinks, threads, and channel history; save attachments to local paths; reply to threads; add or remove reactions; edit messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -1,134 +1,151 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: slack-mcp
|
|
3
|
-
description: Read and write Slack through yt-slack-mcp — read permalinks, threads, channel history, and attachments; reply in threads, react, upload files, and edit messages in allowed channels. Use when asked to inspect a Slack link, summarize a channel or thread, find recent channel messages, or reply, react, upload, or edit in Slack.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Slack MCP
|
|
7
|
-
|
|
8
|
-
Use the `yt-slack-mcp` MCP server through this skill. Read before writing, and
|
|
9
|
-
write only where the channel allow-list permits.
|
|
10
|
-
|
|
11
|
-
Server: `yt-slack-mcp` 0.
|
|
12
|
-
|
|
13
|
-
## Setup and auth
|
|
14
|
-
|
|
15
|
-
The server talks to Slack through your logged-in browser session, not a bot
|
|
16
|
-
token.
|
|
17
|
-
|
|
18
|
-
1. Save the `xoxc` token (Slack web localStorage) as a single line in
|
|
19
|
-
`~/.config/yt-slack-mcp/secrets/slack-xoxc.txt`.
|
|
20
|
-
2. Save the `xoxd` token (the cookie named `d`) as a single line in
|
|
21
|
-
`~/.config/yt-slack-mcp/secrets/slack-xoxd.txt`.
|
|
22
|
-
3. Allow writes by listing channel IDs in `allowedWriteChannels` in
|
|
23
|
-
`~/.config/yt-slack-mcp/config.json`. With an empty or missing list, every
|
|
24
|
-
write is refused.
|
|
25
|
-
|
|
26
|
-
Configuration is file-based: `config.json` also accepts `secretsDir`, `xoxcPath`,
|
|
27
|
-
`xoxdPath`, and `userAgent`, and the defaults apply when a key is absent. Never
|
|
28
|
-
echo a token or a file's contents back to the user.
|
|
29
|
-
|
|
30
|
-
### What each failure means
|
|
31
|
-
|
|
32
|
-
| Message | Cause | Fix |
|
|
33
|
-
|---------|-------|-----|
|
|
34
|
-
| `Error: missing slack-xoxc.txt (looked at <path>)` | The `xoxc` file is absent at startup; the server exits before listing tools. | Write the token file at the reported path, or set `xoxcPath` in `config.json` to point at it. |
|
|
35
|
-
| `Error: missing slack-xoxd.txt (looked at <path>)` | Same, for the `xoxd` file. | Write the token file at the reported path, or set `xoxdPath` in `config.json` to point at it. |
|
|
36
|
-
| `Error: invalid Slack MCP config <path>: <detail>` | `config.json` is not valid JSON, or a value has the wrong type; the detail names the problem. | Fix the JSON, correct the value, or remove the file. |
|
|
37
|
-
| `Error: writes are not allowed for channel <id>` | The channel is not in the allow-list. | Add the channel ID to the allow-list if the user wants writes there; never work around the refusal. |
|
|
38
|
-
| `slack <method> failed: invalid_auth` (or `not_authed`) | The browser session expired or the token was copied incorrectly. | Refresh the `xoxc`/`xoxd` pair, then retry once. |
|
|
39
|
-
| `Error: missing <name>` | A required write-tool argument (`channel_id`, `thread_ts`, `timestamp`, `text`, `reaction`) was blank. | Supply the actual value; do not invent IDs. |
|
|
40
|
-
|
|
41
|
-
Tool errors arrive as text starting with `Error:`. Report the text instead of
|
|
42
|
-
retrying the same call unchanged.
|
|
43
|
-
|
|
44
|
-
## Troubleshooting
|
|
45
|
-
|
|
46
|
-
- **Writes refused or tokens missing after an upgrade.** Configuration is
|
|
47
|
-
file-based only: values supplied through environment variables are ignored.
|
|
48
|
-
Move `secretsDir`, `xoxcPath`, `xoxdPath`, `allowedWriteChannels`, and
|
|
49
|
-
`userAgent` into `~/.config/yt-slack-mcp/config.json`.
|
|
50
|
-
- **Only some expected channels are writable.** `allowedWriteChannels` is the
|
|
51
|
-
complete list with no additive default; compare it with the channel IDs the
|
|
52
|
-
user expects.
|
|
53
|
-
- **The server exits at startup.** A reported token path is missing; create the
|
|
54
|
-
file at that path. An empty or stale token does not stop startup; the first
|
|
55
|
-
Slack call fails later as `invalid_auth`.
|
|
56
|
-
- **A write still fails inside an allowed channel.** The browser session may have
|
|
57
|
-
lost write access or the message may be outside the edit window; report the
|
|
58
|
-
exact `Error:` text instead of retrying.
|
|
59
|
-
|
|
60
|
-
## Tool map
|
|
61
|
-
|
|
62
|
-
Choose the read tool from what the user gave you, and the write tool from the
|
|
63
|
-
action they asked for.
|
|
64
|
-
|
|
65
|
-
| Tool | Use it when |
|
|
66
|
-
|------|-------------|
|
|
67
|
-
| `slack_read_permalink` | A Slack link is the input. Reads the linked message's thread, including inline images, PDF text, and text attachments. |
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1
|
+
---
|
|
2
|
+
name: slack-mcp
|
|
3
|
+
description: Read and write Slack through yt-slack-mcp — read permalinks, threads, channel history, and attachments; save an attachment to a local file; reply in threads, react, upload files, and edit messages in allowed channels. Use when asked to inspect a Slack link, summarize a channel or thread, find recent channel messages, save an attachment, or reply, react, upload, or edit in Slack.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Slack MCP
|
|
7
|
+
|
|
8
|
+
Use the `yt-slack-mcp` MCP server through this skill. Read before writing, and
|
|
9
|
+
write only where the channel allow-list permits.
|
|
10
|
+
|
|
11
|
+
Server: `yt-slack-mcp` 0.7.0, launched as `npx yt-slack-mcp` over stdio.
|
|
12
|
+
|
|
13
|
+
## Setup and auth
|
|
14
|
+
|
|
15
|
+
The server talks to Slack through your logged-in browser session, not a bot
|
|
16
|
+
token.
|
|
17
|
+
|
|
18
|
+
1. Save the `xoxc` token (Slack web localStorage) as a single line in
|
|
19
|
+
`~/.config/yt-slack-mcp/secrets/slack-xoxc.txt`.
|
|
20
|
+
2. Save the `xoxd` token (the cookie named `d`) as a single line in
|
|
21
|
+
`~/.config/yt-slack-mcp/secrets/slack-xoxd.txt`.
|
|
22
|
+
3. Allow writes by listing channel IDs in `allowedWriteChannels` in
|
|
23
|
+
`~/.config/yt-slack-mcp/config.json`. With an empty or missing list, every
|
|
24
|
+
write is refused.
|
|
25
|
+
|
|
26
|
+
Configuration is file-based: `config.json` also accepts `secretsDir`, `xoxcPath`,
|
|
27
|
+
`xoxdPath`, and `userAgent`, and the defaults apply when a key is absent. Never
|
|
28
|
+
echo a token or a file's contents back to the user.
|
|
29
|
+
|
|
30
|
+
### What each failure means
|
|
31
|
+
|
|
32
|
+
| Message | Cause | Fix |
|
|
33
|
+
|---------|-------|-----|
|
|
34
|
+
| `Error: missing slack-xoxc.txt (looked at <path>)` | The `xoxc` file is absent at startup; the server exits before listing tools. | Write the token file at the reported path, or set `xoxcPath` in `config.json` to point at it. |
|
|
35
|
+
| `Error: missing slack-xoxd.txt (looked at <path>)` | Same, for the `xoxd` file. | Write the token file at the reported path, or set `xoxdPath` in `config.json` to point at it. |
|
|
36
|
+
| `Error: invalid Slack MCP config <path>: <detail>` | `config.json` is not valid JSON, or a value has the wrong type; the detail names the problem. | Fix the JSON, correct the value, or remove the file. |
|
|
37
|
+
| `Error: writes are not allowed for channel <id>` | The channel is not in the allow-list. | Add the channel ID to the allow-list if the user wants writes there; never work around the refusal. |
|
|
38
|
+
| `slack <method> failed: invalid_auth` (or `not_authed`) | The browser session expired or the token was copied incorrectly. | Refresh the `xoxc`/`xoxd` pair, then retry once. |
|
|
39
|
+
| `Error: missing <name>` | A required write-tool argument (`channel_id`, `thread_ts`, `timestamp`, `text`, `reaction`) was blank. | Supply the actual value; do not invent IDs. |
|
|
40
|
+
|
|
41
|
+
Tool errors arrive as text starting with `Error:`. Report the text instead of
|
|
42
|
+
retrying the same call unchanged.
|
|
43
|
+
|
|
44
|
+
## Troubleshooting
|
|
45
|
+
|
|
46
|
+
- **Writes refused or tokens missing after an upgrade.** Configuration is
|
|
47
|
+
file-based only: values supplied through environment variables are ignored.
|
|
48
|
+
Move `secretsDir`, `xoxcPath`, `xoxdPath`, `allowedWriteChannels`, and
|
|
49
|
+
`userAgent` into `~/.config/yt-slack-mcp/config.json`.
|
|
50
|
+
- **Only some expected channels are writable.** `allowedWriteChannels` is the
|
|
51
|
+
complete list with no additive default; compare it with the channel IDs the
|
|
52
|
+
user expects.
|
|
53
|
+
- **The server exits at startup.** A reported token path is missing; create the
|
|
54
|
+
file at that path. An empty or stale token does not stop startup; the first
|
|
55
|
+
Slack call fails later as `invalid_auth`.
|
|
56
|
+
- **A write still fails inside an allowed channel.** The browser session may have
|
|
57
|
+
lost write access or the message may be outside the edit window; report the
|
|
58
|
+
exact `Error:` text instead of retrying.
|
|
59
|
+
|
|
60
|
+
## Tool map
|
|
61
|
+
|
|
62
|
+
Choose the read tool from what the user gave you, and the write tool from the
|
|
63
|
+
action they asked for.
|
|
64
|
+
|
|
65
|
+
| Tool | Use it when |
|
|
66
|
+
|------|-------------|
|
|
67
|
+
| `slack_read_permalink` | A Slack link is the input. Reads the linked message's thread, including inline images, PDF text, and text attachments. |
|
|
68
|
+
| `slack_save_file` | The user needs an attachment on disk. Accepts a message or file permalink, or a file ID, plus `destination_path`; keeps the original bytes, replaces characters the OS rejects in the filename, and refuses to overwrite an existing file in a directory destination. Reports the saved path, filename, and MIME type. It is not channel-restricted. |
|
|
69
|
+
| `slack_get_replies` | You have a channel ID and thread timestamp and need that thread again or its latest replies. |
|
|
70
|
+
| `slack_get_history` | There is no link or thread: read recent messages when you have a channel ID. The tool does not resolve channel names. |
|
|
71
|
+
| `slack_reply_to_thread` | Post a reply into an existing thread. Requires `channel_id`, `thread_ts`, `text`, and `model` set to your backend model ID whenever you can determine it. |
|
|
72
|
+
| `slack_add_reaction` | Mark or acknowledge a message without posting text. |
|
|
73
|
+
| `slack_remove_reaction` | Remove a reaction this account added. |
|
|
74
|
+
| `slack_upload_files` | Post up to 10 local files (10 MB each) to a channel or thread, with an optional `initial_comment`. |
|
|
75
|
+
| `slack_edit_message` | Correct a message this account already posted, by channel ID and timestamp. |
|
|
76
|
+
|
|
77
|
+
The server has no plain top-level text post; a reply needs an existing thread.
|
|
78
|
+
A file upload with an `initial_comment` is the only way to start a new top-level
|
|
79
|
+
message. Say so instead of promising a text post.
|
|
80
|
+
|
|
81
|
+
## Recipes
|
|
82
|
+
|
|
83
|
+
### Read a permalink and reply in its thread
|
|
84
|
+
|
|
85
|
+
1. Call `slack_read_permalink` with the `url`.
|
|
86
|
+
2. Take the channel ID from the permalink and the root `[ts]` from the first
|
|
87
|
+
returned message.
|
|
88
|
+
3. Read the whole thread before answering; do not repost what is already there.
|
|
89
|
+
4. Call `slack_reply_to_thread` with `channel_id`, `thread_ts`, `text`, and
|
|
90
|
+
`model` set to the backend model ID you are running, for example
|
|
91
|
+
`claude-sonnet-4-5` — not a display name.
|
|
92
|
+
|
|
93
|
+
### Summarize a channel
|
|
94
|
+
|
|
95
|
+
1. Resolve the channel ID first. The tool does not accept names like `#ops`; take
|
|
96
|
+
the ID from a permalink or ask the user.
|
|
97
|
+
2. Call `slack_get_history` with `channel_id` and `limit` up to 200.
|
|
98
|
+
3. Summarize in the conversation, quoting `[ts]` values when the user needs to
|
|
99
|
+
locate a message.
|
|
100
|
+
4. Post the summary only when asked, and only as a reply in a relevant thread or
|
|
101
|
+
as a file upload's `initial_comment`; there is no plain top-level text post.
|
|
102
|
+
|
|
103
|
+
### Upload a file with a comment
|
|
104
|
+
|
|
105
|
+
1. Resolve absolute local paths; at most 10 files, 10 MB each.
|
|
106
|
+
2. Call `slack_upload_files` with `file_paths`, `channel_id`, an optional
|
|
107
|
+
`thread_ts`, and `initial_comment` when a message should accompany the files.
|
|
108
|
+
3. Pass `model` with the backend model ID you are running; it is used only when
|
|
109
|
+
`initial_comment` is present, and without a comment the upload stays
|
|
110
|
+
comment-free.
|
|
111
|
+
4. Report the returned file and message permalinks.
|
|
112
|
+
|
|
113
|
+
### Save an attachment to disk
|
|
114
|
+
|
|
115
|
+
1. Take the message or file permalink, or the file ID, from the user.
|
|
116
|
+
2. Resolve the destination: an existing directory keeps Slack's filename, with
|
|
117
|
+
characters the OS rejects replaced, and refuses to overwrite a file already
|
|
118
|
+
there; pass an explicit file path to overwrite. Its parent directory must
|
|
119
|
+
exist.
|
|
120
|
+
3. Call `slack_save_file` with `file` and `destination_path`.
|
|
121
|
+
4. Report the returned saved path, filename, and MIME type. If the message has
|
|
122
|
+
several attachments, the error lists their IDs; retry with the one the user
|
|
123
|
+
meant.
|
|
124
|
+
|
|
125
|
+
### Correct a posted message
|
|
126
|
+
|
|
127
|
+
1. Call `slack_edit_message` with `channel_id`, the message `timestamp`, and the
|
|
128
|
+
replacement `text`.
|
|
129
|
+
2. Leave `agent_authored` at its default for agent-written text; pass `model`
|
|
130
|
+
with your backend model ID so the prefix names the model.
|
|
131
|
+
3. Pass `agent_authored: false` only when replacing agent text with the user's own
|
|
132
|
+
words, such as correcting their message; `model` is then ignored.
|
|
133
|
+
4. Slack removes existing Block Kit blocks on edit and only allows edits to this
|
|
134
|
+
account's messages within its edit window.
|
|
135
|
+
|
|
136
|
+
## Guardrails
|
|
137
|
+
|
|
138
|
+
- Read before writing. Fetch the thread or channel context first, even when the
|
|
139
|
+
requested text seems obvious.
|
|
140
|
+
- Stay inside the allow-list. Never attempt a write to a channel that is not
|
|
141
|
+
listed, and never suggest disabling the check to "make it work".
|
|
142
|
+
- Always pass `model` on every text-writing call — reply, upload with a comment,
|
|
143
|
+
edit — with the backend model ID you are running, for example
|
|
144
|
+
`claude-sonnet-4-5`, not a display name, so posts read `🤖 <model ID>:`. Omit it
|
|
145
|
+
only when you cannot determine your model ID. Never add the prefix yourself;
|
|
146
|
+
the server adds it once.
|
|
147
|
+
- `agent_authored: false` is an explicit correction of non-agent text, not a
|
|
148
|
+
default and not a way to hide that an agent posted.
|
|
149
|
+
- Channel names are not resolved. Never pass `#name` as `channel_id`; use a
|
|
150
|
+
`C...`, `G...`, or `D...` ID from a permalink or ask the user.
|
|
151
|
+
- Prefer edits over reposting. Do not "fix" a message by posting it again.
|