@tensakulabs/discord-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/dist/auth.js +62 -0
- package/dist/cli.js +152 -0
- package/dist/config.js +32 -0
- package/dist/daemon.js +132 -0
- package/dist/db.js +125 -0
- package/dist/index.js +144 -0
- package/dist/purge.js +34 -0
- package/dist/ratelimit.js +19 -0
- package/dist/state.js +48 -0
- package/dist/tools/get_dms.js +17 -0
- package/dist/tools/get_messages.js +47 -0
- package/dist/tools/get_unread.js +67 -0
- package/dist/tools/list_channels.js +13 -0
- package/dist/tools/list_guilds.js +10 -0
- package/dist/tools/search.js +19 -0
- package/dist/tools/send_message.js +35 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# discord-mcp
|
|
2
|
+
|
|
3
|
+
Discord selfbot MCP server — read & send Discord messages from Claude Code or OpenClaw.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **Selfbot Warning:** This uses your Discord user token, which violates Discord's ToS. Your account may be banned. Use at your own risk.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Exposes 6 Discord tools via MCP (Model Context Protocol):
|
|
10
|
+
|
|
11
|
+
| Tool | Description |
|
|
12
|
+
|------|-------------|
|
|
13
|
+
| `discord_list_guilds` | List all servers you're in |
|
|
14
|
+
| `discord_list_channels` | List text channels in a server |
|
|
15
|
+
| `discord_get_messages` | Fetch recent messages from a channel |
|
|
16
|
+
| `discord_get_dms` | List your open DM conversations |
|
|
17
|
+
| `discord_send_message` | Send a message (channel or DM) |
|
|
18
|
+
| `discord_get_unread` | Get messages you haven't seen yet |
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
### 1. Install & configure
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx discord-mcp setup
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This will:
|
|
29
|
+
1. Show you how to extract your Discord token from the desktop app
|
|
30
|
+
2. Save it securely to your OS keychain
|
|
31
|
+
3. Auto-register the MCP server in Claude's config
|
|
32
|
+
|
|
33
|
+
### 2. Token extraction (step shown during setup)
|
|
34
|
+
|
|
35
|
+
Open Discord desktop app → DevTools console → paste:
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
window.webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m.find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Copy the returned string.
|
|
42
|
+
|
|
43
|
+
### 3. Restart Claude
|
|
44
|
+
|
|
45
|
+
Restart Claude desktop app. You'll see Discord tools available.
|
|
46
|
+
|
|
47
|
+
### Verify it's working
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx discord-mcp status
|
|
51
|
+
# ✅ Connected as: yourname#0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Manual MCP config (if auto-config fails)
|
|
55
|
+
|
|
56
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"discord": {
|
|
62
|
+
"command": "npx",
|
|
63
|
+
"args": ["-y", "discord-mcp"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage with Claude
|
|
70
|
+
|
|
71
|
+
Once registered, Claude can use Discord tools directly:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
"What did I miss in the #general channel of the Tensaku server?"
|
|
75
|
+
→ Claude calls discord_list_guilds, discord_list_channels, discord_get_unread
|
|
76
|
+
|
|
77
|
+
"Reply to Alex saying I'll be there at 5pm"
|
|
78
|
+
→ Claude calls discord_send_message with replyToMessageId
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## OpenClaw integration
|
|
82
|
+
|
|
83
|
+
Add to OpenClaw's MCP config:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcp": {
|
|
88
|
+
"servers": [{
|
|
89
|
+
"name": "discord",
|
|
90
|
+
"transport": "stdio",
|
|
91
|
+
"command": "npx",
|
|
92
|
+
"args": ["-y", "discord-mcp"]
|
|
93
|
+
}]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Security
|
|
99
|
+
|
|
100
|
+
- Token stored in OS keychain (macOS Keychain, Linux Secret Service, Windows Credential Store)
|
|
101
|
+
- Fallback: AES-256-CBC encrypted file at `~/.config/discord-mcp/token.enc`
|
|
102
|
+
- Token never written to config files or logs
|
|
103
|
+
- State (last-seen timestamps, channel modes) at `~/.config/discord-mcp/state.json`
|
|
104
|
+
|
|
105
|
+
## Architecture
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
discord-mcp/
|
|
109
|
+
├── src/
|
|
110
|
+
│ ├── index.ts MCP server entry — registers 6 tools
|
|
111
|
+
│ ├── cli.ts setup + status commands
|
|
112
|
+
│ ├── auth.ts keychain token storage
|
|
113
|
+
│ ├── ratelimit.ts 429 backoff + Discord headers
|
|
114
|
+
│ ├── state.ts per-channel state (review/auto/muted + last-seen)
|
|
115
|
+
│ └── tools/
|
|
116
|
+
│ ├── list_guilds.ts
|
|
117
|
+
│ ├── list_channels.ts
|
|
118
|
+
│ ├── get_messages.ts
|
|
119
|
+
│ ├── get_dms.ts
|
|
120
|
+
│ ├── send_message.ts
|
|
121
|
+
│ └── get_unread.ts
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
All Discord API calls go through `rateLimitedFetch` — automatic backoff on 429.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
5
|
+
const SERVICE = "discord-mcp";
|
|
6
|
+
const ACCOUNT = "user-token";
|
|
7
|
+
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
8
|
+
const TOKEN_FILE = join(CONFIG_DIR, "token.enc");
|
|
9
|
+
const KEY_FILE = join(CONFIG_DIR, "key.bin");
|
|
10
|
+
let _keytar = null;
|
|
11
|
+
async function getKeytar() {
|
|
12
|
+
if (_keytar !== undefined)
|
|
13
|
+
return _keytar;
|
|
14
|
+
try {
|
|
15
|
+
_keytar = (await import("keytar")).default;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
_keytar = null;
|
|
19
|
+
}
|
|
20
|
+
return _keytar;
|
|
21
|
+
}
|
|
22
|
+
export async function saveToken(token) {
|
|
23
|
+
const keytar = await getKeytar();
|
|
24
|
+
try {
|
|
25
|
+
if (!keytar)
|
|
26
|
+
throw new Error("keytar not available");
|
|
27
|
+
await keytar.setPassword(SERVICE, ACCOUNT, token);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Fallback: encrypt to file (for headless/CI environments)
|
|
31
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
32
|
+
let key;
|
|
33
|
+
if (existsSync(KEY_FILE)) {
|
|
34
|
+
key = readFileSync(KEY_FILE);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
key = randomBytes(32);
|
|
38
|
+
writeFileSync(KEY_FILE, key, { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
const iv = randomBytes(16);
|
|
41
|
+
const cipher = createCipheriv("aes-256-cbc", key, iv);
|
|
42
|
+
const enc = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
43
|
+
writeFileSync(TOKEN_FILE, Buffer.concat([iv, enc]), { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function getToken() {
|
|
47
|
+
// Try keychain first
|
|
48
|
+
const keytar = await getKeytar();
|
|
49
|
+
const keychainToken = keytar ? await keytar.getPassword(SERVICE, ACCOUNT).catch(() => null) : null;
|
|
50
|
+
if (keychainToken)
|
|
51
|
+
return keychainToken;
|
|
52
|
+
// Fallback: encrypted file
|
|
53
|
+
if (!existsSync(TOKEN_FILE) || !existsSync(KEY_FILE)) {
|
|
54
|
+
throw new Error("No Discord token found. Run: npx discord-mcp setup");
|
|
55
|
+
}
|
|
56
|
+
const key = readFileSync(KEY_FILE);
|
|
57
|
+
const raw = readFileSync(TOKEN_FILE);
|
|
58
|
+
const iv = raw.slice(0, 16);
|
|
59
|
+
const enc = raw.slice(16);
|
|
60
|
+
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
|
61
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
|
|
62
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createInterface } from "readline";
|
|
4
|
+
import { saveToken, getToken } from "./auth.js";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { homedir, platform } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program
|
|
11
|
+
.name("discord-mcp")
|
|
12
|
+
.description("Discord selfbot MCP server for Claude")
|
|
13
|
+
.version("0.1.0");
|
|
14
|
+
program
|
|
15
|
+
.command("setup")
|
|
16
|
+
.description("Configure Discord token and register MCP server")
|
|
17
|
+
.action(async () => {
|
|
18
|
+
console.log(`
|
|
19
|
+
╔══════════════════════════════════════════════════╗
|
|
20
|
+
║ discord-mcp setup ║
|
|
21
|
+
╚══════════════════════════════════════════════════╝
|
|
22
|
+
|
|
23
|
+
Step 1: Extract your Discord token
|
|
24
|
+
|
|
25
|
+
1. Open Discord desktop app
|
|
26
|
+
2. Press Ctrl+Shift+I (or Cmd+Option+I on Mac) to open DevTools
|
|
27
|
+
3. Click the "Console" tab
|
|
28
|
+
4. Paste this one-liner and press Enter:
|
|
29
|
+
|
|
30
|
+
window.webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m.find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
|
31
|
+
|
|
32
|
+
5. Copy the returned string (no quotes needed)
|
|
33
|
+
|
|
34
|
+
`);
|
|
35
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
36
|
+
const token = await new Promise(resolve => {
|
|
37
|
+
rl.question("Paste your Discord token: ", ans => { rl.close(); resolve(ans.trim()); });
|
|
38
|
+
});
|
|
39
|
+
if (!token || token.length < 20) {
|
|
40
|
+
console.error("❌ Invalid token. Try again.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
await saveToken(token);
|
|
44
|
+
console.log("✅ Token saved securely to OS keychain (or encrypted file fallback).");
|
|
45
|
+
// Auto-patch claude_desktop_config.json
|
|
46
|
+
const configPaths = [
|
|
47
|
+
join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"), // macOS
|
|
48
|
+
join(homedir(), ".config", "Claude", "claude_desktop_config.json"), // Linux
|
|
49
|
+
join(process.env["APPDATA"] ?? "", "Claude", "claude_desktop_config.json"), // Windows
|
|
50
|
+
];
|
|
51
|
+
const configPath = configPaths.find(existsSync);
|
|
52
|
+
// Write launchd plist (macOS only) — ISC-D6
|
|
53
|
+
if (platform() === "darwin") {
|
|
54
|
+
const launchAgentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
55
|
+
const plistPath = join(launchAgentsDir, "com.discord-mcp.daemon.plist");
|
|
56
|
+
const npxPath = (() => { try {
|
|
57
|
+
return execSync("which npx").toString().trim();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "/usr/local/bin/npx";
|
|
61
|
+
} })();
|
|
62
|
+
const logDir = join(homedir(), ".config", "discord-mcp");
|
|
63
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
64
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
65
|
+
<plist version="1.0">
|
|
66
|
+
<dict>
|
|
67
|
+
<key>Label</key>
|
|
68
|
+
<string>com.discord-mcp.daemon</string>
|
|
69
|
+
<key>ProgramArguments</key>
|
|
70
|
+
<array>
|
|
71
|
+
<string>${npxPath}</string>
|
|
72
|
+
<string>@tensakulabs/discord-mcp</string>
|
|
73
|
+
<string>daemon-start</string>
|
|
74
|
+
</array>
|
|
75
|
+
<key>RunAtLoad</key>
|
|
76
|
+
<true/>
|
|
77
|
+
<key>KeepAlive</key>
|
|
78
|
+
<true/>
|
|
79
|
+
<key>StandardOutPath</key>
|
|
80
|
+
<string>${logDir}/daemon.log</string>
|
|
81
|
+
<key>StandardErrorPath</key>
|
|
82
|
+
<string>${logDir}/daemon.log</string>
|
|
83
|
+
</dict>
|
|
84
|
+
</plist>`;
|
|
85
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
86
|
+
writeFileSync(plistPath, plist);
|
|
87
|
+
try {
|
|
88
|
+
execSync(`launchctl unload "${plistPath}" 2>/dev/null; launchctl load "${plistPath}"`);
|
|
89
|
+
console.log(`✅ Daemon registered: com.discord-mcp.daemon (starts at login, running now)`);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
console.log(`✅ Plist written: ${plistPath} (run: launchctl load "${plistPath}")`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (configPath) {
|
|
96
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
97
|
+
config.mcpServers ??= {};
|
|
98
|
+
config.mcpServers["discord"] = {
|
|
99
|
+
command: "npx",
|
|
100
|
+
args: ["-y", "@tensakulabs/discord-mcp"],
|
|
101
|
+
};
|
|
102
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
103
|
+
console.log(`✅ Registered in Claude config: ${configPath}`);
|
|
104
|
+
console.log("\n🎉 Done! Restart Claude to start using Discord tools.");
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(`
|
|
108
|
+
⚠️ Could not find Claude desktop config automatically.
|
|
109
|
+
|
|
110
|
+
Add this to your claude_desktop_config.json manually:
|
|
111
|
+
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"discord": {
|
|
114
|
+
"command": "npx",
|
|
115
|
+
"args": ["-y", "@tensakulabs/discord-mcp"]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
program
|
|
122
|
+
.command("daemon-start")
|
|
123
|
+
.description("Start the daemon (called by launchd — not intended for direct use)")
|
|
124
|
+
.action(async () => {
|
|
125
|
+
// Dynamically import daemon to start it
|
|
126
|
+
await import("./daemon.js");
|
|
127
|
+
});
|
|
128
|
+
program
|
|
129
|
+
.command("status")
|
|
130
|
+
.description("Check if token is set and valid")
|
|
131
|
+
.action(async () => {
|
|
132
|
+
try {
|
|
133
|
+
const token = await getToken();
|
|
134
|
+
// Validate token with a lightweight API call
|
|
135
|
+
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
|
136
|
+
headers: { Authorization: token },
|
|
137
|
+
});
|
|
138
|
+
if (res.ok) {
|
|
139
|
+
const user = await res.json();
|
|
140
|
+
console.log(`✅ Connected as: ${user.username}#${user.discriminator}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.error(`❌ Token invalid: HTTP ${res.status}`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
console.error("❌ No token found. Run: npx @tensakulabs/discord-mcp setup");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
program.parse();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
retention: {
|
|
8
|
+
guild_channels: 30,
|
|
9
|
+
dms: 90,
|
|
10
|
+
mentioned: 90,
|
|
11
|
+
fallback_to_api: true,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export function loadConfig() {
|
|
15
|
+
if (!existsSync(CONFIG_FILE))
|
|
16
|
+
return DEFAULTS;
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
19
|
+
return {
|
|
20
|
+
retention: { ...DEFAULTS.retention, ...(raw.retention ?? {}) },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return DEFAULTS;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function writeDefaultConfig() {
|
|
28
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
29
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
30
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2));
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* discord-mcp daemon
|
|
4
|
+
* Connects to Discord Gateway via WebSocket, ingests messages into SQLite.
|
|
5
|
+
* Run as a launchd service (com.discord-mcp.daemon).
|
|
6
|
+
*/
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
import { getToken } from "./auth.js";
|
|
9
|
+
import { initDb, insertMessage } from "./db.js";
|
|
10
|
+
import { schedulePurge } from "./purge.js";
|
|
11
|
+
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
12
|
+
const IDENTIFY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12); // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
|
|
13
|
+
let ws = null;
|
|
14
|
+
let heartbeatInterval = null;
|
|
15
|
+
let sequence = null;
|
|
16
|
+
let sessionId = null;
|
|
17
|
+
let resumeGatewayUrl = null;
|
|
18
|
+
let reconnectDelay = 1000;
|
|
19
|
+
const db = initDb();
|
|
20
|
+
schedulePurge();
|
|
21
|
+
async function connect(resume = false) {
|
|
22
|
+
const token = await getToken();
|
|
23
|
+
const url = resume && resumeGatewayUrl ? resumeGatewayUrl : GATEWAY_URL;
|
|
24
|
+
ws = new WebSocket(url);
|
|
25
|
+
ws.on("open", () => {
|
|
26
|
+
reconnectDelay = 1000; // reset on successful connect
|
|
27
|
+
console.error("[discord-mcp daemon] Connected to gateway.");
|
|
28
|
+
});
|
|
29
|
+
ws.on("message", (data) => {
|
|
30
|
+
const payload = JSON.parse(data.toString());
|
|
31
|
+
if (payload.s)
|
|
32
|
+
sequence = payload.s;
|
|
33
|
+
switch (payload.op) {
|
|
34
|
+
case 10: // HELLO
|
|
35
|
+
startHeartbeat(payload.d.heartbeat_interval);
|
|
36
|
+
if (resume && sessionId) {
|
|
37
|
+
send({ op: 6, d: { token, session_id: sessionId, seq: sequence } }); // RESUME
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
identify(token);
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case 11: // HEARTBEAT_ACK
|
|
44
|
+
break;
|
|
45
|
+
case 1: // HEARTBEAT request
|
|
46
|
+
sendHeartbeat();
|
|
47
|
+
break;
|
|
48
|
+
case 7: // RECONNECT
|
|
49
|
+
reconnect(true);
|
|
50
|
+
break;
|
|
51
|
+
case 9: // INVALID SESSION
|
|
52
|
+
sessionId = null;
|
|
53
|
+
setTimeout(() => reconnect(false), 1000 + Math.random() * 4000);
|
|
54
|
+
break;
|
|
55
|
+
case 0: // DISPATCH
|
|
56
|
+
handleEvent(payload.t, payload.d);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
ws.on("close", (code) => {
|
|
61
|
+
clearHeartbeat();
|
|
62
|
+
console.error(`[discord-mcp daemon] Disconnected (${code}). Reconnecting in ${reconnectDelay}ms...`);
|
|
63
|
+
setTimeout(() => reconnect(code !== 1000), reconnectDelay);
|
|
64
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
65
|
+
});
|
|
66
|
+
ws.on("error", (err) => {
|
|
67
|
+
console.error("[discord-mcp daemon] WebSocket error:", err.message);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function identify(token) {
|
|
71
|
+
send({
|
|
72
|
+
op: 2,
|
|
73
|
+
d: {
|
|
74
|
+
token,
|
|
75
|
+
intents: IDENTIFY_INTENTS,
|
|
76
|
+
properties: { os: "linux", browser: "discord-mcp", device: "discord-mcp" },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function handleEvent(type, data) {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case "READY":
|
|
83
|
+
sessionId = data.session_id;
|
|
84
|
+
resumeGatewayUrl = data.resume_gateway_url;
|
|
85
|
+
console.error(`[discord-mcp daemon] Ready.`);
|
|
86
|
+
break;
|
|
87
|
+
case "MESSAGE_CREATE": {
|
|
88
|
+
const msg = data;
|
|
89
|
+
if (msg.author?.bot)
|
|
90
|
+
break; // ignore bots
|
|
91
|
+
const isMention = Array.isArray(msg.mentions) &&
|
|
92
|
+
msg.mentions.some((u) => u.id === (data.self_id ?? ""));
|
|
93
|
+
insertMessage(db, {
|
|
94
|
+
id: msg.id,
|
|
95
|
+
channel_id: msg.channel_id,
|
|
96
|
+
guild_id: msg.guild_id ?? null,
|
|
97
|
+
author_id: msg.author.id,
|
|
98
|
+
author_name: msg.author.username,
|
|
99
|
+
content: msg.content,
|
|
100
|
+
timestamp: new Date(msg.timestamp).getTime(),
|
|
101
|
+
is_dm: msg.guild_id ? 0 : 1,
|
|
102
|
+
is_mention: isMention ? 1 : 0,
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function startHeartbeat(intervalMs) {
|
|
109
|
+
clearHeartbeat();
|
|
110
|
+
heartbeatInterval = setInterval(sendHeartbeat, intervalMs);
|
|
111
|
+
}
|
|
112
|
+
function clearHeartbeat() {
|
|
113
|
+
if (heartbeatInterval) {
|
|
114
|
+
clearInterval(heartbeatInterval);
|
|
115
|
+
heartbeatInterval = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function sendHeartbeat() {
|
|
119
|
+
send({ op: 1, d: sequence });
|
|
120
|
+
}
|
|
121
|
+
function send(payload) {
|
|
122
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
123
|
+
ws.send(JSON.stringify(payload));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function reconnect(resume) {
|
|
127
|
+
ws?.terminate();
|
|
128
|
+
ws = null;
|
|
129
|
+
connect(resume);
|
|
130
|
+
}
|
|
131
|
+
// Start
|
|
132
|
+
connect(false);
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync, existsSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
6
|
+
const DB_FILE = join(CONFIG_DIR, "messages.db");
|
|
7
|
+
let _db = null;
|
|
8
|
+
export function getDb() {
|
|
9
|
+
if (_db)
|
|
10
|
+
return _db;
|
|
11
|
+
if (!existsSync(DB_FILE))
|
|
12
|
+
return null; // ISC-A5: graceful miss
|
|
13
|
+
try {
|
|
14
|
+
_db = new Database(DB_FILE);
|
|
15
|
+
// ISC-A4: WAL mode prevents corruption on crash
|
|
16
|
+
_db.pragma("journal_mode = WAL");
|
|
17
|
+
_db.pragma("synchronous = NORMAL");
|
|
18
|
+
return _db;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function initDb() {
|
|
25
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
const db = new Database(DB_FILE);
|
|
27
|
+
db.pragma("journal_mode = WAL");
|
|
28
|
+
db.pragma("synchronous = NORMAL");
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
channel_id TEXT NOT NULL,
|
|
33
|
+
guild_id TEXT,
|
|
34
|
+
author_id TEXT NOT NULL,
|
|
35
|
+
author_name TEXT NOT NULL,
|
|
36
|
+
content TEXT NOT NULL,
|
|
37
|
+
timestamp INTEGER NOT NULL, -- unix ms
|
|
38
|
+
is_dm INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
is_mention INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
seen INTEGER NOT NULL DEFAULT 0
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_channel_ts ON messages(channel_id, timestamp DESC);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_seen ON messages(seen, timestamp DESC);
|
|
45
|
+
|
|
46
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
47
|
+
content,
|
|
48
|
+
author_name,
|
|
49
|
+
content='messages',
|
|
50
|
+
content_rowid='rowid'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
54
|
+
INSERT INTO messages_fts(rowid, content, author_name)
|
|
55
|
+
VALUES (new.rowid, new.content, new.author_name);
|
|
56
|
+
END;
|
|
57
|
+
`);
|
|
58
|
+
_db = db;
|
|
59
|
+
return db;
|
|
60
|
+
}
|
|
61
|
+
export function insertMessage(db, msg) {
|
|
62
|
+
const stmt = db.prepare(`
|
|
63
|
+
INSERT OR IGNORE INTO messages
|
|
64
|
+
(id, channel_id, guild_id, author_id, author_name, content, timestamp, is_dm, is_mention, seen)
|
|
65
|
+
VALUES
|
|
66
|
+
(@id, @channel_id, @guild_id, @author_id, @author_name, @content, @timestamp, @is_dm, @is_mention, 0)
|
|
67
|
+
`);
|
|
68
|
+
stmt.run(msg);
|
|
69
|
+
}
|
|
70
|
+
export function queryMessages(db, channelId, limit, since, until) {
|
|
71
|
+
let sql = "SELECT * FROM messages WHERE channel_id = ?";
|
|
72
|
+
const params = [channelId];
|
|
73
|
+
if (since) {
|
|
74
|
+
sql += " AND timestamp >= ?";
|
|
75
|
+
params.push(since);
|
|
76
|
+
}
|
|
77
|
+
if (until) {
|
|
78
|
+
sql += " AND timestamp <= ?";
|
|
79
|
+
params.push(until);
|
|
80
|
+
}
|
|
81
|
+
sql += " ORDER BY timestamp DESC LIMIT ?";
|
|
82
|
+
params.push(limit);
|
|
83
|
+
return db.prepare(sql).all(...params);
|
|
84
|
+
}
|
|
85
|
+
export function queryUnread(db) {
|
|
86
|
+
return db.prepare("SELECT * FROM messages WHERE seen = 0 ORDER BY timestamp ASC").all();
|
|
87
|
+
}
|
|
88
|
+
export function markAllSeen(db, ids) {
|
|
89
|
+
if (ids.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
92
|
+
db.prepare(`UPDATE messages SET seen = 1 WHERE id IN (${placeholders})`).run(...ids);
|
|
93
|
+
}
|
|
94
|
+
export function searchMessages(db, query, channelId, since, until, limit = 50) {
|
|
95
|
+
// FTS5 search
|
|
96
|
+
let sql = `
|
|
97
|
+
SELECT m.* FROM messages m
|
|
98
|
+
JOIN messages_fts f ON m.rowid = f.rowid
|
|
99
|
+
WHERE messages_fts MATCH ?
|
|
100
|
+
`;
|
|
101
|
+
const params = [query];
|
|
102
|
+
if (channelId) {
|
|
103
|
+
sql += " AND m.channel_id = ?";
|
|
104
|
+
params.push(channelId);
|
|
105
|
+
}
|
|
106
|
+
if (since) {
|
|
107
|
+
sql += " AND m.timestamp >= ?";
|
|
108
|
+
params.push(since);
|
|
109
|
+
}
|
|
110
|
+
if (until) {
|
|
111
|
+
sql += " AND m.timestamp <= ?";
|
|
112
|
+
params.push(until);
|
|
113
|
+
}
|
|
114
|
+
sql += " ORDER BY m.timestamp DESC LIMIT ?";
|
|
115
|
+
params.push(limit);
|
|
116
|
+
return db.prepare(sql).all(...params);
|
|
117
|
+
}
|
|
118
|
+
export function deleteOlderThan(db, cutoffMs, isDm, isMention) {
|
|
119
|
+
db.prepare(`
|
|
120
|
+
DELETE FROM messages
|
|
121
|
+
WHERE timestamp < ?
|
|
122
|
+
AND is_dm = ?
|
|
123
|
+
AND is_mention = ?
|
|
124
|
+
`).run(cutoffMs, isDm ? 1 : 0, isMention ? 1 : 0);
|
|
125
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { listGuilds } from "./tools/list_guilds.js";
|
|
5
|
+
import { listChannels } from "./tools/list_channels.js";
|
|
6
|
+
import { getMessages } from "./tools/get_messages.js";
|
|
7
|
+
import { getDMChannels } from "./tools/get_dms.js";
|
|
8
|
+
import { sendMessage } from "./tools/send_message.js";
|
|
9
|
+
import { getUnread } from "./tools/get_unread.js";
|
|
10
|
+
import { searchDiscord } from "./tools/search.js";
|
|
11
|
+
const server = new Server({ name: "discord-mcp", version: "0.2.0" }, { capabilities: { tools: {} } });
|
|
12
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
13
|
+
tools: [
|
|
14
|
+
{
|
|
15
|
+
name: "discord_list_guilds",
|
|
16
|
+
description: "List all Discord servers (guilds) the user is a member of.",
|
|
17
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "discord_list_channels",
|
|
21
|
+
description: "List text channels in a guild.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
guildId: { type: "string", description: "Guild ID from discord_list_guilds" },
|
|
26
|
+
},
|
|
27
|
+
required: ["guildId"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "discord_get_messages",
|
|
32
|
+
description: "Get messages from a channel. Uses local cache if daemon is running, Discord API otherwise.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
channelId: { type: "string" },
|
|
37
|
+
limit: { type: "number", default: 25, description: "Max messages (1-100)" },
|
|
38
|
+
since: { type: "string", description: "ISO date or datetime e.g. '2026-02-25' or 'yesterday'" },
|
|
39
|
+
until: { type: "string", description: "ISO date or datetime upper bound" },
|
|
40
|
+
},
|
|
41
|
+
required: ["channelId"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "discord_get_dms",
|
|
46
|
+
description: "List the user's open DM conversations.",
|
|
47
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "discord_send_message",
|
|
51
|
+
description: "Send a message to a channel or user (DM). Specify channelId OR userId.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
channelId: { type: "string", description: "Guild channel ID" },
|
|
56
|
+
userId: { type: "string", description: "User ID to DM" },
|
|
57
|
+
content: { type: "string", description: "Message text" },
|
|
58
|
+
replyToMessageId: { type: "string", description: "Optional message ID to reply to" },
|
|
59
|
+
},
|
|
60
|
+
required: ["content"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "discord_get_unread",
|
|
65
|
+
description: "Get messages you haven't seen yet. Uses local cache when daemon is running.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
channels: {
|
|
70
|
+
type: "array",
|
|
71
|
+
description: "Channels to check.",
|
|
72
|
+
items: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
guildName: { type: "string" },
|
|
76
|
+
channelName: { type: "string" },
|
|
77
|
+
channelId: { type: "string" },
|
|
78
|
+
},
|
|
79
|
+
required: ["guildName", "channelName", "channelId"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
required: ["channels"],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "discord_search",
|
|
88
|
+
description: "Full-text search across locally cached Discord messages (requires daemon).",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
query: { type: "string", description: "Search terms" },
|
|
93
|
+
channelId: { type: "string", description: "Limit to a specific channel (optional)" },
|
|
94
|
+
since: { type: "string", description: "ISO date lower bound (optional)" },
|
|
95
|
+
until: { type: "string", description: "ISO date upper bound (optional)" },
|
|
96
|
+
limit: { type: "number", default: 50, description: "Max results" },
|
|
97
|
+
},
|
|
98
|
+
required: ["query"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}));
|
|
103
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
104
|
+
const { name, arguments: args } = request.params;
|
|
105
|
+
try {
|
|
106
|
+
let result;
|
|
107
|
+
switch (name) {
|
|
108
|
+
case "discord_list_guilds":
|
|
109
|
+
result = await listGuilds();
|
|
110
|
+
break;
|
|
111
|
+
case "discord_list_channels":
|
|
112
|
+
result = await listChannels(args.guildId);
|
|
113
|
+
break;
|
|
114
|
+
case "discord_get_messages": {
|
|
115
|
+
const a = args;
|
|
116
|
+
result = await getMessages(a.channelId, a.limit, a.since, a.until);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "discord_get_dms":
|
|
120
|
+
result = await getDMChannels();
|
|
121
|
+
break;
|
|
122
|
+
case "discord_send_message":
|
|
123
|
+
result = await sendMessage(args);
|
|
124
|
+
break;
|
|
125
|
+
case "discord_get_unread":
|
|
126
|
+
result = await getUnread(args.channels);
|
|
127
|
+
break;
|
|
128
|
+
case "discord_search": {
|
|
129
|
+
const a = args;
|
|
130
|
+
result = await searchDiscord(a.query, a.channelId, a.since, a.until, a.limit);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
135
|
+
}
|
|
136
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const transport = new StdioServerTransport();
|
|
144
|
+
await server.connect(transport);
|
package/dist/purge.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getDb, deleteOlderThan } from "./db.js";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
export function runPurge() {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
if (!db)
|
|
6
|
+
return;
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const guildCutoff = now - config.retention.guild_channels * 24 * 60 * 60 * 1000;
|
|
10
|
+
const dmCutoff = now - config.retention.dms * 24 * 60 * 60 * 1000;
|
|
11
|
+
const mentionCutoff = now - config.retention.mentioned * 24 * 60 * 60 * 1000;
|
|
12
|
+
// Purge guild messages (not DM, not mention)
|
|
13
|
+
deleteOlderThan(db, guildCutoff, false, false);
|
|
14
|
+
// Purge DMs
|
|
15
|
+
deleteOlderThan(db, dmCutoff, true, false);
|
|
16
|
+
// Purge mentions
|
|
17
|
+
deleteOlderThan(db, mentionCutoff, false, true);
|
|
18
|
+
// DM mentions use the more conservative of dm/mention retention (dm wins)
|
|
19
|
+
deleteOlderThan(db, dmCutoff, true, true);
|
|
20
|
+
console.error(`[discord-mcp] Purge complete.`);
|
|
21
|
+
}
|
|
22
|
+
export function schedulePurge() {
|
|
23
|
+
// Run at next 2am, then every 24h
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const next2am = new Date(now);
|
|
26
|
+
next2am.setHours(2, 0, 0, 0);
|
|
27
|
+
if (next2am <= now)
|
|
28
|
+
next2am.setDate(next2am.getDate() + 1);
|
|
29
|
+
const msUntil2am = next2am.getTime() - now.getTime();
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
runPurge();
|
|
32
|
+
setInterval(runPurge, 24 * 60 * 60 * 1000);
|
|
33
|
+
}, msUntil2am);
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function rateLimitedFetch(input, init, retries = 4) {
|
|
2
|
+
const res = await fetch(input, init);
|
|
3
|
+
if (res.status === 429 && retries > 0) {
|
|
4
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? 1);
|
|
5
|
+
const delay = Math.min(retryAfter * 1000, 8000);
|
|
6
|
+
console.error(`[discord-mcp] Rate limited. Retrying in ${delay}ms...`);
|
|
7
|
+
await new Promise(r => setTimeout(r, delay));
|
|
8
|
+
return rateLimitedFetch(input, init, retries - 1);
|
|
9
|
+
}
|
|
10
|
+
return res;
|
|
11
|
+
}
|
|
12
|
+
export function makeDiscordHeaders(token) {
|
|
13
|
+
return {
|
|
14
|
+
// ISC-C2: user token — NO "Bot " prefix
|
|
15
|
+
"Authorization": token,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"User-Agent": "Mozilla/5.0 (compatible; discord-mcp/0.1)",
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
5
|
+
const STATE_FILE = join(CONFIG_DIR, "state.json");
|
|
6
|
+
function load() {
|
|
7
|
+
if (!existsSync(STATE_FILE))
|
|
8
|
+
return {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function save(state) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
export function getChannelMode(channelId) {
|
|
21
|
+
const state = load();
|
|
22
|
+
const s = state[channelId] ?? { mode: "review" };
|
|
23
|
+
// Auto-expire
|
|
24
|
+
if (s.expiresAt && Date.now() > s.expiresAt) {
|
|
25
|
+
state[channelId] = { ...s, mode: "review", expiresAt: undefined };
|
|
26
|
+
save(state);
|
|
27
|
+
return "review";
|
|
28
|
+
}
|
|
29
|
+
return s.mode;
|
|
30
|
+
}
|
|
31
|
+
export function setChannelMode(channelId, mode, durationMs) {
|
|
32
|
+
const state = load();
|
|
33
|
+
state[channelId] = {
|
|
34
|
+
...(state[channelId] ?? {}),
|
|
35
|
+
mode,
|
|
36
|
+
expiresAt: durationMs ? Date.now() + durationMs : undefined,
|
|
37
|
+
};
|
|
38
|
+
save(state);
|
|
39
|
+
}
|
|
40
|
+
export function markSeen(channelId, timestamp) {
|
|
41
|
+
const state = load();
|
|
42
|
+
state[channelId] = { ...(state[channelId] ?? { mode: "review" }), lastSeen: timestamp };
|
|
43
|
+
save(state);
|
|
44
|
+
}
|
|
45
|
+
export function getLastSeen(channelId) {
|
|
46
|
+
const state = load();
|
|
47
|
+
return state[channelId]?.lastSeen ?? 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getToken } from "../auth.js";
|
|
2
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
+
export async function getDMChannels() {
|
|
4
|
+
const token = await getToken();
|
|
5
|
+
const res = await rateLimitedFetch("https://discord.com/api/v10/users/@me/channels", { headers: makeDiscordHeaders(token) });
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
throw new Error(`Discord API error: ${res.status}`);
|
|
8
|
+
const channels = await res.json();
|
|
9
|
+
// type 1 = DM, type 3 = GROUP_DM
|
|
10
|
+
return channels
|
|
11
|
+
.filter(c => c.type === 1 || c.type === 3)
|
|
12
|
+
.map(c => ({
|
|
13
|
+
channelId: c.id,
|
|
14
|
+
with: c.recipients.map(r => r.username).join(", "),
|
|
15
|
+
lastMessageId: c.last_message_id,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getDb, queryMessages } from "../db.js";
|
|
2
|
+
import { getToken } from "../auth.js";
|
|
3
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
4
|
+
import { markSeen } from "../state.js";
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
export async function getMessages(channelId, limit = 25, since, // ISO date string e.g. "2026-02-25" or "2026-02-25T00:00:00Z"
|
|
7
|
+
until) {
|
|
8
|
+
const sinceMs = since ? new Date(since).getTime() : undefined;
|
|
9
|
+
const untilMs = until ? new Date(until).getTime() : undefined;
|
|
10
|
+
// ISC-D4: SQLite-first
|
|
11
|
+
const db = getDb();
|
|
12
|
+
if (db) {
|
|
13
|
+
const rows = queryMessages(db, channelId, limit, sinceMs, untilMs);
|
|
14
|
+
if (rows.length > 0) {
|
|
15
|
+
if (rows.length > 0)
|
|
16
|
+
markSeen(channelId, rows[0].timestamp);
|
|
17
|
+
return rows.map(r => ({
|
|
18
|
+
id: r.id,
|
|
19
|
+
author: r.author_name,
|
|
20
|
+
content: r.content,
|
|
21
|
+
timestamp: new Date(r.timestamp).toISOString(),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// ISC-D4: REST fallback on miss
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
if (!config.retention.fallback_to_api) {
|
|
28
|
+
return []; // privacy mode — no REST fallback
|
|
29
|
+
}
|
|
30
|
+
const token = await getToken();
|
|
31
|
+
let url = `https://discord.com/api/v10/channels/${channelId}/messages?limit=${Math.min(limit, 100)}`;
|
|
32
|
+
const res = await rateLimitedFetch(url, { headers: makeDiscordHeaders(token) });
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
throw new Error(`Discord API error: ${res.status}`);
|
|
35
|
+
const messages = await res.json();
|
|
36
|
+
if (messages.length > 0) {
|
|
37
|
+
const latestTs = new Date(messages[0].timestamp).getTime();
|
|
38
|
+
markSeen(channelId, latestTs);
|
|
39
|
+
}
|
|
40
|
+
return messages.map(m => ({
|
|
41
|
+
id: m.id,
|
|
42
|
+
author: m.author.username,
|
|
43
|
+
content: m.content,
|
|
44
|
+
timestamp: m.timestamp,
|
|
45
|
+
replyTo: m.referenced_message?.content,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getDb, queryUnread, markAllSeen } from "../db.js";
|
|
2
|
+
import { getToken } from "../auth.js";
|
|
3
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
4
|
+
import { getLastSeen, markSeen } from "../state.js";
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
export async function getUnread(channels) {
|
|
7
|
+
// ISC-D4: SQLite-first path
|
|
8
|
+
const db = getDb();
|
|
9
|
+
if (db) {
|
|
10
|
+
const unreadRows = queryUnread(db);
|
|
11
|
+
if (unreadRows.length > 0) {
|
|
12
|
+
// Group by channel
|
|
13
|
+
const grouped = {};
|
|
14
|
+
for (const row of unreadRows) {
|
|
15
|
+
if (!grouped[row.channel_id])
|
|
16
|
+
grouped[row.channel_id] = [];
|
|
17
|
+
grouped[row.channel_id].push(row);
|
|
18
|
+
}
|
|
19
|
+
markAllSeen(db, unreadRows.map(r => r.id));
|
|
20
|
+
return Object.entries(grouped).map(([channelId, msgs]) => {
|
|
21
|
+
const ch = channels.find(c => c.channelId === channelId);
|
|
22
|
+
return {
|
|
23
|
+
guild: ch?.guildName ?? "Unknown",
|
|
24
|
+
channel: ch?.channelName ?? channelId,
|
|
25
|
+
channelId,
|
|
26
|
+
messages: msgs.map(m => ({
|
|
27
|
+
id: m.id,
|
|
28
|
+
author: m.author_name,
|
|
29
|
+
content: m.content,
|
|
30
|
+
timestamp: new Date(m.timestamp).toISOString(),
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// REST fallback (daemon not running or no unread in SQLite)
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
if (!config.retention.fallback_to_api)
|
|
39
|
+
return [];
|
|
40
|
+
const token = await getToken();
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const ch of channels) {
|
|
43
|
+
const lastSeen = getLastSeen(ch.channelId);
|
|
44
|
+
const res = await rateLimitedFetch(`https://discord.com/api/v10/channels/${ch.channelId}/messages?limit=25`, { headers: makeDiscordHeaders(token) });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
continue;
|
|
47
|
+
const messages = await res.json();
|
|
48
|
+
const unread = messages
|
|
49
|
+
.filter(m => !m.author.bot && new Date(m.timestamp).getTime() > lastSeen)
|
|
50
|
+
.reverse();
|
|
51
|
+
if (unread.length > 0) {
|
|
52
|
+
markSeen(ch.channelId, new Date(messages[0].timestamp).getTime());
|
|
53
|
+
results.push({
|
|
54
|
+
guild: ch.guildName,
|
|
55
|
+
channel: ch.channelName,
|
|
56
|
+
channelId: ch.channelId,
|
|
57
|
+
messages: unread.map(m => ({
|
|
58
|
+
id: m.id,
|
|
59
|
+
author: m.author.username,
|
|
60
|
+
content: m.content,
|
|
61
|
+
timestamp: m.timestamp,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getToken } from "../auth.js";
|
|
2
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
+
export async function listChannels(guildId) {
|
|
4
|
+
const token = await getToken();
|
|
5
|
+
const res = await rateLimitedFetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, { headers: makeDiscordHeaders(token) });
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
throw new Error(`Discord API error: ${res.status}`);
|
|
8
|
+
const channels = await res.json();
|
|
9
|
+
// type 0 = GUILD_TEXT, type 5 = GUILD_ANNOUNCEMENT
|
|
10
|
+
return channels
|
|
11
|
+
.filter(c => c.type === 0 || c.type === 5)
|
|
12
|
+
.map(c => ({ id: c.id, name: c.name, topic: c.topic }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getToken } from "../auth.js";
|
|
2
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
+
export async function listGuilds() {
|
|
4
|
+
const token = await getToken();
|
|
5
|
+
const res = await rateLimitedFetch("https://discord.com/api/v10/users/@me/guilds", { headers: makeDiscordHeaders(token) });
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
throw new Error(`Discord API error: ${res.status}`);
|
|
8
|
+
const guilds = await res.json();
|
|
9
|
+
return guilds.map(g => ({ id: g.id, name: g.name }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getDb, searchMessages } from "../db.js";
|
|
2
|
+
export async function searchDiscord(query, channelId, since, until, limit = 50) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
if (!db) {
|
|
5
|
+
throw new Error("Daemon not running — no local message history to search. Start discord-mcp daemon first.");
|
|
6
|
+
}
|
|
7
|
+
const sinceMs = since ? new Date(since).getTime() : undefined;
|
|
8
|
+
const untilMs = until ? new Date(until).getTime() : undefined;
|
|
9
|
+
const rows = searchMessages(db, query, channelId, sinceMs, untilMs, limit);
|
|
10
|
+
return rows.map(r => ({
|
|
11
|
+
id: r.id,
|
|
12
|
+
author: r.author_name,
|
|
13
|
+
content: r.content,
|
|
14
|
+
channel_id: r.channel_id,
|
|
15
|
+
timestamp: new Date(r.timestamp).toISOString(),
|
|
16
|
+
is_dm: r.is_dm === 1,
|
|
17
|
+
is_mention: r.is_mention === 1,
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getToken } from "../auth.js";
|
|
2
|
+
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
+
export async function sendMessage(opts) {
|
|
4
|
+
const token = await getToken();
|
|
5
|
+
let targetChannelId = opts.channelId;
|
|
6
|
+
// If userId given, open DM channel first
|
|
7
|
+
if (!targetChannelId && opts.userId) {
|
|
8
|
+
const dmRes = await rateLimitedFetch("https://discord.com/api/v10/users/@me/channels", {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: makeDiscordHeaders(token),
|
|
11
|
+
body: JSON.stringify({ recipient_id: opts.userId }),
|
|
12
|
+
});
|
|
13
|
+
if (!dmRes.ok)
|
|
14
|
+
throw new Error(`Failed to open DM: ${dmRes.status}`);
|
|
15
|
+
const dm = await dmRes.json();
|
|
16
|
+
targetChannelId = dm.id;
|
|
17
|
+
}
|
|
18
|
+
if (!targetChannelId)
|
|
19
|
+
throw new Error("Must provide channelId or userId");
|
|
20
|
+
const body = { content: opts.content };
|
|
21
|
+
if (opts.replyToMessageId) {
|
|
22
|
+
body.message_reference = { message_id: opts.replyToMessageId };
|
|
23
|
+
}
|
|
24
|
+
const res = await rateLimitedFetch(`https://discord.com/api/v10/channels/${targetChannelId}/messages`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: makeDiscordHeaders(token),
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const err = await res.json();
|
|
31
|
+
throw new Error(`Failed to send: ${res.status} — ${JSON.stringify(err)}`);
|
|
32
|
+
}
|
|
33
|
+
const msg = await res.json();
|
|
34
|
+
return { messageId: msg.id, timestamp: msg.timestamp, channelId: targetChannelId };
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tensakulabs/discord-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Discord selfbot MCP server — read & send messages via Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"discord-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"prepublishOnly": "tsc"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
|
+
"better-sqlite3": "^9.0.0",
|
|
26
|
+
"ws": "^8.16.0",
|
|
27
|
+
"commander": "^12.0.0"
|
|
28
|
+
},
|
|
29
|
+
"optionalDependencies": {
|
|
30
|
+
"keytar": "^7.9.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.3.0",
|
|
37
|
+
"@types/node": "^20.0.0",
|
|
38
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
39
|
+
"@types/ws": "^8.5.0"
|
|
40
|
+
}
|
|
41
|
+
}
|