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:
@@ -8,6 +8,7 @@ export function getSessionDb(): Database.Database {
|
||||
if (!_db) {
|
||||
_db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH);
|
||||
_db.pragma('journal_mode = DELETE');
|
||||
_db.pragma('busy_timeout = 5000');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
}
|
||||
return _db;
|
||||
@@ -20,6 +21,7 @@ export function initTestSessionDb(): Database.Database {
|
||||
_db.exec(`
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
@@ -34,6 +36,7 @@ export function initTestSessionDb(): Database.Database {
|
||||
);
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE,
|
||||
in_reply_to TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js';
|
||||
|
||||
export interface MessageInRow {
|
||||
id: string;
|
||||
seq: number | null;
|
||||
kind: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js';
|
||||
|
||||
export interface MessageOutRow {
|
||||
id: string;
|
||||
seq: number | null;
|
||||
in_reply_to: string | null;
|
||||
timestamp: string;
|
||||
delivered: number;
|
||||
@@ -26,22 +27,44 @@ export interface WriteMessageOut {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Write a new outbound message. */
|
||||
export function writeMessageOut(msg: WriteMessageOut): void {
|
||||
getSessionDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_out (id, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (@id, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
|
||||
)
|
||||
.run({
|
||||
in_reply_to: null,
|
||||
deliver_after: null,
|
||||
recurrence: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
...msg,
|
||||
});
|
||||
/** Write a new outbound message, auto-assigning a seq number. */
|
||||
export function writeMessageOut(msg: WriteMessageOut): number {
|
||||
const db = getSessionDb();
|
||||
const nextSeq = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM (
|
||||
SELECT seq FROM messages_in WHERE seq IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT seq FROM messages_out WHERE seq IS NOT NULL
|
||||
)`,
|
||||
)
|
||||
.get() as { next: number }
|
||||
).next;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
|
||||
).run({
|
||||
in_reply_to: null,
|
||||
deliver_after: null,
|
||||
recurrence: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
...msg,
|
||||
seq: nextSeq,
|
||||
});
|
||||
|
||||
return nextSeq;
|
||||
}
|
||||
|
||||
/** Look up a message's platform ID by seq number. */
|
||||
export function getMessageIdBySeq(seq: number): string | null {
|
||||
const inRow = getSessionDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as { id: string } | undefined;
|
||||
if (inRow) return inRow.id;
|
||||
const outRow = getSessionDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as { id: string } | undefined;
|
||||
return outRow?.id ?? null;
|
||||
}
|
||||
|
||||
/** Get undelivered messages (for host polling). */
|
||||
|
||||
@@ -67,7 +67,8 @@ function formatChatMessages(messages: MessageInRow[]): string {
|
||||
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
||||
const time = formatTime(msg.timestamp);
|
||||
const text = content.text || '';
|
||||
lines.push(`<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`);
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
lines.push(`<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`);
|
||||
}
|
||||
lines.push('</messages>');
|
||||
return lines.join('\n');
|
||||
@@ -78,7 +79,8 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
||||
const time = formatTime(msg.timestamp);
|
||||
const text = content.text || '';
|
||||
return `<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`;
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`;
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
|
||||
@@ -64,7 +64,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// MCP server path
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'mcp-tools.js');
|
||||
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
|
||||
|
||||
// SDK env
|
||||
const env: Record<string, string | undefined> = {
|
||||
|
||||
58
container/agent-runner/src/mcp-tools/agents.ts
Normal file
58
container/agent-runner/src/mcp-tools/agents.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Agent-to-agent MCP tools: send_to_agent.
|
||||
*/
|
||||
import { writeMessageOut } 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 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 sendToAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_to_agent',
|
||||
description: 'Send a message to another agent group.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
agentGroupId: { type: 'string', description: 'Target agent group ID' },
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
sessionId: { type: 'string', description: 'Target specific session (optional)' },
|
||||
},
|
||||
required: ['agentGroupId', 'text'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const agentGroupId = args.agentGroupId as string;
|
||||
const text = args.text as string;
|
||||
if (!agentGroupId || !text) return err('agentGroupId and text are required');
|
||||
|
||||
const id = generateId();
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
channel_type: 'agent',
|
||||
platform_id: agentGroupId,
|
||||
thread_id: (args.sessionId as string) || null,
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
log(`send_to_agent: ${id} → ${agentGroupId}`);
|
||||
return ok(`Message sent to agent ${agentGroupId} (id: ${id})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const agentTools: McpToolDefinition[] = [sendToAgent];
|
||||
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];
|
||||
53
container/agent-runner/src/mcp-tools/index.ts
Normal file
53
container/agent-runner/src/mcp-tools/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* MCP tools barrel — collects all tool modules and starts the server.
|
||||
*
|
||||
* Each module exports a McpToolDefinition[] array. This file registers
|
||||
* them all with the MCP server. Adding a new tool module requires only
|
||||
* importing it here and spreading its tools array.
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
import { coreTools } from './core.js';
|
||||
import { schedulingTools } from './scheduling.js';
|
||||
import { interactiveTools } from './interactive.js';
|
||||
import { agentTools } from './agents.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools];
|
||||
|
||||
const toolMap = new Map<string, McpToolDefinition>();
|
||||
for (const t of allTools) {
|
||||
toolMap.set(t.tool.name, t);
|
||||
}
|
||||
|
||||
async function startMcpServer(): Promise<void> {
|
||||
const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: allTools.map((t) => t.tool),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = toolMap.get(name);
|
||||
if (!tool) {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
||||
}
|
||||
return tool.handler(args ?? {});
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
log(`MCP server started with ${allTools.length} tools: ${allTools.map((t) => t.tool.name).join(', ')}`);
|
||||
}
|
||||
|
||||
startMcpServer().catch((err) => {
|
||||
log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
147
container/agent-runner/src/mcp-tools/interactive.ts
Normal file
147
container/agent-runner/src/mcp-tools/interactive.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Interactive MCP tools: ask_user_question, send_card.
|
||||
*
|
||||
* ask_user_question is a blocking tool call — it writes a messages_out row
|
||||
* with a question card, then polls messages_in for the response.
|
||||
*/
|
||||
import { getSessionDb } from '../db/connection.js';
|
||||
import { writeMessageOut } 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 };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const askUserQuestion: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'ask_user_question',
|
||||
description:
|
||||
'Ask the user a multiple-choice question and wait for their response. This is a blocking call — execution pauses until the user responds or the timeout expires.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
question: { type: 'string', description: 'The question to ask' },
|
||||
options: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Button labels for the user to choose from',
|
||||
},
|
||||
timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' },
|
||||
},
|
||||
required: ['question', 'options'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const question = args.question as string;
|
||||
const options = args.options as string[];
|
||||
const timeout = ((args.timeout as number) || 300) * 1000;
|
||||
if (!question || !options?.length) return err('question and options are required');
|
||||
|
||||
const questionId = generateId();
|
||||
const r = routing();
|
||||
|
||||
// Write question card to messages_out
|
||||
writeMessageOut({
|
||||
id: questionId,
|
||||
kind: 'chat-sdk',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId,
|
||||
question,
|
||||
options,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`);
|
||||
|
||||
// Poll for response in messages_in
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const response = getSessionDb()
|
||||
.prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1")
|
||||
.get(`%"questionId":"${questionId}"%`) as { content: string } | undefined;
|
||||
|
||||
if (response) {
|
||||
const parsed = JSON.parse(response.content);
|
||||
// Mark the response as completed so the poll loop doesn't pick it up
|
||||
getSessionDb()
|
||||
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?")
|
||||
.run(`%"questionId":"${questionId}"%`);
|
||||
|
||||
log(`ask_user_question response: ${questionId} → ${parsed.selectedOption}`);
|
||||
return ok(parsed.selectedOption);
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
log(`ask_user_question timeout: ${questionId}`);
|
||||
return err(`Question timed out after ${timeout / 1000}s`);
|
||||
},
|
||||
};
|
||||
|
||||
export const sendCard: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_card',
|
||||
description: 'Send a structured card (interactive or display-only) to the current conversation.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
card: {
|
||||
type: 'object',
|
||||
description: 'Card structure with title, description, and optional children/actions',
|
||||
},
|
||||
fallbackText: { type: 'string', description: 'Text fallback for platforms without card support' },
|
||||
},
|
||||
required: ['card'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const card = args.card as Record<string, unknown>;
|
||||
if (!card) return err('card is required');
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat-sdk',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({ type: 'card', card, fallbackText: (args.fallbackText as string) || '' }),
|
||||
});
|
||||
|
||||
log(`send_card: ${id}`);
|
||||
return ok(`Card sent (id: ${id})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const interactiveTools: McpToolDefinition[] = [askUserQuestion, sendCard];
|
||||
199
container/agent-runner/src/mcp-tools/scheduling.ts
Normal file
199
container/agent-runner/src/mcp-tools/scheduling.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
|
||||
*
|
||||
* Tasks are messages_in rows with process_after timestamps and optional recurrence.
|
||||
* The host sweep detects due tasks and wakes the container.
|
||||
*/
|
||||
import { getSessionDb } from '../db/connection.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `task-${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 scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' },
|
||||
recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' },
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfter = args.processAfter as string;
|
||||
if (!prompt || !processAfter) return err('prompt and processAfter are required');
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
const recurrence = (args.recurrence as string) || null;
|
||||
const script = (args.script as string) || null;
|
||||
|
||||
const content = JSON.stringify({ prompt, script });
|
||||
|
||||
getSessionDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`,
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
process_after: processAfter,
|
||||
recurrence,
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content,
|
||||
});
|
||||
|
||||
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
|
||||
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const listTasks: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'list_tasks',
|
||||
description: 'List scheduled and pending tasks.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Filter by status: pending, processing, completed, paused (default: all non-completed)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const status = args.status as string | undefined;
|
||||
let rows;
|
||||
if (status) {
|
||||
rows = getSessionDb()
|
||||
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC")
|
||||
.all(status);
|
||||
} else {
|
||||
rows = getSessionDb()
|
||||
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') ORDER BY process_after ASC")
|
||||
.all();
|
||||
}
|
||||
|
||||
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
|
||||
|
||||
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
|
||||
const content = JSON.parse(r.content);
|
||||
const prompt = (content.prompt as string || '').slice(0, 80);
|
||||
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`;
|
||||
});
|
||||
|
||||
return ok(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
export const cancelTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'cancel_task',
|
||||
description: 'Cancel a scheduled task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to cancel' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const result = getSessionDb()
|
||||
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')")
|
||||
.run(taskId);
|
||||
|
||||
if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`);
|
||||
|
||||
log(`cancel_task: ${taskId}`);
|
||||
return ok(`Task cancelled: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const pauseTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'pause_task',
|
||||
description: 'Pause a scheduled task. It will not run until resumed.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to pause' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const result = getSessionDb()
|
||||
.prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'")
|
||||
.run(taskId);
|
||||
|
||||
if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`);
|
||||
|
||||
log(`pause_task: ${taskId}`);
|
||||
return ok(`Task paused: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const resumeTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'resume_task',
|
||||
description: 'Resume a paused task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to resume' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const result = getSessionDb()
|
||||
.prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'")
|
||||
.run(taskId);
|
||||
|
||||
if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`);
|
||||
|
||||
log(`resume_task: ${taskId}`);
|
||||
return ok(`Task resumed: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const schedulingTools: McpToolDefinition[] = [scheduleTask, listTasks, cancelTask, pauseTask, resumeTask];
|
||||
6
container/agent-runner/src/mcp-tools/types.ts
Normal file
6
container/agent-runner/src/mcp-tools/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface McpToolDefinition {
|
||||
tool: Tool;
|
||||
handler: (args: Record<string, unknown>) => Promise<CallToolResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user