v2 phase 4+5: Discord via Chat SDK, expanded MCP tools, message seq IDs
- Chat SDK bridge + Discord adapter (gateway listener, message routing) - MCP tools refactored into modular structure: core (send_message, send_file, edit_message, add_reaction), scheduling (schedule/list/cancel/pause/resume tasks), interactive (ask_user_question, send_card), agents (send_to_agent) - Message seq IDs: shared integer sequence across messages_in/out so agents see small numeric IDs instead of platform snowflakes - busy_timeout=5000 for session DB (poll loop + MCP server concurrent access) - Always copy agent-runner source to fix stale cache when non-index files change - Seed script for Discord testing, e2e test script Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
container/agent-runner/src/mcp-tools/core.ts
Normal file
190
container/agent-runner/src/mcp-tools/core.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Core MCP tools: send_message, send_file, edit_message, add_reaction.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function routing() {
|
||||
return {
|
||||
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
|
||||
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
|
||||
thread_id: process.env.NANOCLAW_THREAD_ID || null,
|
||||
};
|
||||
}
|
||||
|
||||
function ok(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
function err(text: string) {
|
||||
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
|
||||
}
|
||||
|
||||
export const sendMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_message',
|
||||
description: 'Send a chat message to the current conversation or a specified destination.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
|
||||
platformId: { type: 'string', description: 'Target platform ID' },
|
||||
threadId: { type: 'string', description: 'Target thread ID' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const text = args.text as string;
|
||||
if (!text) return err('text is required');
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
|
||||
const seq = writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
platform_id: (args.platformId as string) || r.platform_id,
|
||||
channel_type: (args.channel as string) || r.channel_type,
|
||||
thread_id: (args.threadId as string) || r.thread_id,
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
log(`send_message: #${seq} ${id} → ${r.channel_type || 'default'}/${r.platform_id || 'default'}`);
|
||||
return ok(`Message sent (id: ${seq})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const sendFile: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_file',
|
||||
description: 'Send a file to the current conversation.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
|
||||
text: { type: 'string', description: 'Optional accompanying message' },
|
||||
filename: { type: 'string', description: 'Display name (default: basename of path)' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const filePath = args.path as string;
|
||||
if (!filePath) return err('path is required');
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
|
||||
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
|
||||
|
||||
const id = generateId();
|
||||
const filename = (args.filename as string) || path.basename(resolvedPath);
|
||||
const r = routing();
|
||||
|
||||
// Copy file to outbox
|
||||
const outboxDir = path.join('/workspace/outbox', id);
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
|
||||
});
|
||||
|
||||
log(`send_file: ${id} → ${filename}`);
|
||||
return ok(`File sent (id: ${id}, filename: ${filename})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const editMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'edit_message',
|
||||
description: 'Edit a previously sent message.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
|
||||
text: { type: 'string', description: 'New message content' },
|
||||
},
|
||||
required: ['messageId', 'text'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const seq = Number(args.messageId);
|
||||
const text = args.text as string;
|
||||
if (!seq || !text) return err('messageId and text are required');
|
||||
|
||||
const platformId = getMessageIdBySeq(seq);
|
||||
if (!platformId) return err(`Message #${seq} not found`);
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({ operation: 'edit', messageId: platformId, text }),
|
||||
});
|
||||
|
||||
log(`edit_message: #${seq} → ${platformId}`);
|
||||
return ok(`Message edit queued for #${seq}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const addReaction: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'add_reaction',
|
||||
description: 'Add an emoji reaction to a message.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
|
||||
emoji: { type: 'string', description: 'Emoji name (e.g., thumbs_up, heart, check)' },
|
||||
},
|
||||
required: ['messageId', 'emoji'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const seq = Number(args.messageId);
|
||||
const emoji = args.emoji as string;
|
||||
if (!seq || !emoji) return err('messageId and emoji are required');
|
||||
|
||||
const platformId = getMessageIdBySeq(seq);
|
||||
if (!platformId) return err(`Message #${seq} not found`);
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }),
|
||||
});
|
||||
|
||||
log(`add_reaction: #${seq} → ${emoji} on ${platformId}`);
|
||||
return ok(`Reaction queued for #${seq}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const coreTools: McpToolDefinition[] = [sendMessage, sendFile, editMessage, addReaction];
|
||||
Reference in New Issue
Block a user