yt-slack-mcp 0.1.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 -0
- package/README.md +101 -0
- package/index.js +463 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# yt-slack-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for **Slack** — read permalinks, threads, and channel history; reply to threads; add reactions.
|
|
4
|
+
|
|
5
|
+
Implements the [Model Context Protocol](https://modelcontextprotocol.io) specification: it runs over the stdio transport, speaks JSON-RPC 2.0, negotiates the protocol version on `initialize`, advertises `tools` capabilities, and describes each tool with a JSON Schema. Protocol version negotiation is handled by the `@modelcontextprotocol/sdk` `Server` class, which defaults to the latest supported version.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| `slack_read_permalink` | Read a message or thread from a permalink (PDFs/images included) |
|
|
12
|
+
| `slack_get_replies` | Read replies for a thread by channel id + timestamp |
|
|
13
|
+
| `slack_get_history` | Read recent channel history |
|
|
14
|
+
| `slack_reply_to_thread` | Reply to a thread (restricted to allowed channels) |
|
|
15
|
+
| `slack_add_reaction` | Add a reaction (restricted to allowed channels) |
|
|
16
|
+
|
|
17
|
+
Read tools use the private Slack browser-session API (via `xoxc` / `xoxd` tokens), so they can pull private file contents — PDFs are downloaded and text-extracted (up to 50 pages / 100k chars), images are returned inline.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
### 1. Obtain browser session tokens
|
|
22
|
+
|
|
23
|
+
- `xoxc`: Slack web localStorage token (from your logged-in browser session).
|
|
24
|
+
- `xoxd`: Slack cookie named `d`.
|
|
25
|
+
|
|
26
|
+
These may need refreshing when Slack rotates/invalidates the session.
|
|
27
|
+
|
|
28
|
+
### 2. Place the secrets
|
|
29
|
+
|
|
30
|
+
Save each token as a single-line file:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
~/.config/opencode/secrets/slack-xoxc.txt
|
|
34
|
+
~/.config/opencode/secrets/slack-xoxd.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or override the paths via the config file or env vars.
|
|
38
|
+
|
|
39
|
+
### 3. (Optional) Configure allowed write channels and paths
|
|
40
|
+
|
|
41
|
+
All configuration can live in a JSON config file at
|
|
42
|
+
`~/.config/yt-slack-mcp/config.json` (override with `SLACK_MCP_CONFIG`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"secretsDir": "~/.config/opencode/secrets",
|
|
47
|
+
"xoxcPath": "~/.config/opencode/secrets/slack-xoxc.txt",
|
|
48
|
+
"xoxdPath": "~/.config/opencode/secrets/slack-xoxd.txt",
|
|
49
|
+
"allowedWriteChannels": ["C0123456789", "C0987654321"],
|
|
50
|
+
"userAgent": "Mozilla/5.0"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
By default the write tools (`slack_reply_to_thread`, `slack_add_reaction`)
|
|
55
|
+
are locked down — no channel is writable. Every field is optional; a
|
|
56
|
+
missing file just means defaults. Env vars always override the config file.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Config resolution order: built-in defaults → config file → env vars.
|
|
61
|
+
|
|
62
|
+
| Config file key | Env var | Default | Description |
|
|
63
|
+
|-----------------|---------|---------|-------------|
|
|
64
|
+
| `secretsDir` | `SLACK_MCP_SECRETS_DIR` | `~/.config/opencode/secrets` | Directory holding the token files |
|
|
65
|
+
| `xoxcPath` | `SLACK_MCP_XOXC_PATH` | `$SECRETS_DIR/slack-xoxc.txt` | Path to the `xoxc` token |
|
|
66
|
+
| `xoxdPath` | `SLACK_MCP_XOXD_PATH` | `$SECRETS_DIR/slack-xoxd.txt` | Path to the `xoxd` token |
|
|
67
|
+
| `allowedWriteChannels` | `SLACK_MCP_ALLOWED_WRITE_CHANNELS` | *(none)* | Channel IDs allowed for write tools (comma-separated in env) |
|
|
68
|
+
| `userAgent` | `SLACK_MCP_USER_AGENT` | `Mozilla/5.0` | HTTP `User-Agent` header |
|
|
69
|
+
| *(config file path)* | `SLACK_MCP_CONFIG` | `~/.config/yt-slack-mcp/config.json` | Path to the JSON config file |
|
|
70
|
+
|
|
71
|
+
## MCP host config
|
|
72
|
+
|
|
73
|
+
### opencode
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{
|
|
77
|
+
"mcp": {
|
|
78
|
+
"slack": {
|
|
79
|
+
"type": "local",
|
|
80
|
+
"command": ["npx", "yt-slack-mcp"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Claude Desktop
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"slack": {
|
|
92
|
+
"command": "npx",
|
|
93
|
+
"args": ["yt-slack-mcp"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Slack MCP server. Mirrors yt-gmail-mcp / yt-zoho-mcp's structure: pure
|
|
4
|
+
// helpers and the execute-tool factory are exported for unit tests; main()
|
|
5
|
+
// only runs when the file is executed directly.
|
|
6
|
+
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import {
|
|
10
|
+
CallToolRequestSchema,
|
|
11
|
+
ListToolsRequestSchema,
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SECRETS_DIR = path.join(os.homedir(), ".config", "opencode", "secrets");
|
|
22
|
+
const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".config", "yt-slack-mcp", "config.json");
|
|
23
|
+
|
|
24
|
+
const maxImageBytes = 10 * 1024 * 1024;
|
|
25
|
+
const maxPdfBytes = 20 * 1024 * 1024;
|
|
26
|
+
const maxPdfPages = 50;
|
|
27
|
+
const maxPdfTextCharacters = 100_000;
|
|
28
|
+
|
|
29
|
+
function expandHome(filePath) {
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
return filePath;
|
|
32
|
+
}
|
|
33
|
+
if (filePath === "~") {
|
|
34
|
+
return os.homedir();
|
|
35
|
+
}
|
|
36
|
+
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
37
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
38
|
+
}
|
|
39
|
+
return filePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseChannelList(value) {
|
|
43
|
+
if (value === undefined || value === null || value === "") {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return value
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((c) => c.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadConfig(env = process.env) {
|
|
53
|
+
const configPath = env.SLACK_MCP_CONFIG || DEFAULT_CONFIG_PATH;
|
|
54
|
+
let fileConfig = {};
|
|
55
|
+
if (fs.existsSync(configPath)) {
|
|
56
|
+
try {
|
|
57
|
+
fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(`invalid Slack MCP config ${configPath}: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const secretsDir =
|
|
64
|
+
env.SLACK_MCP_SECRETS_DIR || expandHome(fileConfig.secretsDir) || DEFAULT_SECRETS_DIR;
|
|
65
|
+
const xoxcPath =
|
|
66
|
+
env.SLACK_MCP_XOXC_PATH || expandHome(fileConfig.xoxcPath) || path.join(secretsDir, "slack-xoxc.txt");
|
|
67
|
+
const xoxdPath =
|
|
68
|
+
env.SLACK_MCP_XOXD_PATH || expandHome(fileConfig.xoxdPath) || path.join(secretsDir, "slack-xoxd.txt");
|
|
69
|
+
const allowedWriteChannels =
|
|
70
|
+
parseChannelList(env.SLACK_MCP_ALLOWED_WRITE_CHANNELS) ?? fileConfig.allowedWriteChannels ?? [];
|
|
71
|
+
const userAgent = env.SLACK_MCP_USER_AGENT || fileConfig.userAgent || "Mozilla/5.0";
|
|
72
|
+
|
|
73
|
+
return { configPath, secretsDir, xoxcPath, xoxdPath, allowedWriteChannels, userAgent };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readSecret(filePath, label) {
|
|
77
|
+
if (!fs.existsSync(filePath)) {
|
|
78
|
+
throw new Error(`missing ${label} (looked at ${filePath})`);
|
|
79
|
+
}
|
|
80
|
+
return fs.readFileSync(filePath, "utf8").trim().replace(/^['"]|['"]$/g, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createSlackClient({ xoxcToken, xoxdToken, userAgent = "Mozilla/5.0" }) {
|
|
84
|
+
const slackHeaders = () => ({
|
|
85
|
+
authorization: `Bearer ${xoxcToken}`,
|
|
86
|
+
cookie: `d=${xoxdToken}`,
|
|
87
|
+
"user-agent": userAgent,
|
|
88
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const slackApi = async (method, params, httpMethod = "GET") => {
|
|
92
|
+
const url = new URL(`https://slack.com/api/${method}`);
|
|
93
|
+
const options = { method: httpMethod, headers: slackHeaders() };
|
|
94
|
+
if (httpMethod === "GET") {
|
|
95
|
+
for (const [key, value] of Object.entries(params)) {
|
|
96
|
+
if (value !== undefined && value !== null) {
|
|
97
|
+
url.searchParams.set(key, value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
options.body = new URLSearchParams(params).toString();
|
|
102
|
+
}
|
|
103
|
+
const response = await fetch(url, options);
|
|
104
|
+
const data = await response.json();
|
|
105
|
+
if (!data.ok) {
|
|
106
|
+
throw new Error(`slack ${method} failed: ${data.error || response.statusText}`);
|
|
107
|
+
}
|
|
108
|
+
return data;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const getSlackFileData = async (file) => {
|
|
112
|
+
if (file.url_private_download || file.url_private || !file.id) {
|
|
113
|
+
return file;
|
|
114
|
+
}
|
|
115
|
+
const response = await slackApi("files.info", { file: file.id });
|
|
116
|
+
return response.file || file;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const downloadSlackImage = async (file) => {
|
|
120
|
+
const fileData = await getSlackFileData(file);
|
|
121
|
+
const imageUrl = fileData.url_private_download || fileData.url_private;
|
|
122
|
+
if (!imageUrl) {
|
|
123
|
+
throw new Error("Slack did not provide a private image URL");
|
|
124
|
+
}
|
|
125
|
+
const response = await fetch(imageUrl, { headers: slackHeaders() });
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
throw new Error(`Slack image download failed: ${response.status}`);
|
|
128
|
+
}
|
|
129
|
+
const imageData = await response.arrayBuffer();
|
|
130
|
+
if (imageData.byteLength > maxImageBytes) {
|
|
131
|
+
throw new Error(`image exceeds ${maxImageBytes / 1024 / 1024} MB limit`);
|
|
132
|
+
}
|
|
133
|
+
const mimeType = response.headers.get("content-type") || fileData.mimetype;
|
|
134
|
+
if (!mimeType?.startsWith("image/")) {
|
|
135
|
+
throw new Error(`Slack returned ${mimeType || "an unknown content type"}`);
|
|
136
|
+
}
|
|
137
|
+
return { type: "image", data: Buffer.from(imageData).toString("base64"), mimeType: mimeType.split(";", 1)[0] };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const extractPdfText = async (pdfData) => {
|
|
141
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
142
|
+
const document = await pdfjs.getDocument({ data: new Uint8Array(pdfData), useWorkerFetch: false, isEvalSupported: false }).promise;
|
|
143
|
+
const pageCount = Math.min(document.numPages, maxPdfPages);
|
|
144
|
+
const pages = [];
|
|
145
|
+
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
|
146
|
+
const page = await document.getPage(pageNumber);
|
|
147
|
+
const textContent = await page.getTextContent();
|
|
148
|
+
const lines = [];
|
|
149
|
+
let line = "";
|
|
150
|
+
for (const item of textContent.items) {
|
|
151
|
+
if (!("str" in item)) continue;
|
|
152
|
+
line += `${item.str}${item.hasEOL ? "" : " "}`;
|
|
153
|
+
if (item.hasEOL) {
|
|
154
|
+
lines.push(line.trim());
|
|
155
|
+
line = "";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (line.trim()) lines.push(line.trim());
|
|
159
|
+
pages.push(`Page ${pageNumber}\n${lines.join("\n")}`);
|
|
160
|
+
}
|
|
161
|
+
let text = pages.join("\n\n").trim();
|
|
162
|
+
if (document.numPages > maxPdfPages) {
|
|
163
|
+
text += `\n\n[PDF text truncated after ${maxPdfPages} pages]`;
|
|
164
|
+
}
|
|
165
|
+
if (text.length > maxPdfTextCharacters) {
|
|
166
|
+
text = `${text.slice(0, maxPdfTextCharacters)}\n\n[PDF text truncated at ${maxPdfTextCharacters} characters]`;
|
|
167
|
+
}
|
|
168
|
+
return text || "[PDF contains no extractable text]";
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const downloadSlackPdf = async (file) => {
|
|
172
|
+
const fileData = await getSlackFileData(file);
|
|
173
|
+
const pdfUrl = fileData.url_private_download || fileData.url_private;
|
|
174
|
+
if (!pdfUrl) {
|
|
175
|
+
throw new Error("Slack did not provide a private PDF URL");
|
|
176
|
+
}
|
|
177
|
+
const response = await fetch(pdfUrl, { headers: slackHeaders() });
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
throw new Error(`Slack PDF download failed: ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
const pdfData = await response.arrayBuffer();
|
|
182
|
+
if (pdfData.byteLength > maxPdfBytes) {
|
|
183
|
+
throw new Error(`PDF exceeds ${maxPdfBytes / 1024 / 1024} MB limit`);
|
|
184
|
+
}
|
|
185
|
+
const mimeType = response.headers.get("content-type") || fileData.mimetype;
|
|
186
|
+
if (mimeType !== "application/pdf" && !(fileData.name || "").toLowerCase().endsWith(".pdf")) {
|
|
187
|
+
throw new Error(`Slack returned ${mimeType || "an unknown content type"}`);
|
|
188
|
+
}
|
|
189
|
+
return extractPdfText(pdfData);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const formatMessages = async (messages) => {
|
|
193
|
+
if (!messages.length) {
|
|
194
|
+
return [{ type: "text", text: "No messages found." }];
|
|
195
|
+
}
|
|
196
|
+
const content = [];
|
|
197
|
+
for (const message of messages) {
|
|
198
|
+
const author = message.user || message.username || message.bot_id || "unknown";
|
|
199
|
+
const lines = [`[${message.ts}] ${author}: ${message.text || ""}`];
|
|
200
|
+
if (message.files?.length) {
|
|
201
|
+
for (const file of message.files) {
|
|
202
|
+
lines.push(`file: ${file.title || file.name || file.id || "untitled"} (${file.mimetype || "unknown type"})`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (message.attachments?.length) {
|
|
206
|
+
for (const attachment of message.attachments) {
|
|
207
|
+
if (attachment.title) lines.push(`attachment: ${attachment.title}`);
|
|
208
|
+
if (attachment.text) lines.push(`attachment text: ${attachment.text}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
content.push({ type: "text", text: lines.join("\n") });
|
|
212
|
+
for (const file of message.files || []) {
|
|
213
|
+
if (file.mimetype?.startsWith("image/")) {
|
|
214
|
+
try {
|
|
215
|
+
content.push(await downloadSlackImage(file));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
content.push({ type: "text", text: `image unavailable: ${error.message}` });
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (file.mimetype === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) {
|
|
222
|
+
try {
|
|
223
|
+
const title = file.title || file.name || file.id || "untitled";
|
|
224
|
+
content.push({ type: "text", text: `PDF text: ${title}\n\n${await downloadSlackPdf(file)}` });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
content.push({ type: "text", text: `PDF unavailable: ${error.message}` });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return content;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return { slackApi, formatMessages };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parsePermalink(url) {
|
|
238
|
+
const match = url.match(/\/archives\/(C[A-Z0-9]+)\/p(\d{10})(\d{6})/);
|
|
239
|
+
if (!match) {
|
|
240
|
+
throw new Error("invalid Slack permalink");
|
|
241
|
+
}
|
|
242
|
+
return { channelId: match[1], timestamp: `${match[2]}.${match[3]}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function requireString(value, name) {
|
|
246
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
247
|
+
throw new Error(`missing ${name}`);
|
|
248
|
+
}
|
|
249
|
+
return value.trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function assertAllowedWriteChannel(channelId, allowed) {
|
|
253
|
+
if (!allowed.has(channelId)) {
|
|
254
|
+
throw new Error(`writes are not allowed for channel ${channelId}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Schemas ─────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const ReadPermalinkSchema = z.object({
|
|
261
|
+
url: z.string().describe("Slack permalink, e.g. https://<workspace>.slack.com/archives/C.../p..."),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const GetRepliesSchema = z.object({
|
|
265
|
+
channel_id: z.string(),
|
|
266
|
+
thread_ts: z.string(),
|
|
267
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const GetHistorySchema = z.object({
|
|
271
|
+
channel_id: z.string(),
|
|
272
|
+
limit: z.number().int().min(1).max(200).optional().default(20),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const ReplyToThreadSchema = z.object({
|
|
276
|
+
channel_id: z.string(),
|
|
277
|
+
thread_ts: z.string(),
|
|
278
|
+
text: z.string(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const AddReactionSchema = z.object({
|
|
282
|
+
channel_id: z.string(),
|
|
283
|
+
timestamp: z.string(),
|
|
284
|
+
reaction: z.string().describe("Emoji name without colons."),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── Tool definitions ─────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
const TOOLS = [
|
|
290
|
+
{
|
|
291
|
+
name: "slack_read_permalink",
|
|
292
|
+
description: "Read a Slack message or thread from a permalink, including extracted text from attached PDFs and images.",
|
|
293
|
+
inputSchema: zodToJsonSchema(ReadPermalinkSchema),
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "slack_get_replies",
|
|
297
|
+
description: "Read replies for a Slack thread by channel id and thread timestamp.",
|
|
298
|
+
inputSchema: zodToJsonSchema(GetRepliesSchema),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: "slack_get_history",
|
|
302
|
+
description: "Read recent Slack channel history.",
|
|
303
|
+
inputSchema: zodToJsonSchema(GetHistorySchema),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "slack_reply_to_thread",
|
|
307
|
+
description: "Reply to a Slack thread. Restricted to allowed channels.",
|
|
308
|
+
inputSchema: zodToJsonSchema(ReplyToThreadSchema),
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "slack_add_reaction",
|
|
312
|
+
description: "Add a reaction to a Slack message. Restricted to allowed channels.",
|
|
313
|
+
inputSchema: zodToJsonSchema(AddReactionSchema),
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
// ── Execute factory (exported for tests) ─────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function createExecuteToolCall({ slackApi, formatMessages, allowedWriteChannels }) {
|
|
320
|
+
const allowed = new Set(allowedWriteChannels);
|
|
321
|
+
return async function executeToolCall(name, args) {
|
|
322
|
+
switch (name) {
|
|
323
|
+
case "slack_read_permalink": {
|
|
324
|
+
const a = ReadPermalinkSchema.parse(args);
|
|
325
|
+
const { channelId, timestamp } = parsePermalink(a.url);
|
|
326
|
+
const messages = await slackApi("conversations.replies", {
|
|
327
|
+
channel: channelId,
|
|
328
|
+
ts: timestamp,
|
|
329
|
+
limit: "50",
|
|
330
|
+
});
|
|
331
|
+
return { content: await formatMessages(messages.messages || []) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case "slack_get_replies": {
|
|
335
|
+
const a = GetRepliesSchema.parse(args);
|
|
336
|
+
const messages = await slackApi("conversations.replies", {
|
|
337
|
+
channel: a.channel_id,
|
|
338
|
+
ts: a.thread_ts,
|
|
339
|
+
limit: String(a.limit),
|
|
340
|
+
});
|
|
341
|
+
return { content: await formatMessages(messages.messages || []) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case "slack_get_history": {
|
|
345
|
+
const a = GetHistorySchema.parse(args);
|
|
346
|
+
const messages = await slackApi("conversations.history", {
|
|
347
|
+
channel: a.channel_id,
|
|
348
|
+
limit: String(a.limit),
|
|
349
|
+
});
|
|
350
|
+
return { content: await formatMessages(messages.messages || []) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case "slack_reply_to_thread": {
|
|
354
|
+
const a = ReplyToThreadSchema.parse(args);
|
|
355
|
+
const channelId = requireString(a.channel_id, "channel_id");
|
|
356
|
+
assertAllowedWriteChannel(channelId, allowed);
|
|
357
|
+
const response = await slackApi("chat.postMessage", {
|
|
358
|
+
channel: channelId,
|
|
359
|
+
thread_ts: requireString(a.thread_ts, "thread_ts"),
|
|
360
|
+
text: requireString(a.text, "text"),
|
|
361
|
+
}, "POST");
|
|
362
|
+
return { content: [{ type: "text", text: `posted reply at ${response.ts}` }] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case "slack_add_reaction": {
|
|
366
|
+
const a = AddReactionSchema.parse(args);
|
|
367
|
+
const channelId = requireString(a.channel_id, "channel_id");
|
|
368
|
+
assertAllowedWriteChannel(channelId, allowed);
|
|
369
|
+
await slackApi("reactions.add", {
|
|
370
|
+
channel: channelId,
|
|
371
|
+
timestamp: requireString(a.timestamp, "timestamp"),
|
|
372
|
+
name: requireString(a.reaction, "reaction"),
|
|
373
|
+
}, "POST");
|
|
374
|
+
return { content: [{ type: "text", text: "added reaction" }] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
default:
|
|
378
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
async function main() {
|
|
386
|
+
let config;
|
|
387
|
+
try {
|
|
388
|
+
config = loadConfig();
|
|
389
|
+
} catch (e) {
|
|
390
|
+
console.error(`Error: ${e.message}`);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let xoxcToken, xoxdToken;
|
|
395
|
+
try {
|
|
396
|
+
xoxcToken = readSecret(config.xoxcPath, "slack-xoxc.txt");
|
|
397
|
+
xoxdToken = readSecret(config.xoxdPath, "slack-xoxd.txt");
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.error(`Error: ${e.message}`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const { slackApi, formatMessages } = createSlackClient({ xoxcToken, xoxdToken, userAgent: config.userAgent });
|
|
404
|
+
|
|
405
|
+
const server = new Server(
|
|
406
|
+
{ name: "yt-slack-mcp", version: "0.1.0" },
|
|
407
|
+
{
|
|
408
|
+
capabilities: { tools: {} },
|
|
409
|
+
instructions:
|
|
410
|
+
"MCP server for Slack. Read with slack_read_permalink, slack_get_replies, slack_get_history; write with slack_reply_to_thread, slack_add_reaction (restricted to allowed channels).",
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
415
|
+
|
|
416
|
+
const executeToolCall = createExecuteToolCall({
|
|
417
|
+
slackApi,
|
|
418
|
+
formatMessages,
|
|
419
|
+
allowedWriteChannels: config.allowedWriteChannels,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
423
|
+
const { name, arguments: args } = req.params;
|
|
424
|
+
try {
|
|
425
|
+
return await executeToolCall(name, args);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
429
|
+
isError: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const transport = new StdioServerTransport();
|
|
435
|
+
await server.connect(transport);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const isMain =
|
|
439
|
+
process.argv[1] &&
|
|
440
|
+
fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
441
|
+
|
|
442
|
+
if (isMain) {
|
|
443
|
+
main().catch((e) => {
|
|
444
|
+
console.error("Fatal:", e);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export {
|
|
450
|
+
parsePermalink,
|
|
451
|
+
requireString,
|
|
452
|
+
assertAllowedWriteChannel,
|
|
453
|
+
createExecuteToolCall,
|
|
454
|
+
createSlackClient,
|
|
455
|
+
loadConfig,
|
|
456
|
+
expandHome,
|
|
457
|
+
parseChannelList,
|
|
458
|
+
ReadPermalinkSchema,
|
|
459
|
+
GetRepliesSchema,
|
|
460
|
+
GetHistorySchema,
|
|
461
|
+
ReplyToThreadSchema,
|
|
462
|
+
AddReactionSchema,
|
|
463
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yt-slack-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Slack — read permalinks, threads, and channel history; reply to threads; add reactions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"yt-slack-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test --experimental-test-coverage"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"slack",
|
|
19
|
+
"model-context-protocol"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/yentsun/slack-mcp.git"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
28
|
+
"pdfjs-dist": "^4.0.0",
|
|
29
|
+
"zod": "^3.23.0",
|
|
30
|
+
"zod-to-json-schema": "^3.23.0"
|
|
31
|
+
}
|
|
32
|
+
}
|