UNPKG

mcp-handoff-server

Version:

MCP server for managing AI agent handoffs with structured documentation and seamless task transitions

601 lines (587 loc) 22.6 kB
import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import * as fs from 'fs/promises'; import * as path from 'path'; import { z } from 'zod'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Tool input schemas const ReadHandoffInput = z.object({ handoff_id: z.string(), format: z.enum(['full', 'summary']).optional().default('full') }); const CreateHandoffInput = z.object({ type: z.enum(['standard', 'quick']), initialData: z.object({ date: z.string(), time: z.string(), currentState: z.object({ workingOn: z.string(), status: z.string(), nextStep: z.string() }), projectContext: z.string().optional(), environmentStatus: z.object({ details: z.record(z.enum(['✅', '⚠️', '❌'])) }) }) }); const UpdateHandoffInput = z.object({ handoff_id: z.string(), updates: z.array(z.object({ section: z.enum(['progress', 'priorities', 'issues', 'environment', 'context']), content: z.record(z.any()) })) }); const CompleteHandoffInput = z.object({ handoff_id: z.string(), completionData: z.object({ endTime: z.string(), progress: z.array(z.string()), nextSteps: z.array(z.string()), archiveReason: z.string().optional() }) }); const ArchiveHandoffInput = z.object({ handoff_id: z.string(), metadata: z.object({ reason: z.string(), tags: z.array(z.string()), completionStatus: z.enum(['success', 'partial', 'blocked']) }) }); const ListHandoffsInput = z.object({ status: z.enum(['active', 'archived', 'all']), type: z.enum(['standard', 'quick']).optional(), filters: z.object({ dateRange: z.object({ start: z.string(), end: z.string() }).optional(), tags: z.array(z.string()).optional(), hasIssues: z.boolean().optional() }).optional() }); export class HandoffMCPServer { app; port; handoffRoot; constructor(port = parseInt(process.env.PORT || '3001', 10), handoffRoot = process.env.HANDOFF_ROOT || '../handoff-system') { this.app = express(); this.port = port; this.handoffRoot = handoffRoot; this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { this.app.use(cors()); this.app.use(express.json()); } setupRoutes() { this.app.post('/mcp', this.handleMCPRequest.bind(this)); this.app.get('/health', (_, res) => { res.json({ status: 'healthy' }); }); } async initializeFileSystem() { // Create base directories const directories = ['active', 'archive', 'templates']; for (const dir of directories) { const dirPath = path.join(this.handoffRoot, dir); await fs.mkdir(dirPath, { recursive: true }); } // Create template files if they don't exist const standardTemplate = path.join(this.handoffRoot, 'templates', 'handoff-template.md'); const quickTemplate = path.join(this.handoffRoot, 'templates', 'quick-handoff.md'); const templates = { [standardTemplate]: `# MCPaaS.dev Agent Handoff Document **Date**: [YYYY-MM-DD] **Time**: [HH:MM UTC] **Session Duration**: [X hours] **Outgoing Agent**: [Agent ID/Name] **Incoming Agent**: [To be filled by next agent] --- ## 🎯 Project Context **Current Focus**: [Brief description of main objective] **Status**: [Current state of work] ## ✅ Recent Progress - [Completed item 1] - [Completed item 2] ## 🔄 Active Work **Working On**: [Current primary task] **Status**: [How far along] **Next Step**: [Very specific next action] ## 🌍 Environment Status - **Server**: ✅/⚠️/❌ [Status details] - **Database**: ✅/⚠️/❌ [Status details] - **Cache**: ✅/⚠️/❌ [Status details] ## ⚠️ Known Issues - [Issue description] - [Another issue]`, [quickTemplate]: `# Quick Handoff - MCPaaS.dev **Date**: [YYYY-MM-DD HH:MM UTC] **Duration**: [X minutes/hours] --- ## 🎯 Current State **Working On**: [Current primary task] **Status**: [How far along] **Next Step**: [Very specific next action] ## ✅ Just Completed 1. [Most recent accomplishment] 2. [Another recent accomplishment] ## 🔥 Immediate Priorities 1. [Critical task 1] 2. [Critical task 2] ## 🌍 Environment Status - **Server**: ✅/⚠️/❌ [Status] - **Database**: ✅/⚠️/❌ [Status] - **Cache**: ✅/⚠️/❌ [Status]` }; for (const [filepath, content] of Object.entries(templates)) { try { await fs.access(filepath); } catch { await fs.writeFile(filepath, content, 'utf-8'); } } console.log('Handoff system initialized with required directories and templates'); } async readHandoff(params) { const filePath = this.resolveHandoffPath(params.handoff_id); const content = await fs.readFile(filePath, 'utf-8'); if (params.format === 'summary') { return this.createHandoffSummary(content); } return { content, metadata: await this.getHandoffMetadata(params.handoff_id) }; } async createHandoff(params) { const template = await this.loadTemplate(params.type); const content = this.populateTemplate(template, params.initialData); const handoff_id = this.generateHandoffId(params.initialData.date); const filepath = this.resolveHandoffPath(handoff_id); await this.ensureDirectoryExists(filepath); await fs.writeFile(filepath, content, 'utf-8'); return { handoff_id, filepath, status: 'created' }; } async updateHandoff(params) { const filePath = this.resolveHandoffPath(params.handoff_id); const content = await fs.readFile(filePath, 'utf-8'); const updatedContent = this.applyUpdates(content, params.updates); await fs.writeFile(filePath, updatedContent, 'utf-8'); return { status: 'updated', modifiedSections: params.updates.map(u => u.section) }; } async completeHandoff(params) { const filePath = this.resolveHandoffPath(params.handoff_id); const content = await fs.readFile(filePath, 'utf-8'); const updatedContent = this.applyUpdates(content, [{ section: 'progress', content: { completionTime: params.completionData.endTime, completedItems: params.completionData.progress } }]); await fs.writeFile(filePath, updatedContent, 'utf-8'); if (params.completionData.archiveReason) { await this.archiveHandoff({ handoff_id: params.handoff_id, metadata: { reason: params.completionData.archiveReason, tags: ['completed'], completionStatus: 'success' } }); return { status: 'completed', archived: true }; } return { status: 'completed', archived: false }; } async archiveHandoff(params) { const sourceFilePath = this.resolveHandoffPath(params.handoff_id); const archivePath = path.join(this.handoffRoot, 'archive', `${params.handoff_id}.md`); const content = await fs.readFile(sourceFilePath, 'utf-8'); const updatedContent = this.addArchiveMetadata(content, params.metadata); await this.ensureDirectoryExists(archivePath); await fs.writeFile(archivePath, updatedContent, 'utf-8'); await fs.unlink(sourceFilePath); return { status: 'archived', archivePath }; } resolveHandoffPath(handoff_id) { return path.join(this.handoffRoot, 'active', `${handoff_id}.md`); } async loadTemplate(type) { const templatePath = path.join(this.handoffRoot, 'templates', type === 'standard' ? 'handoff-template.md' : 'quick-handoff.md'); return fs.readFile(templatePath, 'utf-8'); } async ensureDirectoryExists(filepath) { const dir = filepath.substring(0, filepath.lastIndexOf('/')); await fs.mkdir(dir, { recursive: true }); } generateHandoffId(date) { return `${date}-${Math.random().toString(36).substring(2, 7)}`; } async getHandoffMetadata(handoff_id) { const filePath = this.resolveHandoffPath(handoff_id); const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); const metadata = {}; for (const line of lines) { if (line.startsWith('**') && line.includes(':')) { const [key, value] = line.replace(/\*\*/g, '').split(':').map(s => s.trim()); metadata[this.camelCase(key)] = value; } if (line.startsWith('---')) break; } return metadata; } createHandoffSummary(content) { const lines = content.split('\n'); const summary = {}; let currentSection = ''; for (const line of lines) { if (line.startsWith('## ')) { currentSection = this.camelCase(line.replace('## ', '').trim()); summary[currentSection] = []; } else if (currentSection && line.trim() && !line.startsWith('---')) { if (line.startsWith('- ') || line.startsWith('* ')) { summary[currentSection].push(line.substring(2).trim()); } } } return summary; } populateTemplate(template, data) { let populated = template; populated = populated.replace('[YYYY-MM-DD]', data.date); populated = populated.replace('[HH:MM UTC]', data.time); populated = populated.replace('[Current primary task]', data.currentState.workingOn); populated = populated.replace('[How far along]', data.currentState.status); populated = populated.replace('[Very specific next action]', data.currentState.nextStep); if (data.projectContext) { populated = populated.replace('[Brief description of main objective]', data.projectContext); } Object.entries(data.environmentStatus.details).forEach(([key, status]) => { const placeholder = new RegExp(`${key}.*: ✅/⚠️/❌`); populated = populated.replace(placeholder, `${key}: ${status}`); }); return populated; } applyUpdates(content, updates) { const lines = content.split('\n'); for (const update of updates) { const sectionStart = lines.findIndex(line => line.toLowerCase().includes(update.section.toLowerCase())); if (sectionStart === -1) continue; const sectionEnd = lines.findIndex((line, i) => i > sectionStart && line.startsWith('## ')); const end = sectionEnd === -1 ? lines.length : sectionEnd; const formattedContent = this.formatSectionContent(update.section, update.content); lines.splice(sectionStart + 1, end - (sectionStart + 1), formattedContent); } return lines.join('\n'); } formatSectionContent(section, content) { let formatted = ''; switch (section) { case 'progress': if (content.completionTime) { formatted += `\n**Completion Time**: ${content.completionTime}\n\n`; } if (content.completedItems) { formatted += '### Completed Items\n'; content.completedItems.forEach((item) => { formatted += `- ${item}\n`; }); } break; case 'priorities': formatted += '\n### High Priority\n'; if (content.highPriority) { content.highPriority.forEach((item) => { formatted += `- ${item}\n`; }); } if (content.mediumPriority) { formatted += '\n### Medium Priority\n'; content.mediumPriority.forEach((item) => { formatted += `- ${item}\n`; }); } break; case 'issues': if (content.critical) { formatted += '\n### Critical Issues\n'; content.critical.forEach((issue) => { formatted += `- ❗ ${issue}\n`; }); } if (content.nonCritical) { formatted += '\n### Non-Critical Issues\n'; content.nonCritical.forEach((issue) => { formatted += `- ⚠️ ${issue}\n`; }); } break; case 'environment': Object.entries(content).forEach(([key, status]) => { formatted += `\n- **${key}**: ${status}`; }); break; case 'context': Object.entries(content).forEach(([key, value]) => { formatted += `\n### ${key}\n${value}\n`; }); break; } return formatted; } addArchiveMetadata(content, metadata) { const lines = content.split('\n'); const archiveMetadata = [ '## 📦 Archive Information', `**Archive Date**: ${new Date().toISOString()}`, `**Archive Reason**: ${metadata.reason}`, `**Completion Status**: ${metadata.completionStatus}`, `**Tags**: ${metadata.tags.join(', ')}`, '' ]; const headerEnd = lines.findIndex(line => line.startsWith('---')) + 1; lines.splice(headerEnd, 0, ...archiveMetadata); return lines.join('\n'); } parseHandoffInfo(content) { const lines = content.split('\n'); const info = { id: '', type: 'standard', title: '', date: '', status: '', priority: 0 }; for (const line of lines) { if (line.startsWith('**Date**:')) { info.date = line.split(':')[1].trim(); } else if (line.startsWith('# ')) { info.title = line.substring(2).trim(); } else if (line.includes('Priority Queue')) { info.priority = this.extractPriority(content); } else if (line.includes('Quick Handoff')) { info.type = 'quick'; } if (line.startsWith('---')) break; } info.id = info.date ? this.generateHandoffId(info.date) : `handoff-${Math.random().toString(36).substring(2, 7)}`; return info; } extractPriority(content) { const priorityMatch = content.match(/🔥|⚡|📋|💡/g); if (!priorityMatch) return 0; const priorities = { '🔥': 4, '⚡': 3, '📋': 2, '💡': 1 }; return Math.max(...priorityMatch.map(emoji => priorities[emoji])); } isInDateRange(date, range) { const handoffDate = new Date(date); const start = new Date(range.start); const end = new Date(range.end); return handoffDate >= start && handoffDate <= end; } hasIssues(content) { return content.toLowerCase().includes('## ⚠️ known issues') && !content.includes('No known issues'); } hasTags(content, tags) { const contentLower = content.toLowerCase(); return tags.some(tag => contentLower.includes(tag.toLowerCase())); } camelCase(str) { return str .toLowerCase() .replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase()) .replace(/^[A-Z]/, c => c.toLowerCase()); } async listHandoffs(params) { const handoffs = []; const searchDirs = params.status === 'all' ? ['active', 'archive'] : [params.status === 'archived' ? 'archive' : 'active']; for (const dir of searchDirs) { const dirPath = path.join(this.handoffRoot, dir); try { const files = await fs.readdir(dirPath); for (const file of files) { if (!file.endsWith('.md')) continue; const filePath = path.join(dirPath, file); const content = await fs.readFile(filePath, 'utf-8'); const handoffInfo = this.parseHandoffInfo(content); if (params.type && handoffInfo.type !== params.type) continue; if (params.filters?.dateRange && !this.isInDateRange(handoffInfo.date, params.filters.dateRange)) continue; if (params.filters?.hasIssues && !this.hasIssues(content)) continue; if (params.filters?.tags && !this.hasTags(content, params.filters.tags)) continue; handoffs.push(handoffInfo); } } catch (error) { console.error(`Error reading directory ${dir}:`, error); } } return { handoffs: handoffs.sort((a, b) => b.priority - a.priority) }; } // Other private helper methods remain unchanged async handleMCPRequest(req, res) { const { method, params } = req.body; try { switch (method) { case 'read_handoff': { const result = await this.readHandoff(ReadHandoffInput.parse(params)); return res.json(this.createResponse(result)); } case 'create_handoff': { const result = await this.createHandoff(CreateHandoffInput.parse(params)); return res.json(this.createResponse(result)); } case 'update_handoff': { const result = await this.updateHandoff(UpdateHandoffInput.parse(params)); return res.json(this.createResponse(result)); } case 'complete_handoff': { const result = await this.completeHandoff(CompleteHandoffInput.parse(params)); return res.json(this.createResponse(result)); } case 'archive_handoff': { const result = await this.archiveHandoff(ArchiveHandoffInput.parse(params)); return res.json(this.createResponse(result)); } case 'list_handoffs': { const result = await this.listHandoffs(ListHandoffsInput.parse(params)); return res.json(this.createResponse(result)); } default: throw new Error(`Unknown method: ${method}`); } } catch (error) { return res.status(400).json(this.createErrorResponse(error)); } } createResponse(result) { return { jsonrpc: '2.0', result, id: 1 }; } createErrorResponse(error) { const err = error; return { jsonrpc: '2.0', error: { code: -32000, message: err.message || 'Internal server error', data: err }, id: 1 }; } async start() { await this.initializeFileSystem(); this.app.listen(this.port, () => { console.log(`Handoff MCP Server running on port ${this.port}`); }); } } // Handle MCP requests from stdin export async function handleStdinMCP() { const readline = (await import('readline')).createInterface({ input: process.stdin, output: process.stdout }); const server = new HandoffMCPServer(); await server.initializeFileSystem(); readline.on('line', async (line) => { try { const request = JSON.parse(line); const { method, params, id } = request; let result; switch (method) { case 'read_handoff': result = await server.readHandoff(ReadHandoffInput.parse(params)); break; case 'create_handoff': result = await server.createHandoff(CreateHandoffInput.parse(params)); break; case 'update_handoff': result = await server.updateHandoff(UpdateHandoffInput.parse(params)); break; case 'complete_handoff': result = await server.completeHandoff(CompleteHandoffInput.parse(params)); break; case 'archive_handoff': result = await server.archiveHandoff(ArchiveHandoffInput.parse(params)); break; case 'list_handoffs': result = await server.listHandoffs(ListHandoffsInput.parse(params)); break; default: throw new Error(`Unknown method: ${method}`); } console.log(JSON.stringify({ jsonrpc: '2.0', result, id })); } catch (error) { const err = error; console.log(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: err.message || 'Internal server error', data: err }, id: error?.request?.id || 1 })); } }); } // Determine whether to run in HTTP or stdio mode if (process.env.HTTP_MODE === 'true') { const server = new HandoffMCPServer(); server.start().catch(console.error); } else { handleStdinMCP().catch(console.error); }