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:
gavrielc
2026-04-09 02:53:39 +03:00
parent b36f127acc
commit afbc20a6c4
21 changed files with 2702 additions and 37 deletions

View File

@@ -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,

View File

@@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js';
export interface MessageInRow {
id: string;
seq: number | null;
kind: string;
timestamp: string;
status: string;

View File

@@ -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). */

View File

@@ -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 {

View File

@@ -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> = {

View 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];

View 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];

View 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);
});

View 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];

View 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];

View 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>;
}