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.
- package/README.md +84 -84
- package/index.js +583 -436
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,101 +1,101 @@
|
|
|
1
1
|
# yt-slack-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
| `slack_get_replies` | Read replies for a thread by channel
|
|
13
|
-
| `slack_get_history` | Read recent channel history |
|
|
14
|
-
| `slack_reply_to_thread` | Reply to a thread
|
|
15
|
-
| `slack_add_reaction` | Add a reaction
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- `
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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/
|
|
34
|
-
~/.config/
|
|
40
|
+
~/.config/yt-slack-mcp/secrets/slack-xoxc.txt
|
|
41
|
+
~/.config/yt-slack-mcp/secrets/slack-xoxd.txt
|
|
35
42
|
```
|
|
36
43
|
|
|
37
|
-
|
|
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/
|
|
47
|
-
"xoxcPath": "~/.config/
|
|
48
|
-
"xoxdPath": "~/.config/
|
|
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/
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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", "
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (filePath
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
if (filePath
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
402
|
+
const UploadFilesSchema = z.object({
|
|
276
403
|
channel_id: z.string(),
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 "
|
|
366
|
-
const a =
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
};
|