@tensakulabs/discord-mcp 0.1.4 → 0.1.7
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/dist/auth.js +43 -22
- package/dist/cli.js +73 -31
- package/dist/config.js +15 -7
- package/dist/daemon.js +5 -4
- package/dist/db.js +22 -13
- package/dist/index.js +8 -7
- package/dist/purge.js +6 -6
- package/dist/state.js +24 -17
- package/dist/tools/get_dms.js +2 -2
- package/dist/tools/get_messages.js +7 -7
- package/dist/tools/get_unread.js +6 -6
- package/dist/tools/list_channels.js +2 -2
- package/dist/tools/list_guilds.js +2 -2
- package/dist/tools/search.js +2 -2
- package/dist/tools/send_message.js +2 -2
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -4,22 +4,34 @@ import { join } from "path";
|
|
|
4
4
|
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
5
5
|
import { execSync } from "child_process";
|
|
6
6
|
const SERVICE = "discord-mcp";
|
|
7
|
-
const ACCOUNT = "user-token";
|
|
8
7
|
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
function keychainAccount(account) {
|
|
9
|
+
return account === "default" ? "user-token" : `user-token-${account}`;
|
|
10
|
+
}
|
|
11
|
+
function tokenFilePath(account) {
|
|
12
|
+
if (account === "default")
|
|
13
|
+
return join(CONFIG_DIR, "token.enc");
|
|
14
|
+
return join(CONFIG_DIR, account, "token.enc");
|
|
15
|
+
}
|
|
16
|
+
function keyFilePath(account) {
|
|
17
|
+
if (account === "default")
|
|
18
|
+
return join(CONFIG_DIR, "key.bin");
|
|
19
|
+
return join(CONFIG_DIR, account, "key.bin");
|
|
20
|
+
}
|
|
11
21
|
// macOS: use built-in `security` CLI — no native module compilation required
|
|
12
|
-
function saveMacOSKeychain(token) {
|
|
22
|
+
function saveMacOSKeychain(token, account) {
|
|
23
|
+
const acct = keychainAccount(account);
|
|
13
24
|
// Delete existing entry first to avoid "already exists" error
|
|
14
25
|
try {
|
|
15
|
-
execSync(`security delete-generic-password -s "${SERVICE}" -a "${
|
|
26
|
+
execSync(`security delete-generic-password -s "${SERVICE}" -a "${acct}" 2>/dev/null`);
|
|
16
27
|
}
|
|
17
28
|
catch { /* not found, ok */ }
|
|
18
|
-
execSync(`security add-generic-password -s "${SERVICE}" -a "${
|
|
29
|
+
execSync(`security add-generic-password -s "${SERVICE}" -a "${acct}" -w "${token}"`);
|
|
19
30
|
}
|
|
20
|
-
function getMacOSKeychain() {
|
|
31
|
+
function getMacOSKeychain(account) {
|
|
32
|
+
const acct = keychainAccount(account);
|
|
21
33
|
try {
|
|
22
|
-
return execSync(`security find-generic-password -s "${SERVICE}" -a "${
|
|
34
|
+
return execSync(`security find-generic-password -s "${SERVICE}" -a "${acct}" -w 2>/dev/null`).toString().trim() || null;
|
|
23
35
|
}
|
|
24
36
|
catch {
|
|
25
37
|
return null;
|
|
@@ -37,55 +49,64 @@ async function getKeytar() {
|
|
|
37
49
|
}
|
|
38
50
|
return _keytar;
|
|
39
51
|
}
|
|
40
|
-
export async function saveToken(token) {
|
|
52
|
+
export async function saveToken(token, account = "default") {
|
|
41
53
|
// macOS: security CLI always works — no native module needed
|
|
42
54
|
if (platform() === "darwin") {
|
|
43
|
-
saveMacOSKeychain(token);
|
|
55
|
+
saveMacOSKeychain(token, account);
|
|
44
56
|
return;
|
|
45
57
|
}
|
|
46
58
|
// Other platforms: try keytar
|
|
47
59
|
const keytar = await getKeytar();
|
|
60
|
+
const acct = keychainAccount(account);
|
|
48
61
|
try {
|
|
49
62
|
if (!keytar)
|
|
50
63
|
throw new Error("keytar not available");
|
|
51
|
-
await keytar.setPassword(SERVICE,
|
|
64
|
+
await keytar.setPassword(SERVICE, acct, token);
|
|
52
65
|
return;
|
|
53
66
|
}
|
|
54
67
|
catch { /* fall through */ }
|
|
55
68
|
// Fallback: encrypt to file (for headless/CI environments)
|
|
56
69
|
console.warn("⚠️ Keychain unavailable, using encrypted file fallback.");
|
|
70
|
+
const tokenFile = tokenFilePath(account);
|
|
71
|
+
const keyFile = keyFilePath(account);
|
|
57
72
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
73
|
+
if (account !== "default")
|
|
74
|
+
mkdirSync(join(CONFIG_DIR, account), { recursive: true, mode: 0o700 });
|
|
58
75
|
let key;
|
|
59
|
-
if (existsSync(
|
|
60
|
-
key = readFileSync(
|
|
76
|
+
if (existsSync(keyFile)) {
|
|
77
|
+
key = readFileSync(keyFile);
|
|
61
78
|
}
|
|
62
79
|
else {
|
|
63
80
|
key = randomBytes(32);
|
|
64
|
-
writeFileSync(
|
|
81
|
+
writeFileSync(keyFile, key, { mode: 0o600 });
|
|
65
82
|
}
|
|
66
83
|
const iv = randomBytes(16);
|
|
67
84
|
const cipher = createCipheriv("aes-256-cbc", key, iv);
|
|
68
85
|
const enc = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
69
|
-
writeFileSync(
|
|
86
|
+
writeFileSync(tokenFile, Buffer.concat([iv, enc]), { mode: 0o600 });
|
|
70
87
|
}
|
|
71
|
-
export async function getToken() {
|
|
88
|
+
export async function getToken(account = "default") {
|
|
72
89
|
// macOS: try security CLI first
|
|
73
90
|
if (platform() === "darwin") {
|
|
74
|
-
const token = getMacOSKeychain();
|
|
91
|
+
const token = getMacOSKeychain(account);
|
|
75
92
|
if (token)
|
|
76
93
|
return token;
|
|
77
94
|
}
|
|
78
95
|
// Try keytar
|
|
79
96
|
const keytar = await getKeytar();
|
|
80
|
-
const
|
|
97
|
+
const acct = keychainAccount(account);
|
|
98
|
+
const keychainToken = keytar ? await keytar.getPassword(SERVICE, acct).catch(() => null) : null;
|
|
81
99
|
if (keychainToken)
|
|
82
100
|
return keychainToken;
|
|
83
101
|
// Fallback: encrypted file
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
const tokenFile = tokenFilePath(account);
|
|
103
|
+
const keyFile = keyFilePath(account);
|
|
104
|
+
if (!existsSync(tokenFile) || !existsSync(keyFile)) {
|
|
105
|
+
const flag = account === "default" ? "" : ` --account ${account}`;
|
|
106
|
+
throw new Error(`No Discord token found. Run: npx @tensakulabs/discord-mcp setup${flag}`);
|
|
86
107
|
}
|
|
87
|
-
const key = readFileSync(
|
|
88
|
-
const raw = readFileSync(
|
|
108
|
+
const key = readFileSync(keyFile);
|
|
109
|
+
const raw = readFileSync(tokenFile);
|
|
89
110
|
const iv = raw.slice(0, 16);
|
|
90
111
|
const enc = raw.slice(16);
|
|
91
112
|
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
package/dist/cli.js
CHANGED
|
@@ -10,14 +10,17 @@ const program = new Command();
|
|
|
10
10
|
program
|
|
11
11
|
.name("discord-mcp")
|
|
12
12
|
.description("Discord selfbot MCP server for Claude")
|
|
13
|
-
.version("0.1.
|
|
13
|
+
.version("0.1.6");
|
|
14
14
|
program
|
|
15
15
|
.command("setup")
|
|
16
16
|
.description("Configure Discord token and register MCP server")
|
|
17
|
-
.
|
|
17
|
+
.option("--account <name>", "Account name (e.g. work, personal)", "default")
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const account = opts.account;
|
|
20
|
+
const accountLabel = account === "default" ? "" : ` [${account}]`;
|
|
18
21
|
console.log(`
|
|
19
22
|
╔══════════════════════════════════════════════════╗
|
|
20
|
-
║ discord-mcp setup
|
|
23
|
+
║ discord-mcp setup${accountLabel.padEnd(22)}║
|
|
21
24
|
╚══════════════════════════════════════════════════╝
|
|
22
25
|
|
|
23
26
|
Step 1: Extract your Discord token
|
|
@@ -32,24 +35,23 @@ Step 1: Extract your Discord token
|
|
|
32
35
|
|
|
33
36
|
`);
|
|
34
37
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
-
const token = await new Promise(
|
|
36
|
-
rl.question("Paste your Discord token: ", ans => { rl.close();
|
|
38
|
+
const token = await new Promise(res => {
|
|
39
|
+
rl.question("Paste your Discord token: ", ans => { rl.close(); res(ans.trim()); });
|
|
37
40
|
});
|
|
38
41
|
if (!token || token.length < 20) {
|
|
39
42
|
console.error("❌ Invalid token. Try again.");
|
|
40
43
|
process.exit(1);
|
|
41
44
|
}
|
|
42
|
-
await saveToken(token);
|
|
45
|
+
await saveToken(token, account);
|
|
43
46
|
console.log("✅ Token saved securely to OS keychain (or encrypted file fallback).");
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
// Write launchd plist (macOS only) — ISC-D6
|
|
47
|
+
// MCP key and daemon label — namespaced for non-default accounts
|
|
48
|
+
const mcpKey = account === "default" ? "discord" : `discord-${account}`;
|
|
49
|
+
const daemonLabel = account === "default" ? "com.discord-mcp.daemon" : `com.discord-mcp.daemon.${account}`;
|
|
50
|
+
const accountArgs = account === "default" ? [] : ["--account", account];
|
|
51
|
+
// Write launchd plist (macOS only)
|
|
50
52
|
if (platform() === "darwin") {
|
|
51
53
|
const launchAgentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
52
|
-
const plistPath = join(launchAgentsDir,
|
|
54
|
+
const plistPath = join(launchAgentsDir, `${daemonLabel}.plist`);
|
|
53
55
|
const npxPath = (() => { try {
|
|
54
56
|
return execSync("which npx").toString().trim();
|
|
55
57
|
}
|
|
@@ -62,18 +64,20 @@ Step 1: Extract your Discord token
|
|
|
62
64
|
catch {
|
|
63
65
|
return "/usr/local/bin";
|
|
64
66
|
} })();
|
|
65
|
-
const logDir =
|
|
67
|
+
const logDir = account === "default"
|
|
68
|
+
? join(homedir(), ".config", "discord-mcp")
|
|
69
|
+
: join(homedir(), ".config", "discord-mcp", account);
|
|
70
|
+
const daemonCmdArgs = [npxPath, "@tensakulabs/discord-mcp", "daemon-start", ...accountArgs];
|
|
71
|
+
const plistArgs = daemonCmdArgs.map(a => ` <string>${a}</string>`).join("\n");
|
|
66
72
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
73
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
74
|
<plist version="1.0">
|
|
69
75
|
<dict>
|
|
70
76
|
<key>Label</key>
|
|
71
|
-
<string
|
|
77
|
+
<string>${daemonLabel}</string>
|
|
72
78
|
<key>ProgramArguments</key>
|
|
73
79
|
<array>
|
|
74
|
-
|
|
75
|
-
<string>@tensakulabs/discord-mcp</string>
|
|
76
|
-
<string>daemon-start</string>
|
|
80
|
+
${plistArgs}
|
|
77
81
|
</array>
|
|
78
82
|
<key>EnvironmentVariables</key>
|
|
79
83
|
<dict>
|
|
@@ -91,61 +95,75 @@ Step 1: Extract your Discord token
|
|
|
91
95
|
</dict>
|
|
92
96
|
</plist>`;
|
|
93
97
|
mkdirSync(launchAgentsDir, { recursive: true });
|
|
98
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
94
99
|
writeFileSync(plistPath, plist);
|
|
95
100
|
try {
|
|
96
101
|
execSync(`launchctl unload "${plistPath}" 2>/dev/null; launchctl load "${plistPath}"`);
|
|
97
|
-
console.log(`✅ Daemon registered:
|
|
102
|
+
console.log(`✅ Daemon registered: ${daemonLabel} (starts at login, running now)`);
|
|
98
103
|
}
|
|
99
104
|
catch {
|
|
100
105
|
console.log(`✅ Plist written: ${plistPath} (run: launchctl load "${plistPath}")`);
|
|
101
106
|
}
|
|
102
107
|
}
|
|
108
|
+
// Auto-patch Claude Code settings.json
|
|
109
|
+
const configPath = [join(homedir(), ".claude", "settings.json")].find(existsSync);
|
|
103
110
|
if (configPath) {
|
|
104
111
|
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
105
112
|
config.mcpServers ??= {};
|
|
106
|
-
config.mcpServers[
|
|
113
|
+
config.mcpServers[mcpKey] = {
|
|
107
114
|
command: "npx",
|
|
108
|
-
args: ["-y", "@tensakulabs/discord-mcp"],
|
|
115
|
+
args: ["-y", "@tensakulabs/discord-mcp", "mcp", ...accountArgs],
|
|
109
116
|
};
|
|
110
117
|
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
111
|
-
console.log(`✅ Registered in Claude config: ${configPath}`);
|
|
118
|
+
console.log(`✅ Registered in Claude config as "${mcpKey}": ${configPath}`);
|
|
112
119
|
console.log("\n🎉 Done! Restart Claude Code to start using Discord tools.");
|
|
113
120
|
}
|
|
114
121
|
else {
|
|
122
|
+
const extraArgs = accountArgs.length ? `, ${accountArgs.map(a => `"${a}"`).join(", ")}` : "";
|
|
115
123
|
console.log(`
|
|
116
124
|
⚠️ Could not find Claude Code config automatically.
|
|
117
125
|
|
|
118
126
|
Add this to your ~/.claude/settings.json manually:
|
|
119
127
|
|
|
120
128
|
"mcpServers": {
|
|
121
|
-
"
|
|
129
|
+
"${mcpKey}": {
|
|
122
130
|
"command": "npx",
|
|
123
|
-
"args": ["-y", "@tensakulabs/discord-mcp"]
|
|
131
|
+
"args": ["-y", "@tensakulabs/discord-mcp", "mcp"${extraArgs}]
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
`);
|
|
127
135
|
}
|
|
128
136
|
});
|
|
137
|
+
program
|
|
138
|
+
.command("mcp")
|
|
139
|
+
.description("Start the MCP server (stdio transport — used by Claude Code)")
|
|
140
|
+
.option("--account <name>", "Account name", "default")
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
process.env.DISCORD_MCP_ACCOUNT = opts.account;
|
|
143
|
+
await import("./index.js");
|
|
144
|
+
});
|
|
129
145
|
program
|
|
130
146
|
.command("daemon-start")
|
|
131
147
|
.description("Start the daemon (called by launchd — not intended for direct use)")
|
|
132
|
-
.
|
|
133
|
-
|
|
148
|
+
.option("--account <name>", "Account name", "default")
|
|
149
|
+
.action(async (opts) => {
|
|
150
|
+
process.env.DISCORD_MCP_ACCOUNT = opts.account;
|
|
134
151
|
await import("./daemon.js");
|
|
135
152
|
});
|
|
136
153
|
program
|
|
137
154
|
.command("status")
|
|
138
155
|
.description("Check if token is set and valid")
|
|
139
|
-
.
|
|
156
|
+
.option("--account <name>", "Account name", "default")
|
|
157
|
+
.action(async (opts) => {
|
|
140
158
|
try {
|
|
141
|
-
const token = await getToken();
|
|
142
|
-
// Validate token with a lightweight API call
|
|
159
|
+
const token = await getToken(opts.account);
|
|
143
160
|
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
|
144
161
|
headers: { Authorization: token },
|
|
145
162
|
});
|
|
146
163
|
if (res.ok) {
|
|
147
164
|
const user = await res.json();
|
|
148
|
-
|
|
165
|
+
const label = opts.account === "default" ? "" : ` [${opts.account}]`;
|
|
166
|
+
console.log(`✅ Connected as${label}: ${user.username}#${user.discriminator}`);
|
|
149
167
|
}
|
|
150
168
|
else {
|
|
151
169
|
console.error(`❌ Token invalid: HTTP ${res.status}`);
|
|
@@ -153,10 +171,34 @@ program
|
|
|
153
171
|
}
|
|
154
172
|
}
|
|
155
173
|
catch {
|
|
156
|
-
|
|
174
|
+
const flag = opts.account === "default" ? "" : ` --account ${opts.account}`;
|
|
175
|
+
console.error(`❌ No token found. Run: npx @tensakulabs/discord-mcp setup${flag}`);
|
|
157
176
|
process.exit(1);
|
|
158
177
|
}
|
|
159
178
|
});
|
|
179
|
+
program
|
|
180
|
+
.command("list-accounts")
|
|
181
|
+
.description("List all configured Discord accounts")
|
|
182
|
+
.action(() => {
|
|
183
|
+
const configPath = join(homedir(), ".claude", "settings.json");
|
|
184
|
+
if (!existsSync(configPath)) {
|
|
185
|
+
console.log("No Claude config found.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
189
|
+
const servers = Object.entries(config.mcpServers ?? {})
|
|
190
|
+
.filter(([k]) => k === "discord" || k.startsWith("discord-"));
|
|
191
|
+
if (servers.length === 0) {
|
|
192
|
+
console.log("No Discord accounts configured. Run: npx @tensakulabs/discord-mcp setup");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
console.log("Configured Discord accounts:");
|
|
196
|
+
for (const [key, val] of servers) {
|
|
197
|
+
const idx = val.args?.indexOf("--account") ?? -1;
|
|
198
|
+
const name = idx >= 0 && val.args ? (val.args[idx + 1] ?? "default") : "default";
|
|
199
|
+
console.log(` ${key} → account: ${name}`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
160
202
|
// Default: no subcommand → run MCP server
|
|
161
203
|
program.action(async () => {
|
|
162
204
|
await import("./index.js");
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
5
|
-
|
|
5
|
+
function configFilePath(account) {
|
|
6
|
+
return account === "default"
|
|
7
|
+
? join(CONFIG_DIR, "config.json")
|
|
8
|
+
: join(CONFIG_DIR, account, "config.json");
|
|
9
|
+
}
|
|
6
10
|
const DEFAULTS = {
|
|
7
11
|
retention: {
|
|
8
12
|
guild_channels: 30,
|
|
@@ -17,11 +21,12 @@ const DEFAULTS = {
|
|
|
17
21
|
on_message: [],
|
|
18
22
|
},
|
|
19
23
|
};
|
|
20
|
-
export function loadConfig() {
|
|
21
|
-
|
|
24
|
+
export function loadConfig(account = "default") {
|
|
25
|
+
const configFile = configFilePath(account);
|
|
26
|
+
if (!existsSync(configFile))
|
|
22
27
|
return DEFAULTS;
|
|
23
28
|
try {
|
|
24
|
-
const raw = JSON.parse(readFileSync(
|
|
29
|
+
const raw = JSON.parse(readFileSync(configFile, "utf8"));
|
|
25
30
|
return {
|
|
26
31
|
retention: { ...DEFAULTS.retention, ...(raw.retention ?? {}) },
|
|
27
32
|
hooks: {
|
|
@@ -36,9 +41,12 @@ export function loadConfig() {
|
|
|
36
41
|
return DEFAULTS;
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
|
-
export function writeDefaultConfig() {
|
|
44
|
+
export function writeDefaultConfig(account = "default") {
|
|
45
|
+
const configFile = configFilePath(account);
|
|
40
46
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
41
|
-
if (
|
|
42
|
-
|
|
47
|
+
if (account !== "default")
|
|
48
|
+
mkdirSync(join(CONFIG_DIR, account), { recursive: true, mode: 0o700 });
|
|
49
|
+
if (!existsSync(configFile)) {
|
|
50
|
+
writeFileSync(configFile, JSON.stringify(DEFAULTS, null, 2));
|
|
43
51
|
}
|
|
44
52
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { loadConfig } from "./config.js";
|
|
|
12
12
|
import { fireHooks } from "./hooks.js";
|
|
13
13
|
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
14
14
|
const IDENTIFY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12); // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
|
|
15
|
+
const ACCOUNT = process.env.DISCORD_MCP_ACCOUNT ?? "default";
|
|
15
16
|
let ws = null;
|
|
16
17
|
let heartbeatInterval = null;
|
|
17
18
|
let sequence = null;
|
|
@@ -19,10 +20,10 @@ let sessionId = null;
|
|
|
19
20
|
let resumeGatewayUrl = null;
|
|
20
21
|
let reconnectDelay = 1000;
|
|
21
22
|
let selfId = null; // set from READY event
|
|
22
|
-
const db = initDb();
|
|
23
|
-
schedulePurge();
|
|
23
|
+
const db = initDb(ACCOUNT);
|
|
24
|
+
schedulePurge(ACCOUNT);
|
|
24
25
|
async function connect(resume = false) {
|
|
25
|
-
const token = await getToken();
|
|
26
|
+
const token = await getToken(ACCOUNT);
|
|
26
27
|
const url = resume && resumeGatewayUrl ? resumeGatewayUrl : GATEWAY_URL;
|
|
27
28
|
ws = new WebSocket(url);
|
|
28
29
|
ws.on("open", () => {
|
|
@@ -116,7 +117,7 @@ function handleEvent(type, data) {
|
|
|
116
117
|
mention_type: mentionType,
|
|
117
118
|
});
|
|
118
119
|
// Fire hooks
|
|
119
|
-
const cfg = loadConfig();
|
|
120
|
+
const cfg = loadConfig(ACCOUNT);
|
|
120
121
|
const ctx = {
|
|
121
122
|
author: msg.author.username,
|
|
122
123
|
content: msg.content,
|
package/dist/db.js
CHANGED
|
@@ -3,27 +3,36 @@ import { mkdirSync, existsSync } from "fs";
|
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
function dbFilePath(account) {
|
|
7
|
+
return account === "default"
|
|
8
|
+
? join(CONFIG_DIR, "messages.db")
|
|
9
|
+
: join(CONFIG_DIR, account, "messages.db");
|
|
10
|
+
}
|
|
11
|
+
const _dbs = {};
|
|
12
|
+
export function getDb(account = "default") {
|
|
13
|
+
if (_dbs[account])
|
|
14
|
+
return _dbs[account];
|
|
15
|
+
const dbFile = dbFilePath(account);
|
|
16
|
+
if (!existsSync(dbFile))
|
|
12
17
|
return null; // ISC-A5: graceful miss
|
|
13
18
|
try {
|
|
14
|
-
|
|
19
|
+
const db = new Database(dbFile);
|
|
15
20
|
// ISC-A4: WAL mode prevents corruption on crash
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
db.pragma("journal_mode = WAL");
|
|
22
|
+
db.pragma("synchronous = NORMAL");
|
|
23
|
+
_dbs[account] = db;
|
|
24
|
+
return db;
|
|
19
25
|
}
|
|
20
26
|
catch {
|
|
21
27
|
return null;
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
|
-
export function initDb() {
|
|
30
|
+
export function initDb(account = "default") {
|
|
31
|
+
const dbFile = dbFilePath(account);
|
|
25
32
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
-
|
|
33
|
+
if (account !== "default")
|
|
34
|
+
mkdirSync(join(CONFIG_DIR, account), { recursive: true, mode: 0o700 });
|
|
35
|
+
const db = new Database(dbFile);
|
|
27
36
|
db.pragma("journal_mode = WAL");
|
|
28
37
|
db.pragma("synchronous = NORMAL");
|
|
29
38
|
db.exec(`
|
|
@@ -61,7 +70,7 @@ export function initDb() {
|
|
|
61
70
|
if (!cols.includes("mention_type")) {
|
|
62
71
|
db.exec("ALTER TABLE messages ADD COLUMN mention_type TEXT DEFAULT NULL");
|
|
63
72
|
}
|
|
64
|
-
|
|
73
|
+
_dbs[account] = db;
|
|
65
74
|
return db;
|
|
66
75
|
}
|
|
67
76
|
export function insertMessage(db, msg) {
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getDMChannels } from "./tools/get_dms.js";
|
|
|
8
8
|
import { sendMessage } from "./tools/send_message.js";
|
|
9
9
|
import { getUnread } from "./tools/get_unread.js";
|
|
10
10
|
import { searchDiscord } from "./tools/search.js";
|
|
11
|
+
const ACCOUNT = process.env.DISCORD_MCP_ACCOUNT ?? "default";
|
|
11
12
|
const server = new Server({ name: "discord-mcp", version: "0.2.0" }, { capabilities: { tools: {} } });
|
|
12
13
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
13
14
|
tools: [
|
|
@@ -106,28 +107,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
106
107
|
let result;
|
|
107
108
|
switch (name) {
|
|
108
109
|
case "discord_list_guilds":
|
|
109
|
-
result = await listGuilds();
|
|
110
|
+
result = await listGuilds(ACCOUNT);
|
|
110
111
|
break;
|
|
111
112
|
case "discord_list_channels":
|
|
112
|
-
result = await listChannels(args.guildId);
|
|
113
|
+
result = await listChannels(args.guildId, ACCOUNT);
|
|
113
114
|
break;
|
|
114
115
|
case "discord_get_messages": {
|
|
115
116
|
const a = args;
|
|
116
|
-
result = await getMessages(a.channelId, a.limit, a.since, a.until);
|
|
117
|
+
result = await getMessages(a.channelId, a.limit, a.since, a.until, ACCOUNT);
|
|
117
118
|
break;
|
|
118
119
|
}
|
|
119
120
|
case "discord_get_dms":
|
|
120
|
-
result = await getDMChannels();
|
|
121
|
+
result = await getDMChannels(ACCOUNT);
|
|
121
122
|
break;
|
|
122
123
|
case "discord_send_message":
|
|
123
|
-
result = await sendMessage(args);
|
|
124
|
+
result = await sendMessage(args, ACCOUNT);
|
|
124
125
|
break;
|
|
125
126
|
case "discord_get_unread":
|
|
126
|
-
result = await getUnread(args.channels);
|
|
127
|
+
result = await getUnread(args.channels, ACCOUNT);
|
|
127
128
|
break;
|
|
128
129
|
case "discord_search": {
|
|
129
130
|
const a = args;
|
|
130
|
-
result = await searchDiscord(a.query, a.channelId, a.since, a.until, a.limit);
|
|
131
|
+
result = await searchDiscord(a.query, a.channelId, a.since, a.until, a.limit, ACCOUNT);
|
|
131
132
|
break;
|
|
132
133
|
}
|
|
133
134
|
default:
|
package/dist/purge.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { getDb, deleteOlderThan } from "./db.js";
|
|
2
2
|
import { loadConfig } from "./config.js";
|
|
3
|
-
export function runPurge() {
|
|
4
|
-
const db = getDb();
|
|
3
|
+
export function runPurge(account = "default") {
|
|
4
|
+
const db = getDb(account);
|
|
5
5
|
if (!db)
|
|
6
6
|
return;
|
|
7
|
-
const config = loadConfig();
|
|
7
|
+
const config = loadConfig(account);
|
|
8
8
|
const now = Date.now();
|
|
9
9
|
const guildCutoff = now - config.retention.guild_channels * 24 * 60 * 60 * 1000;
|
|
10
10
|
const dmCutoff = now - config.retention.dms * 24 * 60 * 60 * 1000;
|
|
@@ -19,7 +19,7 @@ export function runPurge() {
|
|
|
19
19
|
deleteOlderThan(db, dmCutoff, true, true);
|
|
20
20
|
console.error(`[discord-mcp] Purge complete.`);
|
|
21
21
|
}
|
|
22
|
-
export function schedulePurge() {
|
|
22
|
+
export function schedulePurge(account = "default") {
|
|
23
23
|
// Run at next 2am, then every 24h
|
|
24
24
|
const now = new Date();
|
|
25
25
|
const next2am = new Date(now);
|
|
@@ -28,7 +28,7 @@ export function schedulePurge() {
|
|
|
28
28
|
next2am.setDate(next2am.getDate() + 1);
|
|
29
29
|
const msUntil2am = next2am.getTime() - now.getTime();
|
|
30
30
|
setTimeout(() => {
|
|
31
|
-
runPurge();
|
|
32
|
-
setInterval(runPurge, 24 * 60 * 60 * 1000);
|
|
31
|
+
runPurge(account);
|
|
32
|
+
setInterval(() => runPurge(account), 24 * 60 * 60 * 1000);
|
|
33
33
|
}, msUntil2am);
|
|
34
34
|
}
|
package/dist/state.js
CHANGED
|
@@ -2,47 +2,54 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
const CONFIG_DIR = join(homedir(), ".config", "discord-mcp");
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
function stateFilePath(account) {
|
|
6
|
+
return account === "default"
|
|
7
|
+
? join(CONFIG_DIR, "state.json")
|
|
8
|
+
: join(CONFIG_DIR, account, "state.json");
|
|
9
|
+
}
|
|
10
|
+
function load(account) {
|
|
11
|
+
const stateFile = stateFilePath(account);
|
|
12
|
+
if (!existsSync(stateFile))
|
|
8
13
|
return {};
|
|
9
14
|
try {
|
|
10
|
-
return JSON.parse(readFileSync(
|
|
15
|
+
return JSON.parse(readFileSync(stateFile, "utf8"));
|
|
11
16
|
}
|
|
12
17
|
catch {
|
|
13
18
|
return {};
|
|
14
19
|
}
|
|
15
20
|
}
|
|
16
|
-
function save(state) {
|
|
21
|
+
function save(state, account) {
|
|
17
22
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
|
-
|
|
23
|
+
if (account !== "default")
|
|
24
|
+
mkdirSync(join(CONFIG_DIR, account), { recursive: true, mode: 0o700 });
|
|
25
|
+
writeFileSync(stateFilePath(account), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
19
26
|
}
|
|
20
|
-
export function getChannelMode(channelId) {
|
|
21
|
-
const state = load();
|
|
27
|
+
export function getChannelMode(channelId, account = "default") {
|
|
28
|
+
const state = load(account);
|
|
22
29
|
const s = state[channelId] ?? { mode: "review" };
|
|
23
30
|
// Auto-expire
|
|
24
31
|
if (s.expiresAt && Date.now() > s.expiresAt) {
|
|
25
32
|
state[channelId] = { ...s, mode: "review", expiresAt: undefined };
|
|
26
|
-
save(state);
|
|
33
|
+
save(state, account);
|
|
27
34
|
return "review";
|
|
28
35
|
}
|
|
29
36
|
return s.mode;
|
|
30
37
|
}
|
|
31
|
-
export function setChannelMode(channelId, mode, durationMs) {
|
|
32
|
-
const state = load();
|
|
38
|
+
export function setChannelMode(channelId, mode, durationMs, account = "default") {
|
|
39
|
+
const state = load(account);
|
|
33
40
|
state[channelId] = {
|
|
34
41
|
...(state[channelId] ?? {}),
|
|
35
42
|
mode,
|
|
36
43
|
expiresAt: durationMs ? Date.now() + durationMs : undefined,
|
|
37
44
|
};
|
|
38
|
-
save(state);
|
|
45
|
+
save(state, account);
|
|
39
46
|
}
|
|
40
|
-
export function markSeen(channelId, timestamp) {
|
|
41
|
-
const state = load();
|
|
47
|
+
export function markSeen(channelId, timestamp, account = "default") {
|
|
48
|
+
const state = load(account);
|
|
42
49
|
state[channelId] = { ...(state[channelId] ?? { mode: "review" }), lastSeen: timestamp };
|
|
43
|
-
save(state);
|
|
50
|
+
save(state, account);
|
|
44
51
|
}
|
|
45
|
-
export function getLastSeen(channelId) {
|
|
46
|
-
const state = load();
|
|
52
|
+
export function getLastSeen(channelId, account = "default") {
|
|
53
|
+
const state = load(account);
|
|
47
54
|
return state[channelId]?.lastSeen ?? 0;
|
|
48
55
|
}
|
package/dist/tools/get_dms.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getToken } from "../auth.js";
|
|
2
2
|
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
-
export async function getDMChannels() {
|
|
4
|
-
const token = await getToken();
|
|
3
|
+
export async function getDMChannels(account = "default") {
|
|
4
|
+
const token = await getToken(account);
|
|
5
5
|
const res = await rateLimitedFetch("https://discord.com/api/v10/users/@me/channels", { headers: makeDiscordHeaders(token) });
|
|
6
6
|
if (!res.ok)
|
|
7
7
|
throw new Error(`Discord API error: ${res.status}`);
|
|
@@ -4,16 +4,16 @@ import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
|
4
4
|
import { markSeen } from "../state.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
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) {
|
|
7
|
+
until, account = "default") {
|
|
8
8
|
const sinceMs = since ? new Date(since).getTime() : undefined;
|
|
9
9
|
const untilMs = until ? new Date(until).getTime() : undefined;
|
|
10
10
|
// ISC-D4: SQLite-first
|
|
11
|
-
const db = getDb();
|
|
11
|
+
const db = getDb(account);
|
|
12
12
|
if (db) {
|
|
13
13
|
const rows = queryMessages(db, channelId, limit, sinceMs, untilMs);
|
|
14
14
|
if (rows.length > 0) {
|
|
15
15
|
if (rows.length > 0)
|
|
16
|
-
markSeen(channelId, rows[0].timestamp);
|
|
16
|
+
markSeen(channelId, rows[0].timestamp, account);
|
|
17
17
|
return rows.map(r => ({
|
|
18
18
|
id: r.id,
|
|
19
19
|
author: r.author_name,
|
|
@@ -23,19 +23,19 @@ until) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
// ISC-D4: REST fallback on miss
|
|
26
|
-
const config = loadConfig();
|
|
26
|
+
const config = loadConfig(account);
|
|
27
27
|
if (!config.retention.fallback_to_api) {
|
|
28
28
|
return []; // privacy mode — no REST fallback
|
|
29
29
|
}
|
|
30
|
-
const token = await getToken();
|
|
31
|
-
|
|
30
|
+
const token = await getToken(account);
|
|
31
|
+
const url = `https://discord.com/api/v10/channels/${channelId}/messages?limit=${Math.min(limit, 100)}`;
|
|
32
32
|
const res = await rateLimitedFetch(url, { headers: makeDiscordHeaders(token) });
|
|
33
33
|
if (!res.ok)
|
|
34
34
|
throw new Error(`Discord API error: ${res.status}`);
|
|
35
35
|
const messages = await res.json();
|
|
36
36
|
if (messages.length > 0) {
|
|
37
37
|
const latestTs = new Date(messages[0].timestamp).getTime();
|
|
38
|
-
markSeen(channelId, latestTs);
|
|
38
|
+
markSeen(channelId, latestTs, account);
|
|
39
39
|
}
|
|
40
40
|
return messages.map(m => ({
|
|
41
41
|
id: m.id,
|
package/dist/tools/get_unread.js
CHANGED
|
@@ -3,9 +3,9 @@ import { getToken } from "../auth.js";
|
|
|
3
3
|
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
4
4
|
import { getLastSeen, markSeen } from "../state.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
|
-
export async function getUnread(channels) {
|
|
6
|
+
export async function getUnread(channels, account = "default") {
|
|
7
7
|
// ISC-D4: SQLite-first path
|
|
8
|
-
const db = getDb();
|
|
8
|
+
const db = getDb(account);
|
|
9
9
|
if (db) {
|
|
10
10
|
const unreadRows = queryUnread(db);
|
|
11
11
|
if (unreadRows.length > 0) {
|
|
@@ -34,13 +34,13 @@ export async function getUnread(channels) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
// REST fallback (daemon not running or no unread in SQLite)
|
|
37
|
-
const config = loadConfig();
|
|
37
|
+
const config = loadConfig(account);
|
|
38
38
|
if (!config.retention.fallback_to_api)
|
|
39
39
|
return [];
|
|
40
|
-
const token = await getToken();
|
|
40
|
+
const token = await getToken(account);
|
|
41
41
|
const results = [];
|
|
42
42
|
for (const ch of channels) {
|
|
43
|
-
const lastSeen = getLastSeen(ch.channelId);
|
|
43
|
+
const lastSeen = getLastSeen(ch.channelId, account);
|
|
44
44
|
const res = await rateLimitedFetch(`https://discord.com/api/v10/channels/${ch.channelId}/messages?limit=25`, { headers: makeDiscordHeaders(token) });
|
|
45
45
|
if (!res.ok)
|
|
46
46
|
continue;
|
|
@@ -49,7 +49,7 @@ export async function getUnread(channels) {
|
|
|
49
49
|
.filter(m => !m.author.bot && new Date(m.timestamp).getTime() > lastSeen)
|
|
50
50
|
.reverse();
|
|
51
51
|
if (unread.length > 0) {
|
|
52
|
-
markSeen(ch.channelId, new Date(messages[0].timestamp).getTime());
|
|
52
|
+
markSeen(ch.channelId, new Date(messages[0].timestamp).getTime(), account);
|
|
53
53
|
results.push({
|
|
54
54
|
guild: ch.guildName,
|
|
55
55
|
channel: ch.channelName,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getToken } from "../auth.js";
|
|
2
2
|
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
-
export async function listChannels(guildId) {
|
|
4
|
-
const token = await getToken();
|
|
3
|
+
export async function listChannels(guildId, account = "default") {
|
|
4
|
+
const token = await getToken(account);
|
|
5
5
|
const res = await rateLimitedFetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, { headers: makeDiscordHeaders(token) });
|
|
6
6
|
if (!res.ok)
|
|
7
7
|
throw new Error(`Discord API error: ${res.status}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getToken } from "../auth.js";
|
|
2
2
|
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
-
export async function listGuilds() {
|
|
4
|
-
const token = await getToken();
|
|
3
|
+
export async function listGuilds(account = "default") {
|
|
4
|
+
const token = await getToken(account);
|
|
5
5
|
const res = await rateLimitedFetch("https://discord.com/api/v10/users/@me/guilds", { headers: makeDiscordHeaders(token) });
|
|
6
6
|
if (!res.ok)
|
|
7
7
|
throw new Error(`Discord API error: ${res.status}`);
|
package/dist/tools/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDb, searchMessages } from "../db.js";
|
|
2
|
-
export async function searchDiscord(query, channelId, since, until, limit = 50) {
|
|
3
|
-
const db = getDb();
|
|
2
|
+
export async function searchDiscord(query, channelId, since, until, limit = 50, account = "default") {
|
|
3
|
+
const db = getDb(account);
|
|
4
4
|
if (!db) {
|
|
5
5
|
throw new Error("Daemon not running — no local message history to search. Start discord-mcp daemon first.");
|
|
6
6
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getToken } from "../auth.js";
|
|
2
2
|
import { makeDiscordHeaders, rateLimitedFetch } from "../ratelimit.js";
|
|
3
|
-
export async function sendMessage(opts) {
|
|
4
|
-
const token = await getToken();
|
|
3
|
+
export async function sendMessage(opts, account = "default") {
|
|
4
|
+
const token = await getToken(account);
|
|
5
5
|
let targetChannelId = opts.channelId;
|
|
6
6
|
// If userId given, open DM channel first
|
|
7
7
|
if (!targetChannelId && opts.userId) {
|