UNPKG

schedule-task-mcp

Version:

MCP server for scheduled task management and execution with support for interval, cron, and date-based triggers

626 lines (537 loc) 18.4 kB
/** * Task scheduler with support for interval, cron, and date triggers */ import * as cron from 'node-cron'; import cronParser from 'cron-parser'; import { TaskStorage, TaskRecord, TaskStatus, TaskHistoryEntry } from './storage.js'; import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { formatInTimezone, getSystemTimeZone } from './format.js'; const HISTORY_LIMIT = 10; interface IntervalTriggerConfig { seconds?: number; minutes?: number; hours?: number; days?: number; } export interface SchedulerConfig { dbPath: string; mcpServer?: Server; // 🎯 MCP server used to send sampling requests } export class TaskScheduler { private storage: TaskStorage; private cronJobs: Map<string, cron.ScheduledTask> = new Map(); private intervalTimers: Map<string, NodeJS.Timeout> = new Map(); private mcpServer?: Server; private readonly timeZone: string; private readonly samplingTimeoutMs: number; constructor(config: SchedulerConfig) { this.storage = new TaskStorage(config.dbPath); this.mcpServer = config.mcpServer; this.timeZone = process.env.SCHEDULE_TASK_TIMEZONE || getSystemTimeZone(); const timeoutEnv = process.env.SCHEDULE_TASK_SAMPLING_TIMEOUT; const parsedTimeout = timeoutEnv ? Number(timeoutEnv) : NaN; this.samplingTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : 180_000; } private computeIntervalMs(config: IntervalTriggerConfig): number { let intervalMs = 0; if (config.seconds) intervalMs += config.seconds * 1000; if (config.minutes) intervalMs += config.minutes * 60 * 1000; if (config.hours) intervalMs += config.hours * 60 * 60 * 1000; if (config.days) intervalMs += config.days * 24 * 60 * 60 * 1000; return intervalMs; } private computeNextRun(task: TaskRecord, fromDate: Date = new Date()): string | undefined { if (!task.enabled || task.status === 'completed') { return task.next_run; } if (task.trigger_type === 'interval') { if (task.next_run) { const existing = new Date(task.next_run); if (existing > fromDate) { return existing.toISOString(); } } const intervalMs = this.computeIntervalMs(task.trigger_config as IntervalTriggerConfig); if (intervalMs <= 0) { return undefined; } return new Date(fromDate.getTime() + intervalMs).toISOString(); } if (task.trigger_type === 'cron') { if (task.next_run) { const existing = new Date(task.next_run); if (existing > fromDate) { return existing.toISOString(); } } try { const interval = cronParser.parseExpression(task.trigger_config.expression, { currentDate: fromDate, tz: this.timeZone, }); const nextDate = interval.next().toDate(); return nextDate.toISOString(); } catch (error) { console.error(`[${task.id}] Failed to compute cron next run:`, error); return undefined; } } if (task.trigger_type === 'date') { const runDate = new Date(task.trigger_config.run_date); if (runDate <= fromDate) { return undefined; } return runDate.toISOString(); } return undefined; } private getTriggerSummary(task: TaskRecord): string { switch (task.trigger_type) { case 'interval': { const parts: string[] = []; const config = task.trigger_config as IntervalTriggerConfig; if (config.days) parts.push(`${config.days}天`); if (config.hours) parts.push(`${config.hours}小时`); if (config.minutes) parts.push(`${config.minutes}分钟`); if (config.seconds) parts.push(`${config.seconds}秒`); return parts.length ? `每${parts.join('')}` : '间隔任务(未配置)'; } case 'cron': return `Cron: ${task.trigger_config.expression}`; case 'date': { const runDate = (task.trigger_config as Record<string, any>).run_date; if (!runDate) { return '一次性 @ 未指定'; } const localized = formatInTimezone(runDate, this.timeZone, runDate); return `一次性 @ ${localized ?? runDate}`; } default: return task.trigger_type; } } private appendHistory(task: TaskRecord, entry: TaskHistoryEntry): void { if (!task.history) { task.history = []; } task.history.unshift(entry); if (task.history.length > HISTORY_LIMIT) { task.history = task.history.slice(0, HISTORY_LIMIT); } } private determineStatus(task: TaskRecord, now: Date = new Date()): TaskStatus { if (!task.enabled) { return task.status === 'completed' ? 'completed' : 'paused'; } if (task.status === 'running') { return 'running'; } if (task.trigger_type === 'date') { const runDate = new Date(task.trigger_config.run_date); if (task.history && task.history[0]?.status === 'success') { return 'completed'; } if (runDate <= now) { return 'completed'; } return 'scheduled'; } if (task.last_status === 'error') { return 'error'; } return 'scheduled'; } private normaliseTask(task: TaskRecord): TaskRecord { const now = new Date(); if (task.history && task.history.length > HISTORY_LIMIT) { task.history = task.history.slice(0, HISTORY_LIMIT); } task.status = this.determineStatus(task, now); if (task.trigger_type === 'date' && task.status === 'completed') { task.enabled = false; } task.next_run = this.computeNextRun(task, now); task.updated_at = task.updated_at || new Date().toISOString(); return task; } public describeTask(task: TaskRecord): TaskRecord & { trigger_summary: string; next_run_local?: string; last_run_local?: string; created_at_local?: string; updated_at_local?: string; history?: Array<TaskHistoryEntry & { run_at_local?: string }>; trigger_config_local?: Record<string, any>; } { const createdAtLocal = formatInTimezone(task.created_at, this.timeZone, task.created_at); const updatedAtLocal = formatInTimezone(task.updated_at, this.timeZone, task.updated_at); const nextRunLocal = task.next_run ? formatInTimezone(task.next_run, this.timeZone, task.next_run) : undefined; const lastRunLocal = task.last_run ? formatInTimezone(task.last_run, this.timeZone, task.last_run) : undefined; const historyWithLocal = task.history?.map((entry) => ({ ...entry, run_at_local: formatInTimezone(entry.run_at, this.timeZone, entry.run_at), })); let triggerConfigLocal: Record<string, any> | undefined; if (task.trigger_type === 'date' && task.trigger_config) { const config = task.trigger_config as Record<string, any>; const runDate = config.run_date; if (runDate) { triggerConfigLocal = { ...config, run_date_local: formatInTimezone(runDate, this.timeZone, runDate), }; } } return { ...task, history: historyWithLocal, trigger_summary: this.getTriggerSummary(task), next_run_local: nextRunLocal, last_run_local: lastRunLocal, created_at_local: createdAtLocal, updated_at_local: updatedAtLocal, trigger_config_local: triggerConfigLocal, }; } /** * Initialize scheduler and restore tasks from storage */ async initialize(): Promise<void> { const tasks = this.storage.list(); for (const task of tasks) { const normalised = this.normaliseTask(task); this.storage.upsert(normalised); if (normalised.enabled && normalised.status !== 'completed') { await this.scheduleTask(normalised); } } } /** * Create a new task */ async createTask(params: { trigger_type: 'interval' | 'cron' | 'date'; trigger_config: Record<string, any>; mcp_server?: string; mcp_tool?: string; mcp_arguments?: Record<string, any>; agent_prompt?: string; // 🎯 New: Agent prompt for sampling }): Promise<TaskRecord> { const now = new Date().toISOString(); const task: TaskRecord = { id: this.generateId(), trigger_type: params.trigger_type, trigger_config: params.trigger_config, mcp_server: params.mcp_server, mcp_tool: params.mcp_tool, mcp_arguments: params.mcp_arguments, agent_prompt: params.agent_prompt, // 🎯 Store agent prompt enabled: true, status: 'scheduled', created_at: now, updated_at: now, history: [], }; task.next_run = this.computeNextRun(task); this.storage.upsert(task); if (task.enabled) { await this.scheduleTask(task); } return task; } /** * List all tasks */ listTasks(): TaskRecord[] { const tasks = this.storage.list(); const normalised: TaskRecord[] = []; for (const task of tasks) { const updated = this.normaliseTask({ ...task }); this.storage.upsert(updated); normalised.push(updated); } return normalised; } /** * Get a specific task */ getTask(id: string): TaskRecord | undefined { const task = this.storage.get(id); if (!task) { return undefined; } const updated = this.normaliseTask({ ...task }); this.storage.upsert(updated); return updated; } /** * Update a task */ async updateTask( id: string, updates: Partial<Pick<TaskRecord, 'trigger_type' | 'trigger_config' | 'mcp_server' | 'mcp_tool' | 'mcp_arguments' | 'agent_prompt' | 'enabled'>> ): Promise<TaskRecord> { const task = this.storage.get(id); if (!task) { throw new Error(`Task not found: ${id}`); } Object.assign(task, updates); task.updated_at = new Date().toISOString(); task.status = this.determineStatus(task); task.next_run = this.computeNextRun(task); this.storage.upsert(task); // Reschedule if needed this.unscheduleTask(id); if (task.enabled && task.status !== 'completed') { await this.scheduleTask(task); } return task; } /** * Delete a task */ async deleteTask(id: string): Promise<boolean> { this.unscheduleTask(id); return this.storage.delete(id); } async clearTaskHistory(id: string): Promise<TaskRecord> { const task = this.storage.get(id); if (!task) { throw new Error(`Task not found: ${id}`); } task.history = []; task.last_message = undefined; task.last_status = undefined; task.updated_at = new Date().toISOString(); this.storage.upsert(task); return task; } /** * Pause a task */ async pauseTask(id: string): Promise<TaskRecord> { return this.updateTask(id, { enabled: false }); } /** * Resume a task */ async resumeTask(id: string): Promise<TaskRecord> { return this.updateTask(id, { enabled: true }); } /** * Execute a task immediately (manual trigger) */ async executeTask(id: string): Promise<{ success: boolean; message: string }> { const task = this.storage.get(id); if (!task) { return { success: false, message: `Task not found: ${id}` }; } return await this.runTask(task); } /** * Schedule a task based on its trigger type */ private async scheduleTask(task: TaskRecord): Promise<void> { if (task.trigger_type === 'interval') { this.scheduleInterval(task); } else if (task.trigger_type === 'cron') { this.scheduleCron(task); } else if (task.trigger_type === 'date') { this.scheduleDate(task); } } /** * Schedule interval-based task */ private scheduleInterval(task: TaskRecord): void { const intervalMs = this.computeIntervalMs(task.trigger_config as IntervalTriggerConfig); if (intervalMs <= 0) { console.error(`Invalid interval configuration for task ${task.id}`); return; } const normalizedInterval = Math.max(1, Math.round(intervalMs)); const timer = setInterval(async () => { await this.runTask(task); }, normalizedInterval); this.intervalTimers.set(task.id, timer); // Calculate next run time const nextRun = new Date(Date.now() + normalizedInterval).toISOString(); task.next_run = nextRun; task.updated_at = new Date().toISOString(); this.storage.upsert(task); } /** * Schedule cron-based task */ private scheduleCron(task: TaskRecord): void { const expression = task.trigger_config.expression; if (!cron.validate(expression)) { console.error(`Invalid cron expression for task ${task.id}: ${expression}`); return; } const cronJob = cron.schedule(expression, async () => { await this.runTask(task); }, { timezone: this.timeZone, }); this.cronJobs.set(task.id, cronJob); const nextRun = this.computeNextRun(task); if (nextRun) { task.next_run = nextRun; task.updated_at = new Date().toISOString(); this.storage.upsert(task); } } /** * Schedule date-based (one-time) task */ private scheduleDate(task: TaskRecord): void { const runDate = new Date(task.trigger_config.run_date); const now = new Date(); if (runDate <= now) { console.error(`Run date is in the past for task ${task.id}`); return; } const delay = runDate.getTime() - now.getTime(); const timer = setTimeout(async () => { this.intervalTimers.delete(task.id); await this.runTask(task); }, delay); this.intervalTimers.set(task.id, timer); task.next_run = runDate.toISOString(); task.updated_at = new Date().toISOString(); this.storage.upsert(task); } /** * Unschedule a task */ private unscheduleTask(id: string): void { const cronJob = this.cronJobs.get(id); if (cronJob) { cronJob.stop(); this.cronJobs.delete(id); } const timer = this.intervalTimers.get(id); if (timer) { clearTimeout(timer); clearInterval(timer); this.intervalTimers.delete(id); } } /** * Run a task */ private async runTask(task: TaskRecord): Promise<{ success: boolean; message: string }> { const start = new Date(); const startIso = start.toISOString(); task.status = 'running'; task.last_run = startIso; task.last_status = 'running'; task.last_message = undefined; task.updated_at = startIso; this.storage.upsert(task); try { let message: string; // 🎯 New: Use MCP Sampling if agent_prompt is provided if (task.agent_prompt) { if (this.mcpServer) { console.error(`[${task.id}] Executing via MCP Sampling: "${task.agent_prompt}"`); const response = await this.mcpServer.request( { method: 'sampling/createMessage', params: { messages: [{ role: 'user', content: { type: 'text', text: task.agent_prompt } }], includeContext: 'allServers', maxTokens: 2000 } }, CreateMessageResultSchema, { timeout: this.samplingTimeoutMs, } ); const content = (response as any).content?.text || JSON.stringify(response); message = `Sampling response: ${content}`; console.error(`[${task.id}] Sampling completed: ${content.substring(0, 200)}...`); } else { message = 'Task configured with agent_prompt but MCP server is not ready to send sampling requests'; console.error(`[${task.id}] ${message}`); } } else if (task.mcp_server && task.mcp_tool) { // Legacy: Log MCP tool call (but don't actually call it - servers can't call each other) message = `Task configured (legacy): ${task.mcp_server}.${task.mcp_tool}`; console.error(`[${task.id}] ${message} - Note: MCP servers cannot directly call other servers. Consider using agent_prompt with sampling instead.`); } else { // No action configured message = `Task executed: ${task.id} (no action configured)`; console.error(`[${task.id}] ${message}`); } task.last_status = 'success'; task.last_message = message; task.status = task.trigger_type === 'date' ? 'completed' : 'scheduled'; const historyEntry: TaskHistoryEntry = { run_at: startIso, status: 'success', message, }; this.appendHistory(task, historyEntry); if (task.trigger_type === 'date') { task.enabled = false; task.next_run = undefined; } else { task.next_run = this.computeNextRun(task, start); } task.updated_at = new Date().toISOString(); this.storage.upsert(task); return { success: true, message }; } catch (error: any) { let errorMessage = error?.message || String(error); if (error?.code === -32001) { errorMessage = `Sampling request timed out after ${Math.round(this.samplingTimeoutMs / 1000)}s`; } console.error(`[${task.id}] Task failed:`, error); task.last_status = 'error'; task.last_message = errorMessage; task.status = 'error'; task.next_run = this.computeNextRun(task, new Date()); const historyEntry: TaskHistoryEntry = { run_at: startIso, status: 'error', message: errorMessage, }; this.appendHistory(task, historyEntry); task.updated_at = new Date().toISOString(); this.storage.upsert(task); return { success: false, message: errorMessage }; } } /** * Shutdown scheduler */ async shutdown(): Promise<void> { // Stop all cron jobs for (const cronJob of this.cronJobs.values()) { cronJob.stop(); } this.cronJobs.clear(); // Clear all timers for (const timer of this.intervalTimers.values()) { clearTimeout(timer); clearInterval(timer); } this.intervalTimers.clear(); } /** * Generate unique task ID */ private generateId(): string { return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } }