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