Project Handoffs MCP Server

  • src
#!/usr/bin/env node 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 { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { HANDOFF_TEMPLATES } from './templates.js'; import { NextStep, WorkingSession, Handoff, ProjectMetadata } from './types.js'; import { ProjectError } from './errors.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BASE_STORAGE_DIR = path.join(__dirname, 'project_data'); interface ProjectData { nextSteps: NextStep[]; workingSessions: WorkingSession[]; handoffs: Handoff[]; } class ProjectManager { private metadataPath: string; constructor() { this.metadataPath = path.join(BASE_STORAGE_DIR, 'projects.json'); } private async ensureStorageExists(): Promise<void> { try { await fs.mkdir(BASE_STORAGE_DIR, { recursive: true }); try { await fs.access(this.metadataPath); } catch { await fs.writeFile(this.metadataPath, JSON.stringify({ projects: [] }, null, 2)); } } catch (error) { throw new ProjectError(`Failed to initialize storage: ${error}`, 'system'); } } private async loadMetadata(): Promise<ProjectMetadata[]> { await this.ensureStorageExists(); try { const data = await fs.readFile(this.metadataPath, 'utf-8'); return JSON.parse(data).projects; } catch (error) { throw new ProjectError(`Failed to load metadata: ${error}`, 'system'); } } private async saveMetadata(projects: ProjectMetadata[]): Promise<void> { try { await fs.writeFile(this.metadataPath, JSON.stringify({ projects }, null, 2)); } catch (error) { throw new ProjectError(`Failed to save metadata: ${error}`, 'system'); } } private async loadProjectData(projectId: string): Promise<ProjectData> { try { const dataPath = path.join(BASE_STORAGE_DIR, `${projectId}.json`); try { const data = await fs.readFile(dataPath, 'utf-8'); return JSON.parse(data); } catch (error) { if ((error as any).code === 'ENOENT') { return { nextSteps: [], workingSessions: [], handoffs: [] }; } throw error; } } catch (error) { throw new ProjectError(`Failed to load project data: ${error}`, projectId); } } private async saveProjectData(projectId: string, data: ProjectData): Promise<void> { try { const dataPath = path.join(BASE_STORAGE_DIR, `${projectId}.json`); await fs.writeFile(dataPath, JSON.stringify(data, null, 2)); } catch (error) { throw new ProjectError(`Failed to save project data: ${error}`, projectId); } } private validateTemplate(type: string, data: Record<string, any>): void { const template = HANDOFF_TEMPLATES[type]; if (!template) return; // Non-templated types are valid const missingFields = template.fields .filter(field => field.required) .filter(field => { const fieldName = field.name.toLowerCase().replace(/\s+/g, '_'); return !data[fieldName] && !data[field.name]; }); if (missingFields.length > 0) { throw new ProjectError( `Missing required fields for ${type}: ${missingFields.map(f => f.name).join(', ')}`, data.projectId || 'validation' ); } } async createProject(name: string, description: string): Promise<ProjectMetadata> { const projects = await this.loadMetadata(); const id = name.toLowerCase().replace(/[^a-z0-9]/g, '_'); if (projects.some(p => p.id === id)) { throw new ProjectError('Project with this name already exists', id); } const newProject: ProjectMetadata = { id, name, description, created: new Date().toISOString(), lastAccessed: new Date().toISOString() }; projects.push(newProject); await this.saveMetadata(projects); await this.saveProjectData(id, { nextSteps: [], workingSessions: [], handoffs: [] }); return newProject; } async deleteProject(projectId: string): Promise<void> { const projects = await this.loadMetadata(); const projectIndex = projects.findIndex(p => p.id === projectId); if (projectIndex === -1) { throw new ProjectError('Project not found', projectId); } // Remove project metadata projects.splice(projectIndex, 1); await this.saveMetadata(projects); // Delete project data file try { await fs.unlink(path.join(BASE_STORAGE_DIR, `${projectId}.json`)); } catch (error) { console.error(`Failed to delete project data file: ${error}`); } } async createNextStep(projectId: string, step: Omit<NextStep, 'id' | 'created' | 'lastModified'>): Promise<NextStep> { this.validateTemplate('next_step', step); const data = await this.loadProjectData(projectId); const newStep: NextStep = { ...step, id: `step_${Date.now()}`, created: new Date().toISOString(), lastModified: new Date().toISOString(), }; // Validate dependencies exist if (step.dependencies?.length) { const missingDeps = step.dependencies.filter( depId => !data.nextSteps.some(s => s.id === depId) ); if (missingDeps.length > 0) { throw new ProjectError(`Dependencies not found: ${missingDeps.join(', ')}`, projectId); } } data.nextSteps.push(newStep); await this.saveProjectData(projectId, data); return newStep; } async startWorkingSession(projectId: string, nextStepId: string): Promise<WorkingSession> { const data = await this.loadProjectData(projectId); const step = data.nextSteps.find(s => s.id === nextStepId); if (!step) { throw new ProjectError(`Next step not found: ${nextStepId}`, projectId); } if (step.status !== 'open') { throw new ProjectError(`Next step is not open: ${nextStepId}`, projectId); } step.status = 'in-progress'; step.lastModified = new Date().toISOString(); const session: WorkingSession = { id: `session_${Date.now()}`, nextStepId, startTime: new Date().toISOString(), progressNotes: [], blockers: [], decisions: [] }; data.workingSessions.push(session); await this.saveProjectData(projectId, data); return session; } async createHandoff( projectId: string, sessionId: string, handoff: Omit<Handoff, 'id' | 'sessionId' | 'timestamp'> ): Promise<Handoff> { this.validateTemplate('handoff', handoff); const data = await this.loadProjectData(projectId); const session = data.workingSessions.find(s => s.id === sessionId); if (!session) { throw new ProjectError(`Session not found: ${sessionId}`, projectId); } if (session.endTime) { throw new ProjectError(`Session already ended: ${sessionId}`, projectId); } const step = data.nextSteps.find(s => s.id === session.nextStepId); if (!step) { throw new ProjectError(`Associated next step not found: ${session.nextStepId}`, projectId); } session.endTime = new Date().toISOString(); step.status = 'completed'; step.lastModified = new Date().toISOString(); const newHandoff: Handoff = { ...handoff, id: `handoff_${Date.now()}`, sessionId, timestamp: new Date().toISOString() }; data.handoffs.push(newHandoff); await this.saveProjectData(projectId, data); return newHandoff; } async getLatestNextSteps(projectId: string): Promise<NextStep[]> { const data = await this.loadProjectData(projectId); return data.nextSteps .filter(step => step.status === 'open') .sort((a, b) => { const priorityOrder = { 'core-critical': 0, 'full-required': 1, 'enhancement': 2 }; return priorityOrder[a.priority] - priorityOrder[b.priority]; }); } async getNextStepHistory(projectId: string, stepId: string): Promise<{ step: NextStep; session?: WorkingSession; handoff?: Handoff; }> { const data = await this.loadProjectData(projectId); const step = data.nextSteps.find(s => s.id === stepId); if (!step) { throw new ProjectError(`Next step not found: ${stepId}`, projectId); } const session = data.workingSessions.find(s => s.nextStepId === stepId); const handoff = session ? data.handoffs.find(h => h.sessionId === session.id) : undefined; return { step, session, handoff }; } } const projectManager = new ProjectManager(); const server = new Server({ name: "project-handoffs", version: "1.0.0", }, { capabilities: { tools: {}, }, }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list_templates", description: "List available templates for next steps, working sessions, and handoffs", inputSchema: { type: "object", properties: {} } }, { name: "create_project", description: "Create a new project for tracking AI session handoffs", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name" }, description: { type: "string", description: "Project description" } }, required: ["name", "description"] } }, { name: "delete_project", description: "Delete a project and all its data", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" } }, required: ["projectId"] } }, { name: "create_next_step", description: "Create a new next step in a project", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" }, title: { type: "string", description: "Brief title of the next step" }, description: { type: "string", description: "Detailed description of work" }, priority: { type: "string", enum: ["core-critical", "full-required", "enhancement"], description: "Implementation priority level" }, parentStepId: { type: "string", description: "ID of parent step if this is a substep" }, dependencies: { type: "array", items: { type: "string" }, description: "IDs of steps that must be completed first" } }, required: ["projectId", "title", "description", "priority"] } }, { name: "start_working_session", description: "Start working on a next step", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" }, nextStepId: { type: "string", description: "ID of the next step to work on" } }, required: ["projectId", "nextStepId"] } }, { name: "create_handoff", description: "Complete a working session with handoff details", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" }, sessionId: { type: "string", description: "Working session ID" }, completedWork: { type: "string", description: "Summary of completed work" }, codeState: { type: "string", description: "Current state of codebase" }, environmentState: { type: "string", description: "Development environment state" }, unresolvedIssues: { type: "array", items: { type: "string" }, description: "List of unresolved issues" }, newNextSteps: { type: "array", items: { type: "string" }, description: "List of new next steps identified" } }, required: ["projectId", "sessionId", "completedWork", "codeState", "environmentState"] } }, { name: "get_latest_next_steps", description: "Get open next steps ordered by priority", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" } }, required: ["projectId"] } }, { name: "get_next_step_history", description: "Get complete history of a next step including session and handoff", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project identifier" }, stepId: { type: "string", description: "Next step ID" } }, required: ["projectId", "stepId"] } } ] }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!args) { throw new ProjectError(`No arguments provided for tool: ${name}`, 'system'); } try { switch (name) { case "list_templates": return { content: [{ type: "text", text: JSON.stringify(HANDOFF_TEMPLATES, null, 2) }] }; case "create_project": const project = await projectManager.createProject( args.name as string, args.description as string ); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] }; case "delete_project": await projectManager.deleteProject(args.projectId as string); return { content: [{ type: "text", text: "Project deleted successfully" }] }; case "create_next_step": const step = await projectManager.createNextStep( args.projectId as string, { projectId: args.projectId as string, title: args.title as string, description: args.description as string, priority: args.priority as NextStep['priority'], parentStepId: args.parentStepId as string, dependencies: args.dependencies as string[] || [], status: 'open' } ); return { content: [{ type: "text", text: JSON.stringify(step, null, 2) }] }; case "start_working_session": const session = await projectManager.startWorkingSession( args.projectId as string, args.nextStepId as string ); return { content: [{ type: "text", text: JSON.stringify(session, null, 2) }] }; case "create_handoff": const handoff = await projectManager.createHandoff( args.projectId as string, args.sessionId as string, { completedWork: args.completedWork as string, codeState: args.codeState as string, environmentState: args.environmentState as string, unresolvedIssues: args.unresolvedIssues as string[] || [], newNextSteps: args.newNextSteps as string[] || [] } ); return { content: [{ type: "text", text: JSON.stringify(handoff, null, 2) }] }; case "get_latest_next_steps": const steps = await projectManager.getLatestNextSteps(args.projectId as string); return { content: [{ type: "text", text: JSON.stringify(steps, null, 2) }] }; case "get_next_step_history": const history = await projectManager.getNextStepHistory( args.projectId as string, args.stepId as string ); return { content: [{ type: "text", text: JSON.stringify(history, null, 2) }] }; default: throw new ProjectError(`Unknown tool: ${name}`, 'system'); } } catch (error) { if (error instanceof ProjectError) { throw error; } throw new ProjectError(`Unexpected error: ${error}`, 'system'); } }); async function main(): Promise<void> { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Project Handoffs MCP Server running on stdio"); } main().catch((error: Error) => { console.error("Fatal error in main():", error); process.exit(1); });