dis-gravity 1.1.0 → 1.2.5
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/.gitlab-ci.yml +9 -0
- package/.mcpbignore +20 -0
- package/README.md +471 -313
- package/SECURITY.md +105 -0
- package/bin/dis-gravity.js +1199 -702
- package/changelog.md +98 -0
- package/dist/extension.js +5630 -3610
- package/lib/core.js +1712 -0
- package/lib/onboarding.js +512 -0
- package/manifest.json +115 -0
- package/mcp/server.js +2174 -0
- package/media/mcp-icon.png +0 -0
- package/package.json +71 -16
- package/scripts/package-mcpb.js +29 -0
package/mcp/server.js
ADDED
|
@@ -0,0 +1,2174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dis-Gravity Model Context Protocol (MCP) Server
|
|
6
|
+
* Stdio JSON-RPC 2.0 server for Claude Desktop, Claude Code, and MCP-compliant AI desktop clients.
|
|
7
|
+
* Packaged as .mcpb (MCP Bundle) and .dxt (Desktop Extension).
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Zero external dependencies: Native Node 18+/24 Discord Gateway v10 WebSocket + REST + Multipart client
|
|
11
|
+
* - Concurrent Discord Gateway WebSocket execution in Node event loop
|
|
12
|
+
* - Full Discord slash commands (/sh, /diff, /cat, /tree, /info, /status, /help)
|
|
13
|
+
* - 2x Retina PNG Table Card rendering via OS headless browser with zero native dependencies
|
|
14
|
+
* - Bi-directional AI agent bridge: MCP Tools + MCP Resources
|
|
15
|
+
* - Fail-closed whitelist security (isUserAllowed)
|
|
16
|
+
* - Path traversal protection on workspace file inspection
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const readline = require('readline');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const https = require('https');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const {
|
|
26
|
+
loadConfig,
|
|
27
|
+
saveConfig,
|
|
28
|
+
looksLikeBotToken,
|
|
29
|
+
inspectApplication,
|
|
30
|
+
ensureMessageContentIntent,
|
|
31
|
+
listUserGuilds,
|
|
32
|
+
waitForGuildJoin,
|
|
33
|
+
findOrCreateChannel,
|
|
34
|
+
sendWelcomeMessage,
|
|
35
|
+
openBrowser,
|
|
36
|
+
buildInviteUrl,
|
|
37
|
+
discordRequest,
|
|
38
|
+
discordMultipartRequest
|
|
39
|
+
} = require('../lib/onboarding');
|
|
40
|
+
const {
|
|
41
|
+
isUserAllowed,
|
|
42
|
+
executeShellCommand,
|
|
43
|
+
findHeadlessBrowser,
|
|
44
|
+
escapeHtml,
|
|
45
|
+
buildTableHtml,
|
|
46
|
+
renderTableToPng,
|
|
47
|
+
parseMarkdownTable,
|
|
48
|
+
safeResolveWorkspacePath,
|
|
49
|
+
readWorkspaceFile,
|
|
50
|
+
listWorkspaceTree,
|
|
51
|
+
getGitStatusAndDiff,
|
|
52
|
+
getSystemHealth,
|
|
53
|
+
parseScheduleSpec,
|
|
54
|
+
parseScheduleCommandInput,
|
|
55
|
+
TaskScheduler,
|
|
56
|
+
ThreadWorkspaceManager,
|
|
57
|
+
isAudioAttachment,
|
|
58
|
+
transcribeAudioFile,
|
|
59
|
+
buildDiffHtml,
|
|
60
|
+
renderDiffToPng,
|
|
61
|
+
detectDestructiveKeywords,
|
|
62
|
+
captureWorkspaceCheckpoint,
|
|
63
|
+
restoreWorkspaceCheckpoint
|
|
64
|
+
} = require('../lib/core');
|
|
65
|
+
|
|
66
|
+
const SERVER_VERSION = '1.2.3';
|
|
67
|
+
const SUPPORTED_PROTOCOL_VERSIONS = [
|
|
68
|
+
'2025-11-25',
|
|
69
|
+
'2025-06-18',
|
|
70
|
+
'2025-03-26',
|
|
71
|
+
'2024-11-05',
|
|
72
|
+
'2024-10-07'
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// All diagnostic logging MUST go to stderr to keep stdout clean for JSON-RPC messages
|
|
76
|
+
const log = (...args) => console.error('[Dis-Gravity MCP]', ...args);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sanitizes environment variable strings and strips unresolved ${user_config.*} placeholders
|
|
80
|
+
*/
|
|
81
|
+
function cleanEnvValue(val) {
|
|
82
|
+
if (!val || typeof val !== 'string') return '';
|
|
83
|
+
const trimmed = val.trim().replace(/^['"]|['"]$/g, '').trim();
|
|
84
|
+
if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return '';
|
|
85
|
+
if (/^\$\{[^}]*\}$/.test(trimmed)) return '';
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanBotToken(val) {
|
|
90
|
+
return cleanEnvValue(val).replace(/^Bot\s+/i, '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanChannelId(val) {
|
|
94
|
+
return cleanEnvValue(val).replace(/^[<#]+|[>]+$/g, '').trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseAllowedUsersList(rawEnv, savedList) {
|
|
98
|
+
const cleanedEnv = cleanEnvValue(rawEnv);
|
|
99
|
+
if (cleanedEnv) {
|
|
100
|
+
return cleanedEnv
|
|
101
|
+
.split(/[\s,]+/)
|
|
102
|
+
.map(s => s.replace(/^[<@!]+|[>]+$/g, '').trim())
|
|
103
|
+
.filter(s => Boolean(s) && !/^\$\{[^}]*\}$/.test(s));
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(savedList)) {
|
|
106
|
+
return savedList
|
|
107
|
+
.map(s => String(s).replace(/^[<@!]+|[>]+$/g, '').trim())
|
|
108
|
+
.filter(s => Boolean(s) && !/^\$\{[^}]*\}$/.test(s));
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveWorkspaceDir(rawWorkspace) {
|
|
114
|
+
const cleaned = cleanEnvValue(rawWorkspace);
|
|
115
|
+
if (cleaned) {
|
|
116
|
+
return path.resolve(cleaned);
|
|
117
|
+
}
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
// When launched by Claude Desktop on Windows/macOS without a workspace, cwd may be the app binary dir
|
|
120
|
+
if (/WindowsApps|System32|\/Applications\/Claude\.app/i.test(cwd)) {
|
|
121
|
+
return os.homedir();
|
|
122
|
+
}
|
|
123
|
+
return path.resolve(cwd);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Load configuration with precedence: sanitized environment variables > ~/.dis-gravity/config.json
|
|
127
|
+
const saved = loadConfig();
|
|
128
|
+
const config = {
|
|
129
|
+
token: cleanBotToken(process.env.DISCORD_BOT_TOKEN) || cleanBotToken(saved.token),
|
|
130
|
+
channelId: cleanChannelId(process.env.DISCORD_CHANNEL_ID) || cleanChannelId(saved.channelId),
|
|
131
|
+
guildId: cleanChannelId(process.env.DISCORD_GUILD_ID) || cleanChannelId(saved.guildId),
|
|
132
|
+
workspace: resolveWorkspaceDir(process.env.DISCORD_WORKSPACE || saved.workspace),
|
|
133
|
+
allowedUsers: parseAllowedUsersList(process.env.DISCORD_ALLOWED_USERS, saved.allowedUsers)
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
let discordClient = null;
|
|
137
|
+
let clientReady = false;
|
|
138
|
+
let discordInitStarted = false;
|
|
139
|
+
|
|
140
|
+
// Pending button interaction waiters for ask_discord_approval
|
|
141
|
+
const pendingComponentWaiters = new Map();
|
|
142
|
+
|
|
143
|
+
// Queue of recent incoming messages from Discord for inbound Claude prompt bridging
|
|
144
|
+
const incomingMessages = [];
|
|
145
|
+
const MAX_INCOMING_MESSAGES = 50;
|
|
146
|
+
|
|
147
|
+
// Thread-per-Workspace Manager & Cron/Interval Scheduler
|
|
148
|
+
const threadWorkspaces = new ThreadWorkspaceManager({
|
|
149
|
+
defaultWorkspace: config.workspace,
|
|
150
|
+
storageFile: path.join(os.homedir(), '.dis-gravity', 'threads.json')
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function resolveMcpThreadWorkspace(channelId) {
|
|
154
|
+
return threadWorkspaces.getThreadWorkspace(channelId, config.workspace);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scheduler = new TaskScheduler({
|
|
158
|
+
onTrigger: async (job) => {
|
|
159
|
+
const targetChannelId = job.threadId || job.channelId || config.channelId;
|
|
160
|
+
const jobWorkspace = job.workspacePath || resolveMcpThreadWorkspace(targetChannelId);
|
|
161
|
+
const trimmed = (job.prompt || '').trim();
|
|
162
|
+
|
|
163
|
+
if (trimmed.startsWith('!sh ') || trimmed.startsWith('/sh ')) {
|
|
164
|
+
const cmdStr = trimmed.substring(4).trim();
|
|
165
|
+
const res = await executeShellCommand(cmdStr, jobWorkspace);
|
|
166
|
+
const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)**` : `❌ **Exit ${res.code} (${res.duration}s)**`;
|
|
167
|
+
const out = (res.stdout || res.stderr || '(No output)').trim();
|
|
168
|
+
if (config.token && targetChannelId) {
|
|
169
|
+
await discordRequest(config.token, 'POST', `/channels/${targetChannelId}/messages`, {
|
|
170
|
+
content: `⏰ **Scheduled Command (\`#${job.id}\` — ${job.description}):** \`${cmdStr}\`\n${badge}\n\`\`\`text\n${out.substring(0, 1750)}\n\`\`\``
|
|
171
|
+
}).catch(() => { });
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
recordIncomingMessage({
|
|
175
|
+
id: `sched_${job.id}_${Date.now()}`,
|
|
176
|
+
author: { username: 'scheduler', discriminator: '0', id: job.userId || 'scheduler' },
|
|
177
|
+
content: trimmed,
|
|
178
|
+
channel_id: targetChannelId,
|
|
179
|
+
workspace: jobWorkspace
|
|
180
|
+
});
|
|
181
|
+
if (config.token && targetChannelId) {
|
|
182
|
+
await discordRequest(config.token, 'POST', `/channels/${targetChannelId}/messages`, {
|
|
183
|
+
content: `⏰ **Scheduled Prompt Queued (\`#${job.id}\` — ${job.description}):**\n> ${trimmed.substring(0, 1800)}`
|
|
184
|
+
}).catch(() => { });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function downloadDiscordAttachmentToTemp(url, filename) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
try {
|
|
193
|
+
const parsed = new URL(url);
|
|
194
|
+
if (parsed.protocol !== 'https:' || !['cdn.discordapp.com', 'media.discordapp.net'].includes(parsed.hostname)) {
|
|
195
|
+
return resolve(null);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
return resolve(null);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const safeName = path.basename(filename || 'voice.ogg').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
202
|
+
const tempFile = path.join(os.tmpdir(), `dg_mcp_voice_${Date.now()}_${safeName}`);
|
|
203
|
+
const fileStream = fs.createWriteStream(tempFile);
|
|
204
|
+
let totalBytes = 0;
|
|
205
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
206
|
+
|
|
207
|
+
const req = https.get(url, (res) => {
|
|
208
|
+
if (res.statusCode !== 200) {
|
|
209
|
+
fileStream.close();
|
|
210
|
+
try { fs.unlinkSync(tempFile); } catch { }
|
|
211
|
+
return resolve(null);
|
|
212
|
+
}
|
|
213
|
+
res.on('data', (chunk) => {
|
|
214
|
+
totalBytes += chunk.length;
|
|
215
|
+
if (totalBytes > MAX_BYTES) {
|
|
216
|
+
req.destroy();
|
|
217
|
+
fileStream.close();
|
|
218
|
+
try { fs.unlinkSync(tempFile); } catch { }
|
|
219
|
+
resolve(null);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
res.pipe(fileStream);
|
|
223
|
+
fileStream.on('finish', () => {
|
|
224
|
+
fileStream.close(() => resolve(tempFile));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
req.on('error', () => {
|
|
229
|
+
fileStream.close();
|
|
230
|
+
try { fs.unlinkSync(tempFile); } catch { }
|
|
231
|
+
resolve(null);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function recordIncomingMessage(msg) {
|
|
237
|
+
const authorTag = msg.author
|
|
238
|
+
? (msg.author.tag || (msg.author.discriminator && msg.author.discriminator !== '0'
|
|
239
|
+
? `${msg.author.username}#${msg.author.discriminator}`
|
|
240
|
+
: msg.author.username))
|
|
241
|
+
: 'unknown';
|
|
242
|
+
|
|
243
|
+
incomingMessages.push({
|
|
244
|
+
id: msg.id,
|
|
245
|
+
author: authorTag,
|
|
246
|
+
authorId: msg.author ? msg.author.id : '',
|
|
247
|
+
content: msg.content,
|
|
248
|
+
channelId: msg.channel_id || config.channelId || '',
|
|
249
|
+
workspace: msg.workspace || resolveMcpThreadWorkspace(msg.channel_id),
|
|
250
|
+
timestamp: new Date().toISOString()
|
|
251
|
+
});
|
|
252
|
+
if (incomingMessages.length > MAX_INCOMING_MESSAGES) {
|
|
253
|
+
incomingMessages.shift();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Build status payload with interactive action buttons (Discord API v10 raw format, zero dependencies)
|
|
258
|
+
function getBridgeStatusPayload(customHeader = null) {
|
|
259
|
+
const health = getSystemHealth(config.workspace);
|
|
260
|
+
|
|
261
|
+
let msg = '';
|
|
262
|
+
if (customHeader) {
|
|
263
|
+
msg += `${customHeader}\n\n`;
|
|
264
|
+
}
|
|
265
|
+
msg += `📊 **Dis-Gravity Claude MCP Bridge Status**\n\n`;
|
|
266
|
+
msg += `• **Client:** 🤖 Claude Desktop / Claude Code MCP Engine\n`;
|
|
267
|
+
msg += `• **Project:** 📁 \`${path.basename(config.workspace)}\`\n`;
|
|
268
|
+
msg += `• **Workspace:** \`${config.workspace}\`\n`;
|
|
269
|
+
msg += `• **Connection:** ${clientReady ? '🟢 **Active Gateway WebSocket**' : '🟡 **REST Mode (Gateway Connecting...)**'}\n`;
|
|
270
|
+
msg += `• **Host:** 💻 ${health.platform} (${health.release}, ${health.arch})\n`;
|
|
271
|
+
msg += `• **Memory:** 📈 ${health.memory}\n`;
|
|
272
|
+
msg += `• **Node Runtime:** ${health.nodeVersion} (PID: ${health.pid})\n`;
|
|
273
|
+
|
|
274
|
+
if (!config.allowedUsers || config.allowedUsers.length === 0) {
|
|
275
|
+
msg += `\n> 🛡️ *Security active: No \`allowedUsers\` configured. Remote execution is BLOCKED until your Discord User ID is added.*`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const actionRow = {
|
|
279
|
+
type: 1, // ACTION_ROW
|
|
280
|
+
components: [
|
|
281
|
+
{
|
|
282
|
+
type: 2, // BUTTON
|
|
283
|
+
style: 1, // Primary
|
|
284
|
+
custom_id: 'mcp:status',
|
|
285
|
+
label: '🔄 Refresh'
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
type: 2, // BUTTON
|
|
289
|
+
style: 2, // Secondary
|
|
290
|
+
custom_id: 'mcp:diff',
|
|
291
|
+
label: '🌿 Git Diff'
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: 2, // BUTTON
|
|
295
|
+
style: 2, // Secondary
|
|
296
|
+
custom_id: 'mcp:info',
|
|
297
|
+
label: '🖥️ System Info'
|
|
298
|
+
}
|
|
299
|
+
]
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return { content: msg.substring(0, 1950), components: [actionRow] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Automatic frictionless onboarding bootstrap when token is present without channelId
|
|
306
|
+
async function autoBootstrapBridge(token) {
|
|
307
|
+
if (!looksLikeBotToken(token)) return false;
|
|
308
|
+
try {
|
|
309
|
+
log('Discord Bot Token detected without Channel ID. Initiating automatic frictionless onboarding...');
|
|
310
|
+
// 1. Ensure Message Content Intent
|
|
311
|
+
const intentRes = await ensureMessageContentIntent(token).catch(() => null);
|
|
312
|
+
if (intentRes?.autoEnabled) {
|
|
313
|
+
log('Automatically enabled Message Content Intent for bot application.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 2. Inspect application
|
|
317
|
+
const app = await inspectApplication(token);
|
|
318
|
+
log(`Identified Bot @${app.botTag} (App: ${app.appName})`);
|
|
319
|
+
|
|
320
|
+
// 3. Check existing guilds
|
|
321
|
+
const guilds = await listUserGuilds(token);
|
|
322
|
+
let targetGuild = null;
|
|
323
|
+
|
|
324
|
+
if (config.guildId) {
|
|
325
|
+
targetGuild = guilds.find(g => g.id === config.guildId) || { id: config.guildId };
|
|
326
|
+
} else if (guilds.length > 0) {
|
|
327
|
+
targetGuild = guilds[0];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!targetGuild) {
|
|
331
|
+
const inviteUrl = buildInviteUrl(app.appId);
|
|
332
|
+
log('Bot is not currently authorized in any Discord server.');
|
|
333
|
+
log(`Opening OAuth2 invite link in browser: ${inviteUrl}`);
|
|
334
|
+
if (!process.env.NODE_TEST_CONTEXT && !process.env.CI) {
|
|
335
|
+
try { openBrowser(inviteUrl); } catch { }
|
|
336
|
+
}
|
|
337
|
+
targetGuild = await waitForGuildJoin(token, { timeoutMs: 90000 }).catch(() => null);
|
|
338
|
+
if (!targetGuild) {
|
|
339
|
+
log(`Timed out waiting for server authorization. Authorize via: ${inviteUrl}`);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 4. Find or create private channel #dis-gravity
|
|
345
|
+
const channelRes = await findOrCreateChannel(token, targetGuild.id, {
|
|
346
|
+
channelName: 'dis-gravity',
|
|
347
|
+
botId: app.botId,
|
|
348
|
+
ownerId: app.ownerId
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
config.channelId = channelRes.channel.id;
|
|
352
|
+
config.guildId = targetGuild.id;
|
|
353
|
+
|
|
354
|
+
// 5. Auto-whitelist owner if not configured
|
|
355
|
+
if ((!config.allowedUsers || config.allowedUsers.length === 0) && app.ownerId) {
|
|
356
|
+
config.allowedUsers = [app.ownerId];
|
|
357
|
+
log(`Auto-whitelisted application owner ID: ${app.ownerId}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 6. Save configuration to ~/.dis-gravity/config.json
|
|
361
|
+
saveConfig({
|
|
362
|
+
token,
|
|
363
|
+
channelId: config.channelId,
|
|
364
|
+
guildId: config.guildId,
|
|
365
|
+
allowedUsers: config.allowedUsers,
|
|
366
|
+
workspace: config.workspace
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
log(`Channel #${channelRes.channel.name} (${channelRes.channel.id}) configured in guild ${targetGuild.name || targetGuild.id}.`);
|
|
370
|
+
|
|
371
|
+
// 7. Send welcome message
|
|
372
|
+
await sendWelcomeMessage(token, config.channelId, {
|
|
373
|
+
ownerIds: config.allowedUsers,
|
|
374
|
+
provider: 'Claude Desktop MCP'
|
|
375
|
+
}).catch(() => { });
|
|
376
|
+
|
|
377
|
+
return true;
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log(`Auto-bootstrap notice: ${err.message}`);
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Discord Interaction REST helpers
|
|
385
|
+
async function replyToInteraction(interaction, data) {
|
|
386
|
+
return discordRequest(
|
|
387
|
+
config.token,
|
|
388
|
+
'POST',
|
|
389
|
+
`/interactions/${interaction.id}/${interaction.token}/callback`,
|
|
390
|
+
{ type: 4, data }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function deferInteractionReply(interaction, ephemeral = false) {
|
|
395
|
+
return discordRequest(
|
|
396
|
+
config.token,
|
|
397
|
+
'POST',
|
|
398
|
+
`/interactions/${interaction.id}/${interaction.token}/callback`,
|
|
399
|
+
{ type: 5, data: ephemeral ? { flags: 64 } : {} }
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function deferInteractionUpdate(interaction) {
|
|
404
|
+
return discordRequest(
|
|
405
|
+
config.token,
|
|
406
|
+
'POST',
|
|
407
|
+
`/interactions/${interaction.id}/${interaction.token}/callback`,
|
|
408
|
+
{ type: 6 }
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function updateInteractionMessage(interaction, data) {
|
|
413
|
+
return discordRequest(
|
|
414
|
+
config.token,
|
|
415
|
+
'POST',
|
|
416
|
+
`/interactions/${interaction.id}/${interaction.token}/callback`,
|
|
417
|
+
{ type: 7, data }
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function editInteractionOriginal(interaction, payload, files = []) {
|
|
422
|
+
const appId = interaction.application_id || discordClient?.applicationId;
|
|
423
|
+
const body = typeof payload === 'string' ? { content: payload } : payload;
|
|
424
|
+
if (files && files.length > 0) {
|
|
425
|
+
return discordMultipartRequest(
|
|
426
|
+
config.token,
|
|
427
|
+
'PATCH',
|
|
428
|
+
`/webhooks/${appId}/${interaction.token}/messages/@original`,
|
|
429
|
+
body,
|
|
430
|
+
files
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return discordRequest(
|
|
434
|
+
config.token,
|
|
435
|
+
'PATCH',
|
|
436
|
+
`/webhooks/${appId}/${interaction.token}/messages/@original`,
|
|
437
|
+
body
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function getInteractionUser(interaction) {
|
|
442
|
+
const u = interaction.member?.user || interaction.user || {};
|
|
443
|
+
const tag = u.discriminator && u.discriminator !== '0'
|
|
444
|
+
? `${u.username}#${u.discriminator}`
|
|
445
|
+
: (u.username || 'unknown');
|
|
446
|
+
return { id: u.id || '', username: u.username || 'unknown', tag };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getSlashOptionString(interaction, optionName) {
|
|
450
|
+
const opts = interaction.data?.options;
|
|
451
|
+
if (!Array.isArray(opts)) return '';
|
|
452
|
+
const found = opts.find(o => o.name === optionName);
|
|
453
|
+
return found && found.value !== undefined ? String(found.value) : '';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Handle Discord Gateway INTERACTION_CREATE events
|
|
457
|
+
async function handleGatewayInteraction(interaction) {
|
|
458
|
+
try {
|
|
459
|
+
const user = getInteractionUser(interaction);
|
|
460
|
+
const isButton = interaction.type === 3;
|
|
461
|
+
const isChatInput = interaction.type === 2;
|
|
462
|
+
const customId = interaction.data?.custom_id || '';
|
|
463
|
+
|
|
464
|
+
// Check if this is an approval button click with an active waiter
|
|
465
|
+
if (isButton && (customId.startsWith('claude_allow_') || customId.startsWith('claude_deny_'))) {
|
|
466
|
+
for (const [waiterId, waiter] of pendingComponentWaiters.entries()) {
|
|
467
|
+
if (customId === `claude_allow_${waiter.actionId}` || customId === `claude_deny_${waiter.actionId}`) {
|
|
468
|
+
if (waiter.messageId && interaction.message?.id && interaction.message.id !== waiter.messageId) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!isUserAllowed(user.id, config.allowedUsers)) {
|
|
472
|
+
const hasWhitelist = config.allowedUsers && config.allowedUsers.length > 0;
|
|
473
|
+
const denial = hasWhitelist
|
|
474
|
+
? '⛔ **Unauthorized:** Your Discord User ID is not whitelisted in Dis-Gravity.'
|
|
475
|
+
: '⛔ **Unauthorized:** No `allowedUsers` configured in Dis-Gravity. Approval is blocked by default.';
|
|
476
|
+
await replyToInteraction(interaction, {
|
|
477
|
+
content: denial,
|
|
478
|
+
flags: 64
|
|
479
|
+
}).catch(() => { });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
pendingComponentWaiters.delete(waiterId);
|
|
483
|
+
waiter.resolve({ interaction, user, customId });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Fail-closed security check for all other slash commands and buttons
|
|
490
|
+
if (!isUserAllowed(user.id, config.allowedUsers)) {
|
|
491
|
+
const hasWhitelist = config.allowedUsers && config.allowedUsers.length > 0;
|
|
492
|
+
const denial = hasWhitelist
|
|
493
|
+
? `⛔ **Unauthorized:** Your Discord User ID (\`${user.id}\`) is not in the Dis-Gravity whitelist.`
|
|
494
|
+
: `⛔ **Unauthorized:** No \`allowedUsers\` configured. Remote execution is blocked by default. Configure your Discord User ID in Claude Settings -> Extensions -> Dis-Gravity.`;
|
|
495
|
+
await replyToInteraction(interaction, { content: denial, flags: 64 }).catch(() => { });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Handle Interactive Dashboard Buttons
|
|
500
|
+
if (isButton) {
|
|
501
|
+
const targetWorkspace = resolveMcpThreadWorkspace(interaction.channel_id);
|
|
502
|
+
if (customId === 'mcp:status') {
|
|
503
|
+
await deferInteractionUpdate(interaction).catch(() => { });
|
|
504
|
+
const payload = getBridgeStatusPayload();
|
|
505
|
+
await editInteractionOriginal(interaction, payload).catch(() => { });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (customId === 'mcp:diff') {
|
|
509
|
+
await deferInteractionReply(interaction, true).catch(() => { });
|
|
510
|
+
const gitInfo = await getGitStatusAndDiff(targetWorkspace);
|
|
511
|
+
let reply = `🌿 **Git Status & Diff Preview (\`${path.basename(targetWorkspace)}\`)**\n\n\`\`\`diff\n${gitInfo.status}\n\`\`\`\n`;
|
|
512
|
+
if (gitInfo.stat) reply += `**Diff Stat:**\n\`\`\`text\n${gitInfo.stat}\n\`\`\``;
|
|
513
|
+
await editInteractionOriginal(interaction, reply).catch(() => { });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (customId === 'mcp:info') {
|
|
517
|
+
await deferInteractionReply(interaction, true).catch(() => { });
|
|
518
|
+
const health = getSystemHealth(targetWorkspace);
|
|
519
|
+
const gitInfo = await getGitStatusAndDiff(targetWorkspace);
|
|
520
|
+
let msg = `🖥️ **Dis-Gravity Host & System Info**\n\n`;
|
|
521
|
+
msg += `• **Project:** \`${path.basename(targetWorkspace)}\`\n`;
|
|
522
|
+
msg += `• **Workspace:** \`${targetWorkspace}\`\n`;
|
|
523
|
+
msg += `• **Branch:** \`${gitInfo.branch}\`\n`;
|
|
524
|
+
msg += `• **System:** ${health.platform} (${health.release}, ${health.arch})\n`;
|
|
525
|
+
msg += `• **CPU:** ${health.cpu}\n`;
|
|
526
|
+
msg += `• **Memory:** ${health.memory}\n`;
|
|
527
|
+
msg += `• **Engine:** Claude Desktop MCP Server (PID: ${health.pid})\n`;
|
|
528
|
+
await editInteractionOriginal(interaction, msg).catch(() => { });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (customId.startsWith('schedule:cancel:')) {
|
|
532
|
+
const jobId = customId.replace('schedule:cancel:', '').trim();
|
|
533
|
+
const cancelled = scheduler.cancelJob(jobId);
|
|
534
|
+
await updateInteractionMessage(interaction, {
|
|
535
|
+
content: cancelled ? `🛑 **Schedule \`#${jobId}\` Cancelled.**` : `⚠️ Schedule \`#${jobId}\` is no longer active.`,
|
|
536
|
+
components: []
|
|
537
|
+
}).catch(() => { });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (customId === 'schedule:cancel_all') {
|
|
541
|
+
const count = scheduler.cancelAll();
|
|
542
|
+
await updateInteractionMessage(interaction, {
|
|
543
|
+
content: `🛑 **Cancelled ${count} scheduled task(s).**`,
|
|
544
|
+
components: []
|
|
545
|
+
}).catch(() => { });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Handle Chat Input Slash Commands
|
|
551
|
+
if (isChatInput) {
|
|
552
|
+
const cmd = interaction.data?.name;
|
|
553
|
+
const targetWorkspace = resolveMcpThreadWorkspace(interaction.channel_id);
|
|
554
|
+
|
|
555
|
+
if (cmd === 'sh') {
|
|
556
|
+
const commandStr = getSlashOptionString(interaction, 'command');
|
|
557
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
558
|
+
const res = await executeShellCommand(commandStr, targetWorkspace);
|
|
559
|
+
const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)**` : `❌ **Exit ${res.code} (${res.duration}s)**`;
|
|
560
|
+
const out = (res.stdout || res.stderr || '(No output)').trim();
|
|
561
|
+
|
|
562
|
+
const parsedTable = parseMarkdownTable(out);
|
|
563
|
+
const files = [];
|
|
564
|
+
if (parsedTable) {
|
|
565
|
+
const pngBuf = await renderTableToPng(
|
|
566
|
+
parsedTable.headers,
|
|
567
|
+
parsedTable.rows,
|
|
568
|
+
parsedTable.alignments,
|
|
569
|
+
`Output: ${commandStr.substring(0, 30)}`
|
|
570
|
+
);
|
|
571
|
+
if (pngBuf) {
|
|
572
|
+
files.push({ name: 'table-output.png', buffer: pngBuf });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await editInteractionOriginal(
|
|
577
|
+
interaction,
|
|
578
|
+
{ content: `${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\`` },
|
|
579
|
+
files
|
|
580
|
+
).catch(() => { });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (cmd === 'diff') {
|
|
585
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
586
|
+
const gitInfo = await getGitStatusAndDiff(targetWorkspace);
|
|
587
|
+
let reply = `🌿 **Git Status & Diff Preview (\`${path.basename(targetWorkspace)}\`)**\n\n`;
|
|
588
|
+
reply += `\`\`\`diff\n${gitInfo.status}\n\`\`\`\n`;
|
|
589
|
+
if (gitInfo.stat && gitInfo.stat !== 'No changes') {
|
|
590
|
+
reply += `**Diff Stat:**\n\`\`\`text\n${gitInfo.stat}\n\`\`\``;
|
|
591
|
+
}
|
|
592
|
+
await editInteractionOriginal(interaction, reply).catch(() => { });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (cmd === 'cat') {
|
|
597
|
+
const file = getSlashOptionString(interaction, 'file');
|
|
598
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
599
|
+
const res = readWorkspaceFile(targetWorkspace, file);
|
|
600
|
+
if (res.error) {
|
|
601
|
+
await editInteractionOriginal(interaction, `❌ **Could not read file:** ${res.error}`).catch(() => { });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const ext = path.extname(file).replace('.', '') || 'text';
|
|
605
|
+
const truncNotice = res.truncated ? `\n*(Truncated from ${res.size} bytes)*` : '';
|
|
606
|
+
await editInteractionOriginal(
|
|
607
|
+
interaction,
|
|
608
|
+
`📄 **\`${file}\`**${truncNotice}\n\`\`\`${ext}\n${res.content.substring(0, 1800)}\n\`\`\``
|
|
609
|
+
).catch(() => { });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (cmd === 'tree') {
|
|
614
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
615
|
+
const tree = listWorkspaceTree(targetWorkspace, '.', 2, 80);
|
|
616
|
+
await editInteractionOriginal(
|
|
617
|
+
interaction,
|
|
618
|
+
`📁 **Workspace Tree (\`${path.basename(targetWorkspace)}\`):**\n\`\`\`text\n${tree.substring(0, 1850)}\n\`\`\``
|
|
619
|
+
).catch(() => { });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (cmd === 'info') {
|
|
624
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
625
|
+
const health = getSystemHealth(targetWorkspace);
|
|
626
|
+
const gitInfo = await getGitStatusAndDiff(targetWorkspace);
|
|
627
|
+
let msg = `🖥️ **Dis-Gravity Host & Workspace Health**\n\n`;
|
|
628
|
+
msg += `• **Project:** \`${path.basename(targetWorkspace)}\`\n`;
|
|
629
|
+
msg += `• **Workspace:** \`${targetWorkspace}\`\n`;
|
|
630
|
+
msg += `• **Branch:** \`${gitInfo.branch}\`\n`;
|
|
631
|
+
msg += `• **System:** ${health.platform} (${health.release}, ${health.arch})\n`;
|
|
632
|
+
msg += `• **CPU:** ${health.cpu}\n`;
|
|
633
|
+
msg += `• **Memory:** ${health.memory}\n`;
|
|
634
|
+
msg += `• **Uptime:** ${Math.floor(health.uptimeSeconds / 60)} min\n`;
|
|
635
|
+
msg += `• **Engine:** Claude Desktop MCP Server (PID: ${health.pid})\n`;
|
|
636
|
+
await editInteractionOriginal(interaction, msg).catch(() => { });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (cmd === 'status') {
|
|
641
|
+
await deferInteractionReply(interaction).catch(() => { });
|
|
642
|
+
const payload = getBridgeStatusPayload();
|
|
643
|
+
await editInteractionOriginal(interaction, payload).catch(() => { });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (cmd === 'schedule') {
|
|
648
|
+
const specStr = getSlashOptionString(interaction, 'when').trim();
|
|
649
|
+
const promptStr = getSlashOptionString(interaction, 'prompt').trim();
|
|
650
|
+
|
|
651
|
+
if (!specStr || specStr.toLowerCase() === 'list') {
|
|
652
|
+
const jobs = scheduler.listJobs();
|
|
653
|
+
if (jobs.length === 0) {
|
|
654
|
+
await replyToInteraction(interaction, {
|
|
655
|
+
content: '⏰ **No active scheduled tasks.**\nUse `/schedule when:"15m" prompt:"!sh npm test"` or `/schedule when:"0 9 * * 1-5" prompt:"Summarize git status"` to schedule one.'
|
|
656
|
+
}).catch(() => { });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
let out = `⏰ **Active Scheduled Tasks (${jobs.length})**\n\n`;
|
|
660
|
+
for (const j of jobs) {
|
|
661
|
+
out += `• **\`#${j.id}\`** (${j.description}) — Next: \`${j.nextRunAt || 'N/A'}\` | Runs: ${j.runCount}\n Prompt: \`${j.prompt.substring(0, 80)}\`\n`;
|
|
662
|
+
}
|
|
663
|
+
const actionRow = {
|
|
664
|
+
type: 1,
|
|
665
|
+
components: [{ type: 2, style: 4, custom_id: 'schedule:cancel_all', label: '🛑 Cancel All' }]
|
|
666
|
+
};
|
|
667
|
+
await replyToInteraction(interaction, { content: out.substring(0, 1900), components: [actionRow] }).catch(() => { });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (specStr.toLowerCase().startsWith('cancel')) {
|
|
672
|
+
const targetId = specStr.replace(/^cancel\s*/i, '').trim() || promptStr;
|
|
673
|
+
if (!targetId || targetId === 'all') {
|
|
674
|
+
const count = scheduler.cancelAll();
|
|
675
|
+
await replyToInteraction(interaction, { content: `🛑 **Cancelled ${count} scheduled task(s).**` }).catch(() => { });
|
|
676
|
+
} else {
|
|
677
|
+
const ok = scheduler.cancelJob(targetId);
|
|
678
|
+
await replyToInteraction(interaction, { content: ok ? `🛑 **Cancelled schedule \`#${targetId}\`.**` : `❌ Schedule \`#${targetId}\` not found.` }).catch(() => { });
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (!promptStr) {
|
|
684
|
+
await replyToInteraction(interaction, { content: '⚠️ Please provide a `prompt` or command to run when the schedule triggers.', flags: 64 }).catch(() => { });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const res = scheduler.addJob({
|
|
689
|
+
spec: specStr,
|
|
690
|
+
prompt: promptStr,
|
|
691
|
+
channelId: interaction.channel_id || config.channelId,
|
|
692
|
+
workspacePath: targetWorkspace,
|
|
693
|
+
userId: user.id
|
|
694
|
+
});
|
|
695
|
+
if (!res.ok) {
|
|
696
|
+
await replyToInteraction(interaction, { content: `❌ **Schedule Error:** ${res.error}`, flags: 64 }).catch(() => { });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const job = res.job;
|
|
700
|
+
const row = {
|
|
701
|
+
type: 1,
|
|
702
|
+
components: [{ type: 2, style: 4, custom_id: `schedule:cancel:${job.id}`, label: `🛑 Cancel #${job.id}` }]
|
|
703
|
+
};
|
|
704
|
+
await replyToInteraction(interaction, {
|
|
705
|
+
content: `⏰ **Scheduled Task \`#${job.id}\` Created!**\n• **Schedule:** ${job.description}\n• **Next Run:** \`${job.nextRunAt}\`\n• **Workspace:** \`${job.workspacePath}\`\n• **Prompt:** \`${job.prompt}\``,
|
|
706
|
+
components: [row]
|
|
707
|
+
}).catch(() => { });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (cmd === 'bind') {
|
|
712
|
+
const wsPath = getSlashOptionString(interaction, 'workspace').trim();
|
|
713
|
+
if (!wsPath || wsPath.toLowerCase() === 'status') {
|
|
714
|
+
const binding = threadWorkspaces.getThreadBinding(interaction.channel_id);
|
|
715
|
+
if (!binding) {
|
|
716
|
+
await replyToInteraction(interaction, {
|
|
717
|
+
content: `🔗 **Current Thread/Channel Workspace:** \`${config.workspace}\` *(default)*\nUse \`/bind workspace:<path>\` inside any thread to bind it to a specific project directory.`
|
|
718
|
+
}).catch(() => { });
|
|
719
|
+
} else {
|
|
720
|
+
await replyToInteraction(interaction, {
|
|
721
|
+
content: `🔗 **Bound Workspace:** \`${binding.workspacePath}\`\n• **Bound At:** \`${binding.boundAt}\``
|
|
722
|
+
}).catch(() => { });
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (wsPath.toLowerCase() === 'unbind' || wsPath.toLowerCase() === 'reset') {
|
|
727
|
+
const removed = threadWorkspaces.unbindThread(interaction.channel_id);
|
|
728
|
+
await replyToInteraction(interaction, {
|
|
729
|
+
content: removed
|
|
730
|
+
? `🔓 **Unbound thread.** Reverted to default workspace (\`${config.workspace}\`).`
|
|
731
|
+
: `ℹ️ This thread was not bound to a custom workspace.`
|
|
732
|
+
}).catch(() => { });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const res = threadWorkspaces.bindThread(interaction.channel_id, wsPath, { userId: user.id });
|
|
736
|
+
if (!res.ok) {
|
|
737
|
+
await replyToInteraction(interaction, { content: `❌ **Bind Failed:** ${res.error}`, flags: 64 }).catch(() => { });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await replyToInteraction(interaction, {
|
|
741
|
+
content: `🔗 **Thread Bound to Workspace!**\n• **Channel/Thread:** <#${interaction.channel_id}>\n• **Workspace:** \`${res.binding.workspacePath}\`\nAll commands (\`/sh\`, \`/diff\`, \`/cat\`, \`/tree\`) in this thread now run isolated in this directory.`
|
|
742
|
+
}).catch(() => { });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (cmd === 'threads') {
|
|
747
|
+
const bindings = threadWorkspaces.listBindings();
|
|
748
|
+
if (bindings.length === 0) {
|
|
749
|
+
await replyToInteraction(interaction, {
|
|
750
|
+
content: `🧵 **No custom thread workspace bindings.**\nDefault workspace: \`${config.workspace}\`\nUse \`/bind workspace:<path>\` inside any thread to bind it to a dedicated project folder.`
|
|
751
|
+
}).catch(() => { });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
let out = `🧵 **Active Thread Workspace Bindings (${bindings.length})**\n\n`;
|
|
755
|
+
for (const b of bindings) {
|
|
756
|
+
out += `• <#${b.threadId}> → \`${b.workspacePath}\`${b.conversationName ? ` *(Session: ${b.conversationName})*` : ''}\n`;
|
|
757
|
+
}
|
|
758
|
+
await replyToInteraction(interaction, { content: out.substring(0, 1900) }).catch(() => { });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (cmd === 'help') {
|
|
763
|
+
let h = `🤖 **Dis-Gravity MCP Remote Control Commands**\n\n`;
|
|
764
|
+
h += `• \`/sh <cmd>\` — Run terminal command in workspace\n`;
|
|
765
|
+
h += `• \`/diff\` — View git status and unified diff preview\n`;
|
|
766
|
+
h += `• \`/cat <file>\` — Safely view workspace file contents\n`;
|
|
767
|
+
h += `• \`/tree\` — View workspace directory layout\n`;
|
|
768
|
+
h += `• \`/info\` — Host hardware telemetry and branch status\n`;
|
|
769
|
+
h += `• \`/status\` — View MCP bridge connection status\n`;
|
|
770
|
+
h += `• \`/schedule <when> [prompt]\` — Schedule one-shot or recurring cron tasks\n`;
|
|
771
|
+
h += `• \`/bind [workspace]\` — Bind a Discord thread to a dedicated project directory\n`;
|
|
772
|
+
h += `• \`/threads\` — List all thread-to-workspace bindings\n`;
|
|
773
|
+
h += `• 🎙️ **Voice Messages** — Send a Discord voice message to auto-transcribe & execute\n`;
|
|
774
|
+
await replyToInteraction(interaction, { content: h }).catch(() => { });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch (err) {
|
|
779
|
+
log('Interaction error:', err.message);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Reply to a channel message via Discord REST v10
|
|
784
|
+
async function replyToChannelMessage(message, payload) {
|
|
785
|
+
const body = typeof payload === 'string' ? { content: payload } : { ...payload };
|
|
786
|
+
body.message_reference = { message_id: message.id, fail_if_not_exists: false };
|
|
787
|
+
return discordRequest(config.token, 'POST', `/channels/${message.channel_id}/messages`, body);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Handle Discord Gateway MESSAGE_CREATE events
|
|
791
|
+
async function handleGatewayMessage(message) {
|
|
792
|
+
try {
|
|
793
|
+
if (!message || !message.author || message.author.bot) return;
|
|
794
|
+
const isDm = !message.guild_id;
|
|
795
|
+
const isBoundThread = Boolean(threadWorkspaces.getThreadBinding(message.channel_id));
|
|
796
|
+
if (message.channel_id !== config.channelId && !isDm && !isBoundThread) return;
|
|
797
|
+
|
|
798
|
+
if (!isUserAllowed(message.author.id, config.allowedUsers)) {
|
|
799
|
+
const hasWhitelist = config.allowedUsers && config.allowedUsers.length > 0;
|
|
800
|
+
const denial = hasWhitelist
|
|
801
|
+
? `⛔ **Unauthorized:** Your Discord User ID (\`${message.author.id}\`) is not on the Dis-Gravity whitelist.`
|
|
802
|
+
: `⛔ **Unauthorized:** No \`allowedUsers\` whitelist configured. Remote execution blocked by default.`;
|
|
803
|
+
await replyToChannelMessage(message, denial).catch(() => { });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const targetWorkspace = resolveMcpThreadWorkspace(message.channel_id);
|
|
808
|
+
let text = (message.content || '').trim();
|
|
809
|
+
|
|
810
|
+
// Auto-transcribe Discord voice messages / audio attachments
|
|
811
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
812
|
+
for (const att of message.attachments) {
|
|
813
|
+
if (isAudioAttachment(att, message.flags || 0)) {
|
|
814
|
+
const tempAudio = await downloadDiscordAttachmentToTemp(att.url, att.filename || 'voice.ogg');
|
|
815
|
+
if (tempAudio) {
|
|
816
|
+
try {
|
|
817
|
+
const stt = await transcribeAudioFile(tempAudio);
|
|
818
|
+
if (stt.ok && stt.text) {
|
|
819
|
+
text = text ? `${text}\n${stt.text}` : stt.text;
|
|
820
|
+
message.content = text;
|
|
821
|
+
await replyToChannelMessage(
|
|
822
|
+
message,
|
|
823
|
+
`🎙️ **Voice Prompt Transcribed (${stt.provider}):**\n> ${stt.text}`
|
|
824
|
+
).catch(() => { });
|
|
825
|
+
} else {
|
|
826
|
+
await replyToChannelMessage(
|
|
827
|
+
message,
|
|
828
|
+
`⚠️ **Voice Transcription Notice:** ${stt.error}`
|
|
829
|
+
).catch(() => { });
|
|
830
|
+
}
|
|
831
|
+
} finally {
|
|
832
|
+
try { fs.unlinkSync(tempAudio); } catch { }
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Record incoming message for Claude MCP inbound resource stream
|
|
840
|
+
recordIncomingMessage({ ...message, workspace: targetWorkspace });
|
|
841
|
+
|
|
842
|
+
if (text.startsWith('!sh ')) {
|
|
843
|
+
const cmd = text.substring(4).trim();
|
|
844
|
+
const res = await executeShellCommand(cmd, targetWorkspace);
|
|
845
|
+
const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)**` : `❌ **Exit ${res.code} (${res.duration}s)**`;
|
|
846
|
+
const out = (res.stdout || res.stderr || '(No output)').trim();
|
|
847
|
+
await replyToChannelMessage(message, `${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\``).catch(() => { });
|
|
848
|
+
} else if (text === '!diff') {
|
|
849
|
+
const gitInfo = await getGitStatusAndDiff(targetWorkspace);
|
|
850
|
+
let reply = `🌿 **Git Status & Diff Preview (\`${path.basename(targetWorkspace)}\`)**\n\n\`\`\`diff\n${gitInfo.status}\n\`\`\`\n`;
|
|
851
|
+
if (gitInfo.stat) reply += `**Diff Stat:**\n\`\`\`text\n${gitInfo.stat}\n\`\`\``;
|
|
852
|
+
await replyToChannelMessage(message, reply).catch(() => { });
|
|
853
|
+
} else if (text.startsWith('!cat ')) {
|
|
854
|
+
const file = text.substring(5).trim();
|
|
855
|
+
const res = readWorkspaceFile(targetWorkspace, file);
|
|
856
|
+
if (res.error) {
|
|
857
|
+
await replyToChannelMessage(message, `❌ **Could not read file:** ${res.error}`).catch(() => { });
|
|
858
|
+
} else {
|
|
859
|
+
const ext = path.extname(file).replace('.', '') || 'text';
|
|
860
|
+
await replyToChannelMessage(
|
|
861
|
+
message,
|
|
862
|
+
`📄 **\`${file}\`**\n\`\`\`${ext}\n${res.content.substring(0, 1800)}\n\`\`\``
|
|
863
|
+
).catch(() => { });
|
|
864
|
+
}
|
|
865
|
+
} else if (text === '!tree') {
|
|
866
|
+
const tree = listWorkspaceTree(targetWorkspace, '.', 2, 80);
|
|
867
|
+
await replyToChannelMessage(message, `📁 **Workspace Tree (\`${path.basename(targetWorkspace)}\`):**\n\`\`\`text\n${tree.substring(0, 1850)}\n\`\`\``).catch(() => { });
|
|
868
|
+
} else if (text === '!info' || text === '!status') {
|
|
869
|
+
const payload = getBridgeStatusPayload();
|
|
870
|
+
await replyToChannelMessage(message, payload).catch(() => { });
|
|
871
|
+
} else if (text === '!schedule' || text.startsWith('!schedule ')) {
|
|
872
|
+
const rawArg = text.replace(/^!schedule\s*/i, '').trim();
|
|
873
|
+
const parsed = parseScheduleCommandInput(rawArg);
|
|
874
|
+
if (parsed.action === 'list') {
|
|
875
|
+
const jobs = scheduler.listJobs();
|
|
876
|
+
if (jobs.length === 0) {
|
|
877
|
+
await replyToChannelMessage(message, '⏰ **No active scheduled tasks.** Use `!schedule 15m | !sh npm test` or `!schedule 0 9 * * 1-5 | Check build` to create one.').catch(() => { });
|
|
878
|
+
} else {
|
|
879
|
+
let out = `⏰ **Active Scheduled Tasks (${jobs.length})**\n\n`;
|
|
880
|
+
for (const j of jobs) {
|
|
881
|
+
out += `• **\`#${j.id}\`** (${j.description}) — Next: \`${j.nextRunAt || 'N/A'}\` | Prompt: \`${j.prompt.substring(0, 80)}\`\n`;
|
|
882
|
+
}
|
|
883
|
+
await replyToChannelMessage(message, out.substring(0, 1900)).catch(() => { });
|
|
884
|
+
}
|
|
885
|
+
} else if (parsed.action === 'cancel') {
|
|
886
|
+
if (parsed.jobId === 'all') {
|
|
887
|
+
const count = scheduler.cancelAll();
|
|
888
|
+
await replyToChannelMessage(message, `🛑 **Cancelled ${count} scheduled task(s).**`).catch(() => { });
|
|
889
|
+
} else {
|
|
890
|
+
const ok = scheduler.cancelJob(parsed.jobId);
|
|
891
|
+
await replyToChannelMessage(message, ok ? `🛑 **Cancelled schedule \`#${parsed.jobId}\`.**` : `❌ Schedule \`#${parsed.jobId}\` not found.`).catch(() => { });
|
|
892
|
+
}
|
|
893
|
+
} else if (parsed.action === 'add') {
|
|
894
|
+
const res = scheduler.addJob({
|
|
895
|
+
spec: parsed.spec,
|
|
896
|
+
prompt: parsed.prompt,
|
|
897
|
+
channelId: message.channel_id,
|
|
898
|
+
workspacePath: targetWorkspace,
|
|
899
|
+
userId: message.author.id
|
|
900
|
+
});
|
|
901
|
+
if (!res.ok) {
|
|
902
|
+
await replyToChannelMessage(message, `❌ **Schedule Error:** ${res.error}`).catch(() => { });
|
|
903
|
+
} else {
|
|
904
|
+
await replyToChannelMessage(
|
|
905
|
+
message,
|
|
906
|
+
`⏰ **Scheduled Task \`#${res.job.id}\` Created!** (${res.job.description})\n• **Next Run:** \`${res.job.nextRunAt}\`\n• **Prompt:** \`${res.job.prompt}\``
|
|
907
|
+
).catch(() => { });
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
await replyToChannelMessage(message, `❌ **Schedule Error:** ${parsed.error}`).catch(() => { });
|
|
911
|
+
}
|
|
912
|
+
} else if (text === '!bind' || text.startsWith('!bind ')) {
|
|
913
|
+
const arg = text.replace(/^!bind\s*/i, '').trim();
|
|
914
|
+
if (!arg || arg === 'status') {
|
|
915
|
+
const binding = threadWorkspaces.getThreadBinding(message.channel_id);
|
|
916
|
+
await replyToChannelMessage(
|
|
917
|
+
message,
|
|
918
|
+
binding
|
|
919
|
+
? `🔗 **Bound Workspace:** \`${binding.workspacePath}\``
|
|
920
|
+
: `🔗 **Current Workspace:** \`${config.workspace}\` *(default)*`
|
|
921
|
+
).catch(() => { });
|
|
922
|
+
} else if (arg === 'unbind' || arg === 'reset') {
|
|
923
|
+
threadWorkspaces.unbindThread(message.channel_id);
|
|
924
|
+
await replyToChannelMessage(message, `🔓 **Unbound thread.** Reverted to default workspace (\`${config.workspace}\`).`).catch(() => { });
|
|
925
|
+
} else {
|
|
926
|
+
const res = threadWorkspaces.bindThread(message.channel_id, arg, { userId: message.author.id });
|
|
927
|
+
await replyToChannelMessage(
|
|
928
|
+
message,
|
|
929
|
+
res.ok ? `🔗 **Bound <#${message.channel_id}> to workspace:** \`${res.binding.workspacePath}\`` : `❌ **Bind Failed:** ${res.error}`
|
|
930
|
+
).catch(() => { });
|
|
931
|
+
}
|
|
932
|
+
} else if (text === '!threads') {
|
|
933
|
+
const bindings = threadWorkspaces.listBindings();
|
|
934
|
+
if (bindings.length === 0) {
|
|
935
|
+
await replyToChannelMessage(message, `🧵 **No custom thread workspace bindings.** Default: \`${config.workspace}\``).catch(() => { });
|
|
936
|
+
} else {
|
|
937
|
+
let out = `🧵 **Active Thread Bindings (${bindings.length})**\n\n`;
|
|
938
|
+
for (const b of bindings) {
|
|
939
|
+
out += `• <#${b.threadId}> → \`${b.workspacePath}\`\n`;
|
|
940
|
+
}
|
|
941
|
+
await replyToChannelMessage(message, out.substring(0, 1900)).catch(() => { });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} catch (err) {
|
|
945
|
+
log('messageCreate error:', err.message);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const SLASH_COMMANDS_DEFINITION = [
|
|
950
|
+
{
|
|
951
|
+
name: 'sh',
|
|
952
|
+
description: 'Execute shell command in workspace',
|
|
953
|
+
options: [{ name: 'command', description: 'Command to execute', type: 3, required: true }]
|
|
954
|
+
},
|
|
955
|
+
{ name: 'diff', description: 'View git status and diff preview' },
|
|
956
|
+
{
|
|
957
|
+
name: 'cat',
|
|
958
|
+
description: 'Safely inspect workspace file contents',
|
|
959
|
+
options: [{ name: 'file', description: 'Relative file path', type: 3, required: true }]
|
|
960
|
+
},
|
|
961
|
+
{ name: 'tree', description: 'View workspace directory layout' },
|
|
962
|
+
{ name: 'info', description: 'View host telemetry and git branch status' },
|
|
963
|
+
{ name: 'status', description: 'View Dis-Gravity MCP bridge status' },
|
|
964
|
+
{
|
|
965
|
+
name: 'schedule',
|
|
966
|
+
description: 'Schedule a one-shot or recurring cron prompt/command',
|
|
967
|
+
options: [
|
|
968
|
+
{ name: 'when', description: 'Delay (15m, 2h), interval (every 30m), cron (0 9 * * 1-5), list, or cancel <id>', type: 3, required: true },
|
|
969
|
+
{ name: 'prompt', description: 'Prompt or !sh command to execute when triggered', type: 3, required: false }
|
|
970
|
+
]
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: 'bind',
|
|
974
|
+
description: 'Bind this Discord thread/channel to a dedicated workspace directory',
|
|
975
|
+
options: [
|
|
976
|
+
{ name: 'workspace', description: 'Path to workspace directory (or "status" / "unbind")', type: 3, required: false }
|
|
977
|
+
]
|
|
978
|
+
},
|
|
979
|
+
{ name: 'threads', description: 'List all active Discord thread-to-workspace bindings' },
|
|
980
|
+
{ name: 'help', description: 'Display Dis-Gravity slash commands' }
|
|
981
|
+
];
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Zero-dependency Discord Gateway v10 WebSocket client using native Node 18+/24 WebSocket
|
|
985
|
+
*/
|
|
986
|
+
class NativeDiscordGateway {
|
|
987
|
+
constructor(token) {
|
|
988
|
+
this.token = token.trim().replace(/^Bot\s+/i, '');
|
|
989
|
+
this.ws = null;
|
|
990
|
+
this.heartbeatTimer = null;
|
|
991
|
+
this.reconnectTimer = null;
|
|
992
|
+
this.seq = null;
|
|
993
|
+
this.user = null;
|
|
994
|
+
this.applicationId = null;
|
|
995
|
+
this.destroyed = false;
|
|
996
|
+
// GUILDS (1) | GUILD_MESSAGES (512) | DIRECT_MESSAGES (4096) | MESSAGE_CONTENT (32768) = 37377
|
|
997
|
+
this.intents = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
connect() {
|
|
1001
|
+
if (this.destroyed) return;
|
|
1002
|
+
if (typeof WebSocket !== 'function') {
|
|
1003
|
+
log('Native WebSocket is unavailable in this Node runtime. Falling back to REST-only mode.');
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
this.ws = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json');
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
log('WebSocket initialization error:', err.message);
|
|
1011
|
+
this.scheduleReconnect(5000);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
this.ws.addEventListener('message', (event) => {
|
|
1016
|
+
try {
|
|
1017
|
+
const raw = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
|
1018
|
+
const packet = JSON.parse(raw);
|
|
1019
|
+
this.onPacket(packet);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
log('Gateway packet parse error:', err.message);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
this.ws.addEventListener('close', (event) => {
|
|
1026
|
+
this.clearHeartbeat();
|
|
1027
|
+
if (this.destroyed) return;
|
|
1028
|
+
clientReady = false;
|
|
1029
|
+
|
|
1030
|
+
const code = event?.code || 0;
|
|
1031
|
+
// 4004 = Authentication failed
|
|
1032
|
+
if (code === 4004) {
|
|
1033
|
+
log('Discord Gateway closed with 4004 (Invalid Bot Token). Not reconnecting.');
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// 4014 = Disallowed intent(s) — retry without privileged MESSAGE_CONTENT intent
|
|
1037
|
+
if (code === 4014 && (this.intents & (1 << 15)) !== 0) {
|
|
1038
|
+
log('Message Content Intent is not enabled on Discord Portal (4014). Reconnecting with standard intents...');
|
|
1039
|
+
this.intents = (1 << 0) | (1 << 9) | (1 << 12);
|
|
1040
|
+
this.scheduleReconnect(1000);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
this.scheduleReconnect(4000);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
this.ws.addEventListener('error', (err) => {
|
|
1048
|
+
if (err?.message) {
|
|
1049
|
+
log('Gateway WebSocket error:', err.message);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
send(payload) {
|
|
1055
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
1056
|
+
try {
|
|
1057
|
+
this.ws.send(JSON.stringify(payload));
|
|
1058
|
+
} catch { }
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
startHeartbeat(intervalMs) {
|
|
1063
|
+
this.clearHeartbeat();
|
|
1064
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
1065
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1066
|
+
this.send({ op: 1, d: this.seq });
|
|
1067
|
+
}, Math.max(5000, intervalMs - jitter));
|
|
1068
|
+
if (this.heartbeatTimer.unref) this.heartbeatTimer.unref();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
clearHeartbeat() {
|
|
1072
|
+
if (this.heartbeatTimer) {
|
|
1073
|
+
clearInterval(this.heartbeatTimer);
|
|
1074
|
+
this.heartbeatTimer = null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
scheduleReconnect(delayMs) {
|
|
1079
|
+
if (this.destroyed || this.reconnectTimer) return;
|
|
1080
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1081
|
+
this.reconnectTimer = null;
|
|
1082
|
+
this.connect();
|
|
1083
|
+
}, delayMs);
|
|
1084
|
+
if (this.reconnectTimer.unref) this.reconnectTimer.unref();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
onPacket(packet) {
|
|
1088
|
+
const { op, d, s, t } = packet;
|
|
1089
|
+
if (s !== null && s !== undefined) {
|
|
1090
|
+
this.seq = s;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
switch (op) {
|
|
1094
|
+
case 10: // HELLO
|
|
1095
|
+
this.startHeartbeat(d.heartbeat_interval || 41250);
|
|
1096
|
+
this.send({
|
|
1097
|
+
op: 2,
|
|
1098
|
+
d: {
|
|
1099
|
+
token: this.token,
|
|
1100
|
+
intents: this.intents,
|
|
1101
|
+
properties: {
|
|
1102
|
+
os: process.platform,
|
|
1103
|
+
browser: 'dis-gravity-mcp',
|
|
1104
|
+
device: 'dis-gravity-mcp'
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
break;
|
|
1109
|
+
|
|
1110
|
+
case 1: // HEARTBEAT REQUEST
|
|
1111
|
+
this.send({ op: 1, d: this.seq });
|
|
1112
|
+
break;
|
|
1113
|
+
|
|
1114
|
+
case 7: // RECONNECT
|
|
1115
|
+
case 9: // INVALID_SESSION
|
|
1116
|
+
try {
|
|
1117
|
+
this.ws?.close();
|
|
1118
|
+
} catch { }
|
|
1119
|
+
this.scheduleReconnect(2000);
|
|
1120
|
+
break;
|
|
1121
|
+
|
|
1122
|
+
case 0: // DISPATCH
|
|
1123
|
+
if (t === 'READY') {
|
|
1124
|
+
const u = d.user || {};
|
|
1125
|
+
const tag = u.discriminator && u.discriminator !== '0'
|
|
1126
|
+
? `${u.username}#${u.discriminator}`
|
|
1127
|
+
: (u.username || 'bot');
|
|
1128
|
+
this.user = { id: u.id, username: u.username, discriminator: u.discriminator || '0', tag };
|
|
1129
|
+
this.applicationId = d.application?.id || null;
|
|
1130
|
+
clientReady = true;
|
|
1131
|
+
log(`Connected to Discord Gateway as @${this.user.tag}`);
|
|
1132
|
+
this.registerSlashCommands();
|
|
1133
|
+
} else if (t === 'INTERACTION_CREATE') {
|
|
1134
|
+
handleGatewayInteraction(d);
|
|
1135
|
+
} else if (t === 'MESSAGE_CREATE') {
|
|
1136
|
+
handleGatewayMessage(d);
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async registerSlashCommands() {
|
|
1143
|
+
if (!this.applicationId) return;
|
|
1144
|
+
try {
|
|
1145
|
+
if (config.guildId) {
|
|
1146
|
+
await discordRequest(
|
|
1147
|
+
this.token,
|
|
1148
|
+
'PUT',
|
|
1149
|
+
`/applications/${this.applicationId}/guilds/${config.guildId}/commands`,
|
|
1150
|
+
SLASH_COMMANDS_DEFINITION
|
|
1151
|
+
);
|
|
1152
|
+
log(`Registered ${SLASH_COMMANDS_DEFINITION.length} guild slash commands in guild ${config.guildId}`);
|
|
1153
|
+
} else {
|
|
1154
|
+
await discordRequest(
|
|
1155
|
+
this.token,
|
|
1156
|
+
'PUT',
|
|
1157
|
+
`/applications/${this.applicationId}/commands`,
|
|
1158
|
+
SLASH_COMMANDS_DEFINITION
|
|
1159
|
+
);
|
|
1160
|
+
log(`Registered ${SLASH_COMMANDS_DEFINITION.length} global slash commands`);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
log('Slash command registration notice:', err.message);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
destroy() {
|
|
1168
|
+
this.destroyed = true;
|
|
1169
|
+
this.clearHeartbeat();
|
|
1170
|
+
if (this.reconnectTimer) {
|
|
1171
|
+
clearTimeout(this.reconnectTimer);
|
|
1172
|
+
this.reconnectTimer = null;
|
|
1173
|
+
}
|
|
1174
|
+
if (this.ws) {
|
|
1175
|
+
try {
|
|
1176
|
+
this.ws.close(1000, 'Shutting down');
|
|
1177
|
+
} catch { }
|
|
1178
|
+
this.ws = null;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Initialize Discord Gateway Client when credentials are set
|
|
1184
|
+
async function initDiscordClient() {
|
|
1185
|
+
if (!config.token) {
|
|
1186
|
+
log('Discord Bot Token not configured. MCP Server running in standalone unconfigured mode.');
|
|
1187
|
+
log('Configure token via setup_discord_bridge tool or in Claude Settings -> Extensions -> Dis-Gravity.');
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// If channelId is missing, attempt automatic frictionless bootstrap
|
|
1192
|
+
if (!config.channelId) {
|
|
1193
|
+
const bootstrapped = await autoBootstrapBridge(config.token);
|
|
1194
|
+
if (!bootstrapped) {
|
|
1195
|
+
log('Channel ID missing and auto-bootstrap did not resolve a guild/channel.');
|
|
1196
|
+
log('Run setup_discord_bridge tool or authorize the bot in your Discord server.');
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Destroy previous client if reconnecting
|
|
1202
|
+
if (discordClient) {
|
|
1203
|
+
try {
|
|
1204
|
+
discordClient.destroy();
|
|
1205
|
+
} catch { }
|
|
1206
|
+
discordClient = null;
|
|
1207
|
+
clientReady = false;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
discordClient = new NativeDiscordGateway(config.token);
|
|
1212
|
+
discordClient.connect();
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
log('Could not start Discord Gateway client:', err.message);
|
|
1215
|
+
discordClient = null;
|
|
1216
|
+
clientReady = false;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function ensureDiscordStarted() {
|
|
1221
|
+
if (discordInitStarted) return;
|
|
1222
|
+
discordInitStarted = true;
|
|
1223
|
+
initDiscordClient().catch(err => log('Init error:', err.message));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Prompt definitions for MCP prompts/list & prompts/get (Claude Desktop 1-click wizard menu)
|
|
1227
|
+
const PROMPTS = [
|
|
1228
|
+
{
|
|
1229
|
+
name: 'setup-discord-bridge',
|
|
1230
|
+
description: 'Run the interactive 60-second setup wizard to connect Claude Desktop to Discord and your mobile phone.',
|
|
1231
|
+
arguments: [
|
|
1232
|
+
{
|
|
1233
|
+
name: 'bot_token',
|
|
1234
|
+
description: 'Discord Bot Token from https://discord.com/developers/applications (optional — leave blank for step-by-step instructions)',
|
|
1235
|
+
required: false
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
name: 'channel_name',
|
|
1239
|
+
description: 'Private Discord channel name to create or reuse (default: dis-gravity)',
|
|
1240
|
+
required: false
|
|
1241
|
+
}
|
|
1242
|
+
]
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
name: 'discord-bridge-status',
|
|
1246
|
+
description: 'Check Dis-Gravity Discord bridge connection status, host health, and send a test notification to mobile.',
|
|
1247
|
+
arguments: []
|
|
1248
|
+
}
|
|
1249
|
+
];
|
|
1250
|
+
|
|
1251
|
+
// Tool definitions for MCP tools/list
|
|
1252
|
+
const TOOLS = [
|
|
1253
|
+
{
|
|
1254
|
+
name: 'notify_discord',
|
|
1255
|
+
description: 'Send a rich status notification, milestone update, or message to the user\'s linked Discord channel and mobile phone. Automatically renders Markdown tables into 2x Retina PNG cards.',
|
|
1256
|
+
inputSchema: {
|
|
1257
|
+
type: 'object',
|
|
1258
|
+
properties: {
|
|
1259
|
+
message: {
|
|
1260
|
+
type: 'string',
|
|
1261
|
+
description: 'Text message content to send to Discord'
|
|
1262
|
+
},
|
|
1263
|
+
level: {
|
|
1264
|
+
type: 'string',
|
|
1265
|
+
enum: ['info', 'success', 'warning', 'error'],
|
|
1266
|
+
description: 'Notification severity level (default: info)'
|
|
1267
|
+
},
|
|
1268
|
+
title: {
|
|
1269
|
+
type: 'string',
|
|
1270
|
+
description: 'Optional bold title header for the notification'
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
required: ['message']
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
name: 'ask_discord_approval',
|
|
1278
|
+
description: 'Pause execution and request interactive 1-tap confirmation from the developer on their mobile Discord app. Halts until the developer taps [ Allow ] or [ Deny ].',
|
|
1279
|
+
inputSchema: {
|
|
1280
|
+
type: 'object',
|
|
1281
|
+
properties: {
|
|
1282
|
+
action: {
|
|
1283
|
+
type: 'string',
|
|
1284
|
+
description: 'Description of the sensitive action to approve (e.g. "Run terminal command: rm -rf dist/" or "Apply database migrations")'
|
|
1285
|
+
},
|
|
1286
|
+
details: {
|
|
1287
|
+
type: 'string',
|
|
1288
|
+
description: 'Additional context, diff preview, or command parameters'
|
|
1289
|
+
},
|
|
1290
|
+
timeout_seconds: {
|
|
1291
|
+
type: 'number',
|
|
1292
|
+
description: 'Approval timeout in seconds before auto-denying (default: 120)'
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
required: ['action']
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
name: 'get_discord_bridge_status',
|
|
1300
|
+
description: 'Retrieve current Dis-Gravity Discord connection status, active channel, and host system health.',
|
|
1301
|
+
inputSchema: {
|
|
1302
|
+
type: 'object',
|
|
1303
|
+
properties: {}
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
name: 'run_workspace_command',
|
|
1308
|
+
description: 'Execute a terminal shell command inside the workspace directory, with optional mobile Discord approval prompt for high-risk operations.',
|
|
1309
|
+
inputSchema: {
|
|
1310
|
+
type: 'object',
|
|
1311
|
+
properties: {
|
|
1312
|
+
command: {
|
|
1313
|
+
type: 'string',
|
|
1314
|
+
description: 'Shell command string to execute (e.g. "npm test", "git status")'
|
|
1315
|
+
},
|
|
1316
|
+
timeout_seconds: {
|
|
1317
|
+
type: 'number',
|
|
1318
|
+
description: 'Maximum command duration in seconds (default: 30)'
|
|
1319
|
+
},
|
|
1320
|
+
require_approval: {
|
|
1321
|
+
type: 'boolean',
|
|
1322
|
+
description: 'If true, requests 1-tap mobile Discord approval before executing'
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
required: ['command']
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
name: 'get_git_diff',
|
|
1330
|
+
description: 'Retrieve current Git branch, short status summary, and unified diff stat for the active workspace.',
|
|
1331
|
+
inputSchema: {
|
|
1332
|
+
type: 'object',
|
|
1333
|
+
properties: {}
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: 'render_table_card',
|
|
1338
|
+
description: 'Convert structured tabular data (headers and rows) into a 2x Retina PNG card and deliver it to Discord.',
|
|
1339
|
+
inputSchema: {
|
|
1340
|
+
type: 'object',
|
|
1341
|
+
properties: {
|
|
1342
|
+
title: {
|
|
1343
|
+
type: 'string',
|
|
1344
|
+
description: 'Card title banner (e.g. "Benchmark Results")'
|
|
1345
|
+
},
|
|
1346
|
+
headers: {
|
|
1347
|
+
type: 'array',
|
|
1348
|
+
items: { type: 'string' },
|
|
1349
|
+
description: 'Array of column header titles'
|
|
1350
|
+
},
|
|
1351
|
+
rows: {
|
|
1352
|
+
type: 'array',
|
|
1353
|
+
items: {
|
|
1354
|
+
type: 'array',
|
|
1355
|
+
items: { type: 'string' }
|
|
1356
|
+
},
|
|
1357
|
+
description: '2D array of row cell values'
|
|
1358
|
+
},
|
|
1359
|
+
alignments: {
|
|
1360
|
+
type: 'array',
|
|
1361
|
+
items: { type: 'string' },
|
|
1362
|
+
description: 'Optional column alignments ("left", "center", "right")'
|
|
1363
|
+
}
|
|
1364
|
+
},
|
|
1365
|
+
required: ['headers', 'rows']
|
|
1366
|
+
}
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
name: 'read_workspace_file',
|
|
1370
|
+
description: 'Safely inspect the content of a file within the workspace with path traversal protection.',
|
|
1371
|
+
inputSchema: {
|
|
1372
|
+
type: 'object',
|
|
1373
|
+
properties: {
|
|
1374
|
+
path: {
|
|
1375
|
+
type: 'string',
|
|
1376
|
+
description: 'Relative file path within workspace'
|
|
1377
|
+
},
|
|
1378
|
+
max_bytes: {
|
|
1379
|
+
type: 'number',
|
|
1380
|
+
description: 'Maximum bytes to read (default: 102400)'
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
required: ['path']
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
name: 'list_workspace_tree',
|
|
1388
|
+
description: 'View the directory tree structure of the workspace.',
|
|
1389
|
+
inputSchema: {
|
|
1390
|
+
type: 'object',
|
|
1391
|
+
properties: {
|
|
1392
|
+
sub_dir: {
|
|
1393
|
+
type: 'string',
|
|
1394
|
+
description: 'Relative subdirectory to list (default: ".")'
|
|
1395
|
+
},
|
|
1396
|
+
max_depth: {
|
|
1397
|
+
type: 'number',
|
|
1398
|
+
description: 'Maximum tree depth (default: 2)'
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
name: 'get_incoming_messages',
|
|
1405
|
+
description: 'Retrieve recent messages and instructions sent from the developer in the linked Discord channel.',
|
|
1406
|
+
inputSchema: {
|
|
1407
|
+
type: 'object',
|
|
1408
|
+
properties: {}
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
name: 'setup_discord_bridge',
|
|
1413
|
+
description: 'Run automated frictionless onboarding wizard to configure Dis-Gravity. Validates bot token, auto-enables Message Content intent, opens OAuth invite link, auto-creates private #dis-gravity channel, auto-whitelists bot owner, and connects live bridge.',
|
|
1414
|
+
inputSchema: {
|
|
1415
|
+
type: 'object',
|
|
1416
|
+
properties: {
|
|
1417
|
+
bot_token: {
|
|
1418
|
+
type: 'string',
|
|
1419
|
+
description: 'Discord Bot Token from Discord Developer Portal'
|
|
1420
|
+
},
|
|
1421
|
+
guild_id: {
|
|
1422
|
+
type: 'string',
|
|
1423
|
+
description: 'Optional Discord Server (Guild) ID to bind to'
|
|
1424
|
+
},
|
|
1425
|
+
channel_name: {
|
|
1426
|
+
type: 'string',
|
|
1427
|
+
description: 'Channel name to find or create (default: "dis-gravity")'
|
|
1428
|
+
},
|
|
1429
|
+
open_browser: {
|
|
1430
|
+
type: 'boolean',
|
|
1431
|
+
description: 'Whether to open the OAuth invite link in the default browser if bot is not in a server (default: true)'
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
required: ['bot_token']
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
];
|
|
1438
|
+
|
|
1439
|
+
// Tool handlers
|
|
1440
|
+
async function handleNotifyDiscord(args) {
|
|
1441
|
+
const token = config.token;
|
|
1442
|
+
const channelId = config.channelId;
|
|
1443
|
+
|
|
1444
|
+
if (!token || !channelId) {
|
|
1445
|
+
throw new Error('Discord Bot Token or Channel ID is not configured. Please set them in Claude Settings -> Extensions -> Dis-Gravity, or run "npx dis-gravity setup" in your terminal.');
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const levelEmojis = {
|
|
1449
|
+
info: 'ℹ️',
|
|
1450
|
+
success: '✅',
|
|
1451
|
+
warning: '⚠️',
|
|
1452
|
+
error: '❌'
|
|
1453
|
+
};
|
|
1454
|
+
const emoji = levelEmojis[args.level] || 'ℹ️';
|
|
1455
|
+
let content = '';
|
|
1456
|
+
|
|
1457
|
+
if (args.title) {
|
|
1458
|
+
content += `${emoji} **${args.title}**\n\n`;
|
|
1459
|
+
}
|
|
1460
|
+
content += args.message || '';
|
|
1461
|
+
|
|
1462
|
+
// Check if message contains a Markdown table for automatic 2x PNG rendering
|
|
1463
|
+
let pngBuf = null;
|
|
1464
|
+
const parsed = parseMarkdownTable(args.message);
|
|
1465
|
+
if (parsed) {
|
|
1466
|
+
pngBuf = await renderTableToPng(
|
|
1467
|
+
parsed.headers,
|
|
1468
|
+
parsed.rows,
|
|
1469
|
+
parsed.alignments,
|
|
1470
|
+
args.title || 'Data Table'
|
|
1471
|
+
).catch(() => null);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (pngBuf) {
|
|
1475
|
+
const res = await discordMultipartRequest(
|
|
1476
|
+
token,
|
|
1477
|
+
'POST',
|
|
1478
|
+
`/channels/${channelId}/messages`,
|
|
1479
|
+
{ content: content.substring(0, 1950) },
|
|
1480
|
+
[{ name: 'table-card.png', buffer: pngBuf }]
|
|
1481
|
+
);
|
|
1482
|
+
return {
|
|
1483
|
+
delivered: true,
|
|
1484
|
+
channelId,
|
|
1485
|
+
messageId: res.id,
|
|
1486
|
+
tableCardAttached: true
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const res = await discordRequest(token, 'POST', `/channels/${channelId}/messages`, {
|
|
1491
|
+
content: content.substring(0, 1950)
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
return {
|
|
1495
|
+
delivered: true,
|
|
1496
|
+
channelId,
|
|
1497
|
+
messageId: res.id,
|
|
1498
|
+
tableCardAttached: false
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function awaitComponentInteraction(actionId, messageId, timeoutMs) {
|
|
1503
|
+
return new Promise((resolve, reject) => {
|
|
1504
|
+
const timer = setTimeout(() => {
|
|
1505
|
+
pendingComponentWaiters.delete(actionId);
|
|
1506
|
+
reject(new Error('Timed out waiting for component interaction'));
|
|
1507
|
+
}, timeoutMs);
|
|
1508
|
+
|
|
1509
|
+
pendingComponentWaiters.set(actionId, {
|
|
1510
|
+
actionId,
|
|
1511
|
+
messageId,
|
|
1512
|
+
resolve: (data) => {
|
|
1513
|
+
clearTimeout(timer);
|
|
1514
|
+
resolve(data);
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function handleAskDiscordApproval(args) {
|
|
1521
|
+
const token = config.token;
|
|
1522
|
+
const channelId = config.channelId;
|
|
1523
|
+
|
|
1524
|
+
if (!token || !channelId) {
|
|
1525
|
+
throw new Error('Discord credentials not configured. Please configure DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID in extension settings, or run "npx dis-gravity setup".');
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const action = args.action;
|
|
1529
|
+
const details = args.details || '';
|
|
1530
|
+
const timeoutSeconds = Math.max(10, Math.min(600, args.timeout_seconds || 120));
|
|
1531
|
+
const actionId = `act_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
|
|
1532
|
+
|
|
1533
|
+
// If Gateway client is connected, use interactive Button components
|
|
1534
|
+
if (discordClient && clientReady) {
|
|
1535
|
+
let text = `🔔 **Claude Approval Request**\n\n`;
|
|
1536
|
+
text += `Claude is requesting your permission to proceed with an action:\n`;
|
|
1537
|
+
text += `> **Action:** \`${action}\`\n`;
|
|
1538
|
+
if (details) {
|
|
1539
|
+
text += `\`\`\`text\n${details.substring(0, 1000)}\n\`\`\`\n`;
|
|
1540
|
+
}
|
|
1541
|
+
text += `⏳ *Expires in ${timeoutSeconds}s. Tap an option below from mobile or desktop:*`;
|
|
1542
|
+
|
|
1543
|
+
const row = {
|
|
1544
|
+
type: 1, // ACTION_ROW
|
|
1545
|
+
components: [
|
|
1546
|
+
{
|
|
1547
|
+
type: 2, // BUTTON
|
|
1548
|
+
style: 3, // Success
|
|
1549
|
+
custom_id: `claude_allow_${actionId}`,
|
|
1550
|
+
label: '🟢 Allow / Proceed'
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
type: 2, // BUTTON
|
|
1554
|
+
style: 4, // Danger
|
|
1555
|
+
custom_id: `claude_deny_${actionId}`,
|
|
1556
|
+
label: '🔴 Deny / Cancel'
|
|
1557
|
+
}
|
|
1558
|
+
]
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
const promptMessage = await discordRequest(token, 'POST', `/channels/${channelId}/messages`, {
|
|
1562
|
+
content: text,
|
|
1563
|
+
components: [row]
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
try {
|
|
1567
|
+
const { interaction, user, customId } = await awaitComponentInteraction(actionId, promptMessage?.id, timeoutSeconds * 1000);
|
|
1568
|
+
|
|
1569
|
+
if (customId.startsWith(`claude_allow_${actionId}`)) {
|
|
1570
|
+
await updateInteractionMessage(interaction, {
|
|
1571
|
+
content: `✅ **Action Approved by <@${user.id}>:** \`${action}\``,
|
|
1572
|
+
components: []
|
|
1573
|
+
}).catch(() => { });
|
|
1574
|
+
|
|
1575
|
+
return {
|
|
1576
|
+
approved: true,
|
|
1577
|
+
action,
|
|
1578
|
+
confirmedBy: user.tag,
|
|
1579
|
+
confirmedById: user.id,
|
|
1580
|
+
timestamp: new Date().toISOString()
|
|
1581
|
+
};
|
|
1582
|
+
} else {
|
|
1583
|
+
await updateInteractionMessage(interaction, {
|
|
1584
|
+
content: `❌ **Action Denied by <@${user.id}>:** \`${action}\``,
|
|
1585
|
+
components: []
|
|
1586
|
+
}).catch(() => { });
|
|
1587
|
+
|
|
1588
|
+
return {
|
|
1589
|
+
approved: false,
|
|
1590
|
+
action,
|
|
1591
|
+
deniedBy: user.tag,
|
|
1592
|
+
reason: 'User clicked Deny in Discord'
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
} catch {
|
|
1596
|
+
// Timeout: remove buttons on original message
|
|
1597
|
+
if (promptMessage?.id) {
|
|
1598
|
+
await discordRequest(token, 'PATCH', `/channels/${channelId}/messages/${promptMessage.id}`, {
|
|
1599
|
+
content: `⏳ **Approval Request Expired (${timeoutSeconds}s):** \`${action}\` was automatically cancelled.`,
|
|
1600
|
+
components: []
|
|
1601
|
+
}).catch(() => { });
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
return {
|
|
1605
|
+
approved: false,
|
|
1606
|
+
action,
|
|
1607
|
+
error: `Timed out waiting for approval after ${timeoutSeconds} seconds`
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Fail-closed fallback if Gateway WebSocket client is not connected
|
|
1613
|
+
let fallbackText = `⚠️ **Claude Approval Blocked (Gateway Offline)**\n\n`;
|
|
1614
|
+
fallbackText += `Claude requested confirmation for:\n> **Action:** \`${action}\`\n`;
|
|
1615
|
+
if (details) fallbackText += `\`\`\`text\n${details.substring(0, 800)}\n\`\`\`\n`;
|
|
1616
|
+
fallbackText += `🛑 *Action was automatically denied because the Discord Gateway WebSocket is not currently connected to receive interactive button confirmation.*`;
|
|
1617
|
+
|
|
1618
|
+
await discordRequest(token, 'POST', `/channels/${channelId}/messages`, {
|
|
1619
|
+
content: fallbackText
|
|
1620
|
+
}).catch(() => { });
|
|
1621
|
+
|
|
1622
|
+
return {
|
|
1623
|
+
approved: false,
|
|
1624
|
+
action,
|
|
1625
|
+
error: 'Discord Gateway WebSocket is not connected; interactive approval could not be verified (fail-closed).'
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async function handleGetStatus() {
|
|
1630
|
+
const health = getSystemHealth(config.workspace);
|
|
1631
|
+
const gitInfo = await getGitStatusAndDiff(config.workspace);
|
|
1632
|
+
|
|
1633
|
+
return {
|
|
1634
|
+
service: 'Dis-Gravity MCP Server',
|
|
1635
|
+
version: SERVER_VERSION,
|
|
1636
|
+
discordConnected: clientReady,
|
|
1637
|
+
botUser: discordClient?.user?.tag || null,
|
|
1638
|
+
channelId: config.channelId || null,
|
|
1639
|
+
allowedUsersCount: config.allowedUsers.length,
|
|
1640
|
+
workspace: config.workspace,
|
|
1641
|
+
git: {
|
|
1642
|
+
branch: gitInfo.branch,
|
|
1643
|
+
isClean: gitInfo.isClean
|
|
1644
|
+
},
|
|
1645
|
+
host: {
|
|
1646
|
+
platform: health.platform,
|
|
1647
|
+
release: health.release,
|
|
1648
|
+
arch: health.arch,
|
|
1649
|
+
cpu: health.cpu,
|
|
1650
|
+
memory: health.memory,
|
|
1651
|
+
uptimeSeconds: health.uptimeSeconds
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async function handleRunWorkspaceCommand(args) {
|
|
1657
|
+
const cmd = args.command;
|
|
1658
|
+
if (!cmd) throw new Error('Command string is required');
|
|
1659
|
+
const timeoutMs = (args.timeout_seconds || 30) * 1000;
|
|
1660
|
+
const requireApproval = Boolean(args.require_approval);
|
|
1661
|
+
|
|
1662
|
+
if (requireApproval) {
|
|
1663
|
+
const approval = await handleAskDiscordApproval({
|
|
1664
|
+
action: `Execute shell command: ${cmd}`,
|
|
1665
|
+
details: `Directory: ${config.workspace}\nTimeout: ${args.timeout_seconds || 30}s`,
|
|
1666
|
+
timeout_seconds: 120
|
|
1667
|
+
});
|
|
1668
|
+
if (!approval.approved) {
|
|
1669
|
+
return {
|
|
1670
|
+
executed: false,
|
|
1671
|
+
denied: true,
|
|
1672
|
+
reason: approval.reason || approval.error || 'Execution denied by user'
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const res = await executeShellCommand(cmd, config.workspace, timeoutMs);
|
|
1678
|
+
return {
|
|
1679
|
+
executed: true,
|
|
1680
|
+
code: res.code,
|
|
1681
|
+
stdout: res.stdout,
|
|
1682
|
+
stderr: res.stderr,
|
|
1683
|
+
duration: res.duration,
|
|
1684
|
+
error: res.error
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function handleGetGitDiff() {
|
|
1689
|
+
return getGitStatusAndDiff(config.workspace);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function handleRenderTableCard(args) {
|
|
1693
|
+
const headers = args.headers || [];
|
|
1694
|
+
const rows = args.rows || [];
|
|
1695
|
+
const alignments = args.alignments || [];
|
|
1696
|
+
const title = args.title || '📊 Dis-Gravity Data Table';
|
|
1697
|
+
|
|
1698
|
+
const pngBuf = await renderTableToPng(headers, rows, alignments, title);
|
|
1699
|
+
if (!pngBuf) {
|
|
1700
|
+
throw new Error('Headless browser not found on host system to render table card.');
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
let deliveredToDiscord = false;
|
|
1704
|
+
if (config.token && config.channelId) {
|
|
1705
|
+
try {
|
|
1706
|
+
await discordMultipartRequest(
|
|
1707
|
+
config.token,
|
|
1708
|
+
'POST',
|
|
1709
|
+
`/channels/${config.channelId}/messages`,
|
|
1710
|
+
{ content: `📊 **${title}**` },
|
|
1711
|
+
[{ name: 'table-card.png', buffer: pngBuf }]
|
|
1712
|
+
);
|
|
1713
|
+
deliveredToDiscord = true;
|
|
1714
|
+
} catch (err) {
|
|
1715
|
+
log('Could not upload rendered table card to Discord:', err.message);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
return {
|
|
1720
|
+
rendered: true,
|
|
1721
|
+
deliveredToDiscord,
|
|
1722
|
+
sizeBytes: pngBuf.length,
|
|
1723
|
+
rowCount: rows.length,
|
|
1724
|
+
columnCount: headers.length
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function handleReadWorkspaceFile(args) {
|
|
1729
|
+
const filePath = args.path;
|
|
1730
|
+
if (!filePath) throw new Error('path parameter is required');
|
|
1731
|
+
const maxBytes = args.max_bytes || 100 * 1024;
|
|
1732
|
+
return readWorkspaceFile(config.workspace, filePath, maxBytes);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
async function handleListWorkspaceTree(args) {
|
|
1736
|
+
const subDir = args.sub_dir || '.';
|
|
1737
|
+
const maxDepth = args.max_depth || 2;
|
|
1738
|
+
const tree = listWorkspaceTree(config.workspace, subDir, maxDepth);
|
|
1739
|
+
return { tree };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
async function handleGetIncomingMessages() {
|
|
1743
|
+
return {
|
|
1744
|
+
count: incomingMessages.length,
|
|
1745
|
+
messages: incomingMessages
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
async function handleSetupDiscordBridge(args) {
|
|
1750
|
+
const token = cleanBotToken(args.bot_token || '');
|
|
1751
|
+
if (!token) {
|
|
1752
|
+
throw new Error('bot_token is required to run setup wizard.');
|
|
1753
|
+
}
|
|
1754
|
+
if (!looksLikeBotToken(token)) {
|
|
1755
|
+
throw new Error('Invalid Discord Bot Token format. A valid token typically looks like: [24+ chars].[6 chars].[27+ chars]');
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const channelName = (args.channel_name || 'dis-gravity').trim();
|
|
1759
|
+
const openBrowserRequested = args.open_browser !== false;
|
|
1760
|
+
|
|
1761
|
+
const result = {
|
|
1762
|
+
step: 'started',
|
|
1763
|
+
botTag: null,
|
|
1764
|
+
appName: null,
|
|
1765
|
+
messageContentIntent: null,
|
|
1766
|
+
guildName: null,
|
|
1767
|
+
guildId: null,
|
|
1768
|
+
channelName: null,
|
|
1769
|
+
channelId: null,
|
|
1770
|
+
whitelistedUserId: null,
|
|
1771
|
+
inviteUrl: null,
|
|
1772
|
+
connected: false
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
// 1. Ensure Message Content Intent
|
|
1776
|
+
const intentRes = await ensureMessageContentIntent(token).catch(e => ({ success: false, message: e.message }));
|
|
1777
|
+
result.messageContentIntent = intentRes?.alreadyEnabled ? 'already_enabled' : (intentRes?.autoEnabled ? 'auto_enabled' : 'manual_required');
|
|
1778
|
+
|
|
1779
|
+
// 2. Inspect application
|
|
1780
|
+
const app = await inspectApplication(token);
|
|
1781
|
+
result.botTag = app.botTag;
|
|
1782
|
+
result.appName = app.appName;
|
|
1783
|
+
result.whitelistedUserId = app.ownerId;
|
|
1784
|
+
|
|
1785
|
+
// 3. Check guild join status
|
|
1786
|
+
const guilds = await listUserGuilds(token).catch(() => []);
|
|
1787
|
+
let targetGuild = null;
|
|
1788
|
+
|
|
1789
|
+
if (args.guild_id) {
|
|
1790
|
+
targetGuild = guilds.find(g => g.id === args.guild_id) || { id: args.guild_id };
|
|
1791
|
+
} else if (guilds.length > 0) {
|
|
1792
|
+
targetGuild = guilds[0];
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// If not joined to any guild, generate invite link and optionally open browser
|
|
1796
|
+
if (!targetGuild) {
|
|
1797
|
+
const inviteUrl = buildInviteUrl(app.appId);
|
|
1798
|
+
result.inviteUrl = inviteUrl;
|
|
1799
|
+
|
|
1800
|
+
if (openBrowserRequested) {
|
|
1801
|
+
try { openBrowser(inviteUrl); } catch { }
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
try {
|
|
1805
|
+
// Wait up to 30 seconds for user to authorize in browser
|
|
1806
|
+
targetGuild = await waitForGuildJoin(token, { timeoutMs: 30000 });
|
|
1807
|
+
} catch {
|
|
1808
|
+
return {
|
|
1809
|
+
...result,
|
|
1810
|
+
step: 'awaiting_server_join',
|
|
1811
|
+
message: `Bot @${app.botTag} is not yet added to a server. Please open the invite link to authorize it, then run this tool again.`,
|
|
1812
|
+
inviteUrl
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
result.guildName = targetGuild.name || 'Discord Server';
|
|
1818
|
+
result.guildId = targetGuild.id;
|
|
1819
|
+
|
|
1820
|
+
// 4. Find or create channel
|
|
1821
|
+
const channelRes = await findOrCreateChannel(token, targetGuild.id, {
|
|
1822
|
+
channelName,
|
|
1823
|
+
botId: app.botId,
|
|
1824
|
+
ownerId: app.ownerId
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
result.channelName = `#${channelRes.channel.name}`;
|
|
1828
|
+
result.channelId = channelRes.channel.id;
|
|
1829
|
+
|
|
1830
|
+
// 5. Update active config
|
|
1831
|
+
config.token = token;
|
|
1832
|
+
config.channelId = channelRes.channel.id;
|
|
1833
|
+
config.guildId = targetGuild.id;
|
|
1834
|
+
if (!config.allowedUsers || config.allowedUsers.length === 0) {
|
|
1835
|
+
config.allowedUsers = app.ownerId ? [app.ownerId] : [];
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// 6. Save config to ~/.dis-gravity/config.json
|
|
1839
|
+
saveConfig({
|
|
1840
|
+
token: config.token,
|
|
1841
|
+
channelId: config.channelId,
|
|
1842
|
+
guildId: config.guildId,
|
|
1843
|
+
allowedUsers: config.allowedUsers,
|
|
1844
|
+
workspace: config.workspace
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
// 7. Send welcome message
|
|
1848
|
+
await sendWelcomeMessage(token, config.channelId, {
|
|
1849
|
+
ownerIds: config.allowedUsers,
|
|
1850
|
+
provider: 'Claude Desktop MCP'
|
|
1851
|
+
}).catch(() => { });
|
|
1852
|
+
|
|
1853
|
+
// 8. Connect Discord client live
|
|
1854
|
+
discordInitStarted = true;
|
|
1855
|
+
await initDiscordClient();
|
|
1856
|
+
result.connected = clientReady || Boolean(discordClient);
|
|
1857
|
+
result.step = 'completed';
|
|
1858
|
+
|
|
1859
|
+
return result;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// JSON-RPC Dispatcher
|
|
1863
|
+
function sendResponse(obj) {
|
|
1864
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async function handleRpc(req) {
|
|
1868
|
+
const { id, method, params } = req;
|
|
1869
|
+
|
|
1870
|
+
// Handle Notifications (no response needed) — note: id === 0 is a valid JSON-RPC request ID!
|
|
1871
|
+
if ((id === undefined || id === null) && method) {
|
|
1872
|
+
if (method === 'notifications/initialized') {
|
|
1873
|
+
log('Client initialized notification received.');
|
|
1874
|
+
ensureDiscordStarted();
|
|
1875
|
+
}
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
try {
|
|
1880
|
+
switch (method) {
|
|
1881
|
+
case 'initialize': {
|
|
1882
|
+
const requestedVersion = params?.protocolVersion;
|
|
1883
|
+
const negotiatedVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
|
1884
|
+
? requestedVersion
|
|
1885
|
+
: '2024-11-05';
|
|
1886
|
+
|
|
1887
|
+
sendResponse({
|
|
1888
|
+
jsonrpc: '2.0',
|
|
1889
|
+
id,
|
|
1890
|
+
result: {
|
|
1891
|
+
protocolVersion: negotiatedVersion,
|
|
1892
|
+
capabilities: {
|
|
1893
|
+
tools: {},
|
|
1894
|
+
resources: {},
|
|
1895
|
+
prompts: {}
|
|
1896
|
+
},
|
|
1897
|
+
serverInfo: {
|
|
1898
|
+
name: 'dis-gravity',
|
|
1899
|
+
version: SERVER_VERSION
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// Start Discord Gateway connection asynchronously after handshake response is sent
|
|
1905
|
+
ensureDiscordStarted();
|
|
1906
|
+
break;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
case 'ping':
|
|
1910
|
+
sendResponse({
|
|
1911
|
+
jsonrpc: '2.0',
|
|
1912
|
+
id,
|
|
1913
|
+
result: {}
|
|
1914
|
+
});
|
|
1915
|
+
break;
|
|
1916
|
+
|
|
1917
|
+
case 'resources/list':
|
|
1918
|
+
sendResponse({
|
|
1919
|
+
jsonrpc: '2.0',
|
|
1920
|
+
id,
|
|
1921
|
+
result: {
|
|
1922
|
+
resources: [
|
|
1923
|
+
{
|
|
1924
|
+
uri: 'discord://chat',
|
|
1925
|
+
name: 'Discord Live Messages',
|
|
1926
|
+
description: 'Recent incoming messages and instructions sent from developers in Discord',
|
|
1927
|
+
mimeType: 'application/json'
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
uri: 'discord://latest-prompt',
|
|
1931
|
+
name: 'Discord Latest Prompt',
|
|
1932
|
+
description: 'The most recent command or prompt sent from Discord',
|
|
1933
|
+
mimeType: 'text/plain'
|
|
1934
|
+
}
|
|
1935
|
+
]
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
break;
|
|
1939
|
+
|
|
1940
|
+
case 'resources/templates/list':
|
|
1941
|
+
sendResponse({
|
|
1942
|
+
jsonrpc: '2.0',
|
|
1943
|
+
id,
|
|
1944
|
+
result: {
|
|
1945
|
+
resourceTemplates: []
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
break;
|
|
1949
|
+
|
|
1950
|
+
case 'prompts/list':
|
|
1951
|
+
sendResponse({
|
|
1952
|
+
jsonrpc: '2.0',
|
|
1953
|
+
id,
|
|
1954
|
+
result: {
|
|
1955
|
+
prompts: PROMPTS
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
break;
|
|
1959
|
+
|
|
1960
|
+
case 'prompts/get': {
|
|
1961
|
+
const promptName = params?.name;
|
|
1962
|
+
const promptArgs = params?.arguments || {};
|
|
1963
|
+
|
|
1964
|
+
if (promptName === 'setup-discord-bridge') {
|
|
1965
|
+
const tokenArg = cleanBotToken(promptArgs.bot_token || '');
|
|
1966
|
+
const channelArg = (promptArgs.channel_name || 'dis-gravity').trim();
|
|
1967
|
+
const promptText = tokenArg
|
|
1968
|
+
? `Please run the Dis-Gravity onboarding wizard now by calling the \`setup_discord_bridge\` tool with \`bot_token: "${tokenArg}"\` and \`channel_name: "${channelArg}"\`, then report the connected server, private channel, and security whitelist status.`
|
|
1969
|
+
: `Please help me set up the Dis-Gravity Discord Bridge using the interactive wizard:\n1. First call \`get_discord_bridge_status\` to check if a token/channel is already configured.\n2. If not configured, guide me in 3 quick steps to get a Discord Bot Token from https://discord.com/developers/applications (New Application -> Bot -> Reset Token -> Copy Token).\n3. As soon as I provide my Bot Token, call the \`setup_discord_bridge\` tool so Dis-Gravity automatically enables Message Content Intent, opens the server invite link, creates the private locked \`#dis-gravity\` channel, whitelists my Discord User ID, and connects the live bridge.`;
|
|
1970
|
+
|
|
1971
|
+
sendResponse({
|
|
1972
|
+
jsonrpc: '2.0',
|
|
1973
|
+
id,
|
|
1974
|
+
result: {
|
|
1975
|
+
description: 'Dis-Gravity Interactive Setup Wizard',
|
|
1976
|
+
messages: [
|
|
1977
|
+
{
|
|
1978
|
+
role: 'user',
|
|
1979
|
+
content: {
|
|
1980
|
+
type: 'text',
|
|
1981
|
+
text: promptText
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
]
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
} else if (promptName === 'discord-bridge-status') {
|
|
1988
|
+
sendResponse({
|
|
1989
|
+
jsonrpc: '2.0',
|
|
1990
|
+
id,
|
|
1991
|
+
result: {
|
|
1992
|
+
description: 'Check Dis-Gravity Discord bridge status and send a test ping',
|
|
1993
|
+
messages: [
|
|
1994
|
+
{
|
|
1995
|
+
role: 'user',
|
|
1996
|
+
content: {
|
|
1997
|
+
type: 'text',
|
|
1998
|
+
text: 'Please check the Dis-Gravity Discord bridge status using `get_discord_bridge_status`. If connected, send a quick test notification to Discord using `notify_discord` to confirm mobile delivery.'
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
]
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
} else {
|
|
2005
|
+
sendResponse({
|
|
2006
|
+
jsonrpc: '2.0',
|
|
2007
|
+
id,
|
|
2008
|
+
error: {
|
|
2009
|
+
code: -32602,
|
|
2010
|
+
message: `Unknown prompt: ${promptName}`
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
case 'resources/read': {
|
|
2018
|
+
const uri = params?.uri;
|
|
2019
|
+
if (uri === 'discord://chat') {
|
|
2020
|
+
sendResponse({
|
|
2021
|
+
jsonrpc: '2.0',
|
|
2022
|
+
id,
|
|
2023
|
+
result: {
|
|
2024
|
+
contents: [
|
|
2025
|
+
{
|
|
2026
|
+
uri: 'discord://chat',
|
|
2027
|
+
mimeType: 'application/json',
|
|
2028
|
+
text: JSON.stringify(incomingMessages, null, 2)
|
|
2029
|
+
}
|
|
2030
|
+
]
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
} else if (uri === 'discord://latest-prompt') {
|
|
2034
|
+
const latest = incomingMessages[incomingMessages.length - 1];
|
|
2035
|
+
sendResponse({
|
|
2036
|
+
jsonrpc: '2.0',
|
|
2037
|
+
id,
|
|
2038
|
+
result: {
|
|
2039
|
+
contents: [
|
|
2040
|
+
{
|
|
2041
|
+
uri: 'discord://latest-prompt',
|
|
2042
|
+
mimeType: 'text/plain',
|
|
2043
|
+
text: latest ? latest.content : ''
|
|
2044
|
+
}
|
|
2045
|
+
]
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
} else {
|
|
2049
|
+
sendResponse({
|
|
2050
|
+
jsonrpc: '2.0',
|
|
2051
|
+
id,
|
|
2052
|
+
error: {
|
|
2053
|
+
code: -32602,
|
|
2054
|
+
message: `Unknown resource URI: ${uri}`
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
case 'tools/list':
|
|
2062
|
+
sendResponse({
|
|
2063
|
+
jsonrpc: '2.0',
|
|
2064
|
+
id,
|
|
2065
|
+
result: {
|
|
2066
|
+
tools: TOOLS
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
break;
|
|
2070
|
+
|
|
2071
|
+
case 'tools/call': {
|
|
2072
|
+
ensureDiscordStarted();
|
|
2073
|
+
const name = params?.name;
|
|
2074
|
+
const args = params?.arguments || {};
|
|
2075
|
+
let result;
|
|
2076
|
+
|
|
2077
|
+
if (name === 'notify_discord') {
|
|
2078
|
+
result = await handleNotifyDiscord(args);
|
|
2079
|
+
} else if (name === 'ask_discord_approval') {
|
|
2080
|
+
result = await handleAskDiscordApproval(args);
|
|
2081
|
+
} else if (name === 'get_discord_bridge_status') {
|
|
2082
|
+
result = await handleGetStatus();
|
|
2083
|
+
} else if (name === 'run_workspace_command') {
|
|
2084
|
+
result = await handleRunWorkspaceCommand(args);
|
|
2085
|
+
} else if (name === 'get_git_diff') {
|
|
2086
|
+
result = await handleGetGitDiff();
|
|
2087
|
+
} else if (name === 'render_table_card') {
|
|
2088
|
+
result = await handleRenderTableCard(args);
|
|
2089
|
+
} else if (name === 'read_workspace_file') {
|
|
2090
|
+
result = await handleReadWorkspaceFile(args);
|
|
2091
|
+
} else if (name === 'list_workspace_tree') {
|
|
2092
|
+
result = await handleListWorkspaceTree(args);
|
|
2093
|
+
} else if (name === 'get_incoming_messages') {
|
|
2094
|
+
result = await handleGetIncomingMessages();
|
|
2095
|
+
} else if (name === 'setup_discord_bridge') {
|
|
2096
|
+
result = await handleSetupDiscordBridge(args);
|
|
2097
|
+
} else {
|
|
2098
|
+
sendResponse({
|
|
2099
|
+
jsonrpc: '2.0',
|
|
2100
|
+
id,
|
|
2101
|
+
error: {
|
|
2102
|
+
code: -32601,
|
|
2103
|
+
message: `Unknown tool: ${name}`
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
sendResponse({
|
|
2110
|
+
jsonrpc: '2.0',
|
|
2111
|
+
id,
|
|
2112
|
+
result: {
|
|
2113
|
+
content: [
|
|
2114
|
+
{
|
|
2115
|
+
type: 'text',
|
|
2116
|
+
text: JSON.stringify(result, null, 2)
|
|
2117
|
+
}
|
|
2118
|
+
]
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
default:
|
|
2125
|
+
sendResponse({
|
|
2126
|
+
jsonrpc: '2.0',
|
|
2127
|
+
id,
|
|
2128
|
+
error: {
|
|
2129
|
+
code: -32601,
|
|
2130
|
+
message: `Method not found: ${method}`
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
break;
|
|
2134
|
+
}
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
sendResponse({
|
|
2137
|
+
jsonrpc: '2.0',
|
|
2138
|
+
id,
|
|
2139
|
+
result: {
|
|
2140
|
+
content: [
|
|
2141
|
+
{
|
|
2142
|
+
type: 'text',
|
|
2143
|
+
text: `❌ Error: ${err.message}`
|
|
2144
|
+
}
|
|
2145
|
+
],
|
|
2146
|
+
isError: true
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Start stdio reader
|
|
2153
|
+
const rl = readline.createInterface({
|
|
2154
|
+
input: process.stdin,
|
|
2155
|
+
output: process.stdout,
|
|
2156
|
+
terminal: false
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
rl.on('line', (line) => {
|
|
2160
|
+
const trimmed = line.trim();
|
|
2161
|
+
if (!trimmed) return;
|
|
2162
|
+
try {
|
|
2163
|
+
const request = JSON.parse(trimmed);
|
|
2164
|
+
handleRpc(request);
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
sendResponse({
|
|
2167
|
+
jsonrpc: '2.0',
|
|
2168
|
+
id: 0,
|
|
2169
|
+
error: { code: -32700, message: 'Parse error: ' + err.message }
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
log('Dis-Gravity MCP Server started on stdio.');
|