yt-slack-mcp 0.1.0 → 0.1.1

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