@tensakulabs/discord-mcp 0.1.2 → 0.1.4
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 +79 -7
- package/dist/cli.js +4 -0
- package/dist/config.js +12 -0
- package/dist/daemon.js +34 -4
- package/dist/db.js +18 -12
- package/dist/hooks.js +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ Exposes 6 Discord tools via MCP (Model Context Protocol):
|
|
|
17
17
|
| `discord_send_message` | Send a message (channel or DM) |
|
|
18
18
|
| `discord_get_unread` | Get messages you haven't seen yet |
|
|
19
19
|
|
|
20
|
+
A background daemon maintains a persistent WebSocket to the Discord Gateway, ingesting messages into a local SQLite database so `discord_get_unread` works even while Claude isn't running.
|
|
21
|
+
|
|
20
22
|
## Setup
|
|
21
23
|
|
|
22
24
|
### 1. Install & configure
|
|
@@ -27,8 +29,9 @@ npx @tensakulabs/discord-mcp setup
|
|
|
27
29
|
|
|
28
30
|
This will:
|
|
29
31
|
1. Show you how to extract your Discord token from the desktop app
|
|
30
|
-
2. Save it securely to your OS keychain
|
|
31
|
-
3.
|
|
32
|
+
2. Save it securely to your OS keychain (macOS: uses built-in `security` CLI — no native module required)
|
|
33
|
+
3. Start the background daemon via launchd (macOS) or systemd (Linux)
|
|
34
|
+
4. Auto-register the MCP server in Claude's config
|
|
32
35
|
|
|
33
36
|
### 2. Token extraction (step shown during setup)
|
|
34
37
|
|
|
@@ -72,6 +75,69 @@ Once registered, Claude can use Discord tools directly:
|
|
|
72
75
|
→ Claude calls discord_send_message with replyToMessageId
|
|
73
76
|
```
|
|
74
77
|
|
|
78
|
+
## Hooks
|
|
79
|
+
|
|
80
|
+
Fire shell commands or HTTP webhooks when Discord events occur. Configure in `~/.config/discord-mcp/config.json`:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"hooks": {
|
|
85
|
+
"on_mention": [
|
|
86
|
+
{
|
|
87
|
+
"type": "command",
|
|
88
|
+
"enabled": true,
|
|
89
|
+
"command": "osascript -e 'display notification \"{content}\" with title \"Mention from {author}\"'"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"on_everyone": [],
|
|
93
|
+
"on_here": [],
|
|
94
|
+
"on_message": []
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Hook types
|
|
100
|
+
|
|
101
|
+
| Hook | Fires when |
|
|
102
|
+
|------|-----------|
|
|
103
|
+
| `on_mention` | Someone directly @username mentions you |
|
|
104
|
+
| `on_everyone` | Someone uses @everyone in a server you're in |
|
|
105
|
+
| `on_here` | Someone uses @here in a server you're in |
|
|
106
|
+
| `on_message` | Any non-bot message (use sparingly — fires a lot) |
|
|
107
|
+
|
|
108
|
+
### Hook config fields
|
|
109
|
+
|
|
110
|
+
| Field | Values | Description |
|
|
111
|
+
|-------|--------|-------------|
|
|
112
|
+
| `type` | `"command"` \| `"http"` | Shell command or HTTP POST |
|
|
113
|
+
| `enabled` | `true` \| `false` | Toggle without removing |
|
|
114
|
+
| `command` | string | Shell command (type: command) |
|
|
115
|
+
| `url` | string | Endpoint to POST to (type: http) |
|
|
116
|
+
|
|
117
|
+
### Template variables
|
|
118
|
+
|
|
119
|
+
Available in `command` strings and HTTP POST body:
|
|
120
|
+
|
|
121
|
+
| Variable | Value |
|
|
122
|
+
|----------|-------|
|
|
123
|
+
| `{author}` | Username of message sender |
|
|
124
|
+
| `{content}` | Message text |
|
|
125
|
+
| `{channel}` | Channel ID |
|
|
126
|
+
| `{guild}` | Guild/server ID (or `"dm"` for DMs) |
|
|
127
|
+
| `{is_dm}` | `true` or `false` |
|
|
128
|
+
|
|
129
|
+
### HTTP hook payload
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"author": "username",
|
|
134
|
+
"content": "message text",
|
|
135
|
+
"channel": "channel-id",
|
|
136
|
+
"guild": "guild-id",
|
|
137
|
+
"is_dm": false
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
75
141
|
## OpenClaw integration
|
|
76
142
|
|
|
77
143
|
Add to OpenClaw's MCP config:
|
|
@@ -91,10 +157,9 @@ Add to OpenClaw's MCP config:
|
|
|
91
157
|
|
|
92
158
|
## Security
|
|
93
159
|
|
|
94
|
-
- Token stored in OS keychain (macOS
|
|
95
|
-
- Fallback: AES-256-CBC encrypted file at `~/.config/discord-mcp/token.enc`
|
|
160
|
+
- Token stored in OS keychain (macOS: `security` CLI — no native module compilation needed; Linux: keytar; fallback: AES-256-CBC encrypted file)
|
|
96
161
|
- Token never written to config files or logs
|
|
97
|
-
-
|
|
162
|
+
- Local files at `~/.config/discord-mcp/`: `messages.db`, `config.json`, `token.enc` (fallback only), `daemon.log`
|
|
98
163
|
|
|
99
164
|
## Architecture
|
|
100
165
|
|
|
@@ -103,9 +168,14 @@ discord-mcp/
|
|
|
103
168
|
├── src/
|
|
104
169
|
│ ├── index.ts MCP server entry — registers 6 tools
|
|
105
170
|
│ ├── cli.ts setup + status commands
|
|
106
|
-
│ ├──
|
|
171
|
+
│ ├── daemon.ts Discord Gateway WebSocket → SQLite ingestion + hooks
|
|
172
|
+
│ ├── auth.ts keychain token storage (macOS security CLI / keytar / encrypted file)
|
|
173
|
+
│ ├── hooks.ts hook runner — shell commands and HTTP webhooks
|
|
174
|
+
│ ├── config.ts config loader (~/.config/discord-mcp/config.json)
|
|
175
|
+
│ ├── db.ts SQLite schema + queries
|
|
107
176
|
│ ├── ratelimit.ts 429 backoff + Discord headers
|
|
108
|
-
│ ├── state.ts per-channel
|
|
177
|
+
│ ├── state.ts per-channel last-seen state
|
|
178
|
+
│ ├── purge.ts scheduled message retention cleanup
|
|
109
179
|
│ └── tools/
|
|
110
180
|
│ ├── list_guilds.ts
|
|
111
181
|
│ ├── list_channels.ts
|
|
@@ -116,3 +186,5 @@ discord-mcp/
|
|
|
116
186
|
```
|
|
117
187
|
|
|
118
188
|
All Discord API calls go through `rateLimitedFetch` — automatic backoff on 429.
|
|
189
|
+
|
|
190
|
+
The daemon runs as a launchd service (`com.discord-mcp.daemon`) on macOS, connecting to `wss://gateway.discord.gg` and storing all messages locally for fast unread queries.
|
package/dist/cli.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -10,6 +10,12 @@ const DEFAULTS = {
|
|
|
10
10
|
mentioned: 90,
|
|
11
11
|
fallback_to_api: true,
|
|
12
12
|
},
|
|
13
|
+
hooks: {
|
|
14
|
+
on_mention: [],
|
|
15
|
+
on_everyone: [],
|
|
16
|
+
on_here: [],
|
|
17
|
+
on_message: [],
|
|
18
|
+
},
|
|
13
19
|
};
|
|
14
20
|
export function loadConfig() {
|
|
15
21
|
if (!existsSync(CONFIG_FILE))
|
|
@@ -18,6 +24,12 @@ export function loadConfig() {
|
|
|
18
24
|
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
19
25
|
return {
|
|
20
26
|
retention: { ...DEFAULTS.retention, ...(raw.retention ?? {}) },
|
|
27
|
+
hooks: {
|
|
28
|
+
on_mention: raw.hooks?.on_mention ?? [],
|
|
29
|
+
on_everyone: raw.hooks?.on_everyone ?? [],
|
|
30
|
+
on_here: raw.hooks?.on_here ?? [],
|
|
31
|
+
on_message: raw.hooks?.on_message ?? [],
|
|
32
|
+
},
|
|
21
33
|
};
|
|
22
34
|
}
|
|
23
35
|
catch {
|
package/dist/daemon.js
CHANGED
|
@@ -8,6 +8,8 @@ import { WebSocket } from "ws";
|
|
|
8
8
|
import { getToken } from "./auth.js";
|
|
9
9
|
import { initDb, insertMessage } from "./db.js";
|
|
10
10
|
import { schedulePurge } from "./purge.js";
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { fireHooks } from "./hooks.js";
|
|
11
13
|
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
12
14
|
const IDENTIFY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12); // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
|
|
13
15
|
let ws = null;
|
|
@@ -16,6 +18,7 @@ let sequence = null;
|
|
|
16
18
|
let sessionId = null;
|
|
17
19
|
let resumeGatewayUrl = null;
|
|
18
20
|
let reconnectDelay = 1000;
|
|
21
|
+
let selfId = null; // set from READY event
|
|
19
22
|
const db = initDb();
|
|
20
23
|
schedulePurge();
|
|
21
24
|
async function connect(resume = false) {
|
|
@@ -82,14 +85,24 @@ function handleEvent(type, data) {
|
|
|
82
85
|
case "READY":
|
|
83
86
|
sessionId = data.session_id;
|
|
84
87
|
resumeGatewayUrl = data.resume_gateway_url;
|
|
85
|
-
|
|
88
|
+
selfId = data.user?.id ?? null;
|
|
89
|
+
console.error(`[discord-mcp daemon] Ready. (selfId: ${selfId})`);
|
|
86
90
|
break;
|
|
87
91
|
case "MESSAGE_CREATE": {
|
|
88
92
|
const msg = data;
|
|
89
93
|
if (msg.author?.bot)
|
|
90
94
|
break; // ignore bots
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
// Detect mention type
|
|
96
|
+
const isDirect = selfId !== null && Array.isArray(msg.mentions) &&
|
|
97
|
+
msg.mentions.some((u) => u.id === selfId);
|
|
98
|
+
const isEveryone = msg.mention_everyone === true &&
|
|
99
|
+
msg.content.includes("@everyone");
|
|
100
|
+
const isHere = msg.mention_everyone === true &&
|
|
101
|
+
msg.content.includes("@here");
|
|
102
|
+
const mentionType = isDirect ? "direct"
|
|
103
|
+
: isEveryone ? "everyone"
|
|
104
|
+
: isHere ? "here"
|
|
105
|
+
: null;
|
|
93
106
|
insertMessage(db, {
|
|
94
107
|
id: msg.id,
|
|
95
108
|
channel_id: msg.channel_id,
|
|
@@ -99,8 +112,25 @@ function handleEvent(type, data) {
|
|
|
99
112
|
content: msg.content,
|
|
100
113
|
timestamp: new Date(msg.timestamp).getTime(),
|
|
101
114
|
is_dm: msg.guild_id ? 0 : 1,
|
|
102
|
-
is_mention:
|
|
115
|
+
is_mention: mentionType !== null ? 1 : 0,
|
|
116
|
+
mention_type: mentionType,
|
|
103
117
|
});
|
|
118
|
+
// Fire hooks
|
|
119
|
+
const cfg = loadConfig();
|
|
120
|
+
const ctx = {
|
|
121
|
+
author: msg.author.username,
|
|
122
|
+
content: msg.content,
|
|
123
|
+
channel: msg.channel_id,
|
|
124
|
+
guild: msg.guild_id ?? "dm",
|
|
125
|
+
is_dm: !msg.guild_id,
|
|
126
|
+
};
|
|
127
|
+
if (isDirect)
|
|
128
|
+
fireHooks(cfg.hooks.on_mention, ctx);
|
|
129
|
+
if (isEveryone)
|
|
130
|
+
fireHooks(cfg.hooks.on_everyone, ctx);
|
|
131
|
+
if (isHere)
|
|
132
|
+
fireHooks(cfg.hooks.on_here, ctx);
|
|
133
|
+
fireHooks(cfg.hooks.on_message, ctx);
|
|
104
134
|
break;
|
|
105
135
|
}
|
|
106
136
|
}
|
package/dist/db.js
CHANGED
|
@@ -28,16 +28,17 @@ export function initDb() {
|
|
|
28
28
|
db.pragma("synchronous = NORMAL");
|
|
29
29
|
db.exec(`
|
|
30
30
|
CREATE TABLE IF NOT EXISTS messages (
|
|
31
|
-
id
|
|
32
|
-
channel_id
|
|
33
|
-
guild_id
|
|
34
|
-
author_id
|
|
35
|
-
author_name
|
|
36
|
-
content
|
|
37
|
-
timestamp
|
|
38
|
-
is_dm
|
|
39
|
-
is_mention
|
|
40
|
-
|
|
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
|
+
mention_type TEXT DEFAULT NULL, -- "direct" | "everyone" | "here" | null
|
|
41
|
+
seen INTEGER NOT NULL DEFAULT 0
|
|
41
42
|
);
|
|
42
43
|
|
|
43
44
|
CREATE INDEX IF NOT EXISTS idx_channel_ts ON messages(channel_id, timestamp DESC);
|
|
@@ -55,15 +56,20 @@ export function initDb() {
|
|
|
55
56
|
VALUES (new.rowid, new.content, new.author_name);
|
|
56
57
|
END;
|
|
57
58
|
`);
|
|
59
|
+
// Migration: add mention_type column if not present (existing installs)
|
|
60
|
+
const cols = db.pragma("table_info(messages)").map(c => c.name);
|
|
61
|
+
if (!cols.includes("mention_type")) {
|
|
62
|
+
db.exec("ALTER TABLE messages ADD COLUMN mention_type TEXT DEFAULT NULL");
|
|
63
|
+
}
|
|
58
64
|
_db = db;
|
|
59
65
|
return db;
|
|
60
66
|
}
|
|
61
67
|
export function insertMessage(db, msg) {
|
|
62
68
|
const stmt = db.prepare(`
|
|
63
69
|
INSERT OR IGNORE INTO messages
|
|
64
|
-
(id, channel_id, guild_id, author_id, author_name, content, timestamp, is_dm, is_mention, seen)
|
|
70
|
+
(id, channel_id, guild_id, author_id, author_name, content, timestamp, is_dm, is_mention, mention_type, seen)
|
|
65
71
|
VALUES
|
|
66
|
-
(@id, @channel_id, @guild_id, @author_id, @author_name, @content, @timestamp, @is_dm, @is_mention, 0)
|
|
72
|
+
(@id, @channel_id, @guild_id, @author_id, @author_name, @content, @timestamp, @is_dm, @is_mention, @mention_type, 0)
|
|
67
73
|
`);
|
|
68
74
|
stmt.run(msg);
|
|
69
75
|
}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
function substitute(template, ctx) {
|
|
3
|
+
return template
|
|
4
|
+
.replace(/\{author\}/g, ctx.author)
|
|
5
|
+
.replace(/\{content\}/g, ctx.content.replace(/"/g, '\\"'))
|
|
6
|
+
.replace(/\{channel\}/g, ctx.channel)
|
|
7
|
+
.replace(/\{guild\}/g, ctx.guild)
|
|
8
|
+
.replace(/\{is_dm\}/g, String(ctx.is_dm));
|
|
9
|
+
}
|
|
10
|
+
async function runHook(hook, ctx) {
|
|
11
|
+
if (!hook.enabled)
|
|
12
|
+
return;
|
|
13
|
+
if (hook.type === "command" && hook.command) {
|
|
14
|
+
const cmd = substitute(hook.command, ctx);
|
|
15
|
+
exec(cmd, (err) => {
|
|
16
|
+
if (err)
|
|
17
|
+
console.error(`[discord-mcp hooks] command failed: ${err.message}`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (hook.type === "http" && hook.url) {
|
|
21
|
+
fetch(hook.url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify(ctx),
|
|
25
|
+
}).catch((err) => {
|
|
26
|
+
console.error(`[discord-mcp hooks] http failed: ${err.message}`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function fireHooks(hooks, ctx) {
|
|
31
|
+
for (const hook of hooks) {
|
|
32
|
+
runHook(hook, ctx).catch(() => { }); // never block the daemon
|
|
33
|
+
}
|
|
34
|
+
}
|