@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 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
+ }