@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 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. Auto-register the MCP server in Claude's config
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 Keychain, Linux Secret Service, Windows Credential Store)
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
- - State (last-seen timestamps, channel modes) at `~/.config/discord-mcp/state.json`
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
- │ ├── auth.ts keychain token storage
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 state (review/auto/muted + last-seen)
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
@@ -157,4 +157,8 @@ program
157
157
  process.exit(1);
158
158
  }
159
159
  });
160
+ // Default: no subcommand → run MCP server
161
+ program.action(async () => {
162
+ await import("./index.js");
163
+ });
160
164
  program.parse();
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
- console.error(`[discord-mcp daemon] Ready.`);
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
- const isMention = Array.isArray(msg.mentions) &&
92
- msg.mentions.some((u) => u.id === (data.self_id ?? ""));
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: isMention ? 1 : 0,
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 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
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensakulabs/discord-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Discord selfbot MCP server — read & send messages via Claude",
5
5
  "license": "MIT",
6
6
  "type": "module",