Skip to main content
Glama

Spec Workflow MCP

server.ts27.4 kB
import fastify, { FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyWebsocket from '@fastify/websocket'; import { join, dirname, basename, resolve } from 'path'; import { readFile } from 'fs/promises'; import { promises as fs } from 'fs'; import { fileURLToPath } from 'url'; import { SpecWatcher } from './watcher.js'; import { SpecParser } from './parser.js'; import open from 'open'; import { WebSocket } from 'ws'; import { findAvailablePort, validateAndCheckPort, checkExistingDashboard, DASHBOARD_TEST_MESSAGE } from './utils.js'; import { ApprovalStorage } from './approval-storage.js'; import { parseTasksFromMarkdown } from '../core/task-parser.js'; import { SpecArchiveService } from '../core/archive-service.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); interface WebSocketConnection { socket: WebSocket; } export interface DashboardOptions { projectPath: string; autoOpen?: boolean; port?: number; } export class DashboardServer { private app: FastifyInstance; private watcher: SpecWatcher; private parser: SpecParser; private approvalStorage: ApprovalStorage; private archiveService: SpecArchiveService; private options: DashboardOptions; private actualPort: number = 0; private clients: Set<WebSocket> = new Set(); private packageVersion: string = 'unknown'; private isUsingExistingDashboard: boolean = false; constructor(options: DashboardOptions) { this.options = options; this.parser = new SpecParser(options.projectPath); this.watcher = new SpecWatcher(options.projectPath, this.parser); this.approvalStorage = new ApprovalStorage(options.projectPath); this.archiveService = new SpecArchiveService(options.projectPath); this.app = fastify({ logger: false }); } async start() { // Check for existing dashboard FIRST before allocating any resources if (this.options.port) { const existingDashboard = await checkExistingDashboard(this.options.port); if (existingDashboard) { this.actualPort = this.options.port; this.isUsingExistingDashboard = true; const existingUrl = `http://localhost:${this.actualPort}`; console.error(`Dashboard already running at ${existingUrl} - connecting to existing instance`); // Return early BEFORE starting any watchers or registering routes // This prevents resource leaks when connecting to existing dashboards return existingUrl; } } // Only proceed with initialization if we're creating a new dashboard // Fetch package version once at startup try { const response = await fetch('https://registry.npmjs.org/@pimzino/spec-workflow-mcp/latest'); if (response.ok) { const packageInfo = await response.json() as { version?: string }; this.packageVersion = packageInfo.version || 'unknown'; } } catch { // Fallback to local package.json version if npm request fails try { const packageJsonPath = join(__dirname, '..', '..', 'package.json'); const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); const packageJson = JSON.parse(packageJsonContent) as { version?: string }; this.packageVersion = packageJson.version || 'unknown'; } catch { // Keep default 'unknown' if both npm and local package.json fail } } // Register plugins await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', }); await this.app.register(fastifyWebsocket); // WebSocket endpoint for real-time updates const self = this; this.app.register(async function (fastify) { fastify.get('/ws', { websocket: true }, (connection: WebSocketConnection) => { const socket = connection.socket; // WebSocket client connected // Add client to set self.clients.add(socket); // Send initial state Promise.all([ self.parser.getAllSpecs(), self.approvalStorage.getAllPendingApprovals() ]) .then(([specs, approvals]) => { socket.send( JSON.stringify({ type: 'initial', data: { specs, approvals }, }) ); }) .catch((error) => { // Error getting initial data }); // Handle client disconnect - ensure all scenarios are covered const cleanup = () => { self.clients.delete(socket); // Remove all listeners to prevent memory leaks socket.removeAllListeners(); }; socket.on('close', cleanup); socket.on('error', cleanup); // Additional safety for abnormal terminations socket.on('disconnect', cleanup); socket.on('end', cleanup); }); }); // Serve Claude icon as favicon this.app.get('/favicon.ico', async (request, reply) => { return reply.sendFile('claude-icon.svg'); }); // API endpoints this.app.get('/api/test', async () => { return { message: DASHBOARD_TEST_MESSAGE }; }); this.app.get('/api/specs', async () => { const specs = await this.parser.getAllSpecs(); return specs; }); this.app.get('/api/specs/archived', async () => { const archivedSpecs = await this.parser.getAllArchivedSpecs(); return archivedSpecs; }); this.app.post('/api/specs/:name/archive', async (request, reply) => { const { name } = request.params as { name: string }; try { await this.archiveService.archiveSpec(name); // Broadcast update to all connected clients this.broadcastSpecUpdate(); return { success: true, message: `Spec '${name}' archived successfully` }; } catch (error: any) { reply.code(400).send({ error: error.message }); } }); this.app.post('/api/specs/:name/unarchive', async (request, reply) => { const { name } = request.params as { name: string }; try { await this.archiveService.unarchiveSpec(name); // Broadcast update to all connected clients this.broadcastSpecUpdate(); return { success: true, message: `Spec '${name}' unarchived successfully` }; } catch (error: any) { reply.code(400).send({ error: error.message }); } }); this.app.get('/api/approvals', async () => { const approvals = await this.approvalStorage.getAllPendingApprovals(); return approvals; }); // Get file content for an approval request this.app.get('/api/approvals/:id/content', async (request, reply) => { const { id } = request.params as { id: string }; try { const approval = await this.approvalStorage.getApproval(id); if (!approval || !approval.filePath) { return reply.code(404).send({ error: 'Approval not found or no file path' }); } // Try several resolution strategies for robustness across environments const candidates: string[] = []; const p = approval.filePath; // 1) As provided if absolute or relative to project root candidates.push(join(this.approvalStorage.projectPath, p)); // 2) If path is already absolute, try it directly (join with absolute will normalize on some platforms) if (p.startsWith('/') || p.match(/^[A-Za-z]:[\\\/]/)) { candidates.push(p); } // 3) If not already under .spec-workflow, try under that root if (!p.includes('.spec-workflow')) { candidates.push(join(this.approvalStorage.projectPath, '.spec-workflow', p)); } let content: string | null = null; let resolvedPath: string | null = null; for (const candidate of candidates) { try { const data = await fs.readFile(candidate, 'utf-8'); content = data; resolvedPath = candidate; break; } catch { // try next candidate } } if (content == null) { return reply.code(500).send({ error: `Failed to read file at any known location for ${approval.filePath}` }); } return { content, filePath: resolvedPath || approval.filePath }; } catch (error: any) { reply.code(500).send({ error: `Failed to read file: ${error.message}` }); } }); this.app.get('/api/info', async () => { // Resolve the project path to get the actual directory name const resolvedPath = resolve(this.options.projectPath); const projectName = basename(resolvedPath) || 'Project'; const steeringStatus = await this.parser.getProjectSteeringStatus(); // Use cached version fetched at startup return { projectName, steering: steeringStatus, dashboardUrl: `http://localhost:${this.actualPort}`, version: this.packageVersion }; }); this.app.get('/api/specs/:name', async (request, reply) => { const { name } = request.params as { name: string }; const spec = await this.parser.getSpec(name); if (!spec) { reply.code(404).send({ error: 'Spec not found' }); } return spec; }); // Get raw markdown content for specs this.app.get('/api/specs/:name/:document', async (request, reply) => { const { name, document } = request.params as { name: string; document: string }; const allowedDocs = ['requirements', 'design', 'tasks']; if (!allowedDocs.includes(document)) { reply.code(400).send({ error: 'Invalid document type' }); return; } const docPath = join(this.options.projectPath, '.spec-workflow', 'specs', name, `${document}.md`); try { const content = await readFile(docPath, 'utf-8'); return { content }; } catch { reply.code(404).send({ error: 'Document not found' }); } }); // Save/update spec document content (active specs) this.app.put('/api/specs/:name/:document', async (request, reply) => { const { name, document } = request.params as { name: string; document: string }; const { content } = request.body as { content: string }; const allowedDocs = ['requirements', 'design', 'tasks']; if (!allowedDocs.includes(document)) { reply.code(400).send({ error: 'Invalid document type' }); return; } if (typeof content !== 'string') { reply.code(400).send({ error: 'Content must be a string' }); return; } const docPath = join(this.options.projectPath, '.spec-workflow', 'specs', name, `${document}.md`); try { // Ensure the spec directory exists const specDir = join(this.options.projectPath, '.spec-workflow', 'specs', name); await fs.mkdir(specDir, { recursive: true }); // Write the content to file await fs.writeFile(docPath, content, 'utf-8'); return { success: true, message: 'Document saved successfully' }; } catch (error: any) { reply.code(500).send({ error: `Failed to save document: ${error.message}` }); } }); // Save/update archived spec document content this.app.put('/api/specs/:name/:document/archived', async (request, reply) => { const { name, document } = request.params as { name: string; document: string }; const { content } = request.body as { content: string }; const allowedDocs = ['requirements', 'design', 'tasks']; if (!allowedDocs.includes(document)) { reply.code(400).send({ error: 'Invalid document type' }); return; } if (typeof content !== 'string') { reply.code(400).send({ error: 'Content must be a string' }); return; } const docPath = join(this.options.projectPath, '.spec-workflow', 'archive', 'specs', name, `${document}.md`); try { // Ensure the archived spec directory exists const specDir = join(this.options.projectPath, '.spec-workflow', 'archive', 'specs', name); await fs.mkdir(specDir, { recursive: true }); // Write the content to file await fs.writeFile(docPath, content, 'utf-8'); return { success: true, message: 'Archived document saved successfully' }; } catch (error: any) { reply.code(500).send({ error: `Failed to save archived document: ${error.message}` }); } }); // Get all spec documents for real-time viewing this.app.get('/api/specs/:name/all', async (request, reply) => { const { name } = request.params as { name: string }; const specDir = join(this.options.projectPath, '.spec-workflow', 'specs', name); const documents = ['requirements', 'design', 'tasks']; const result: Record<string, { content: string; lastModified: string } | null> = {}; for (const doc of documents) { const docPath = join(specDir, `${doc}.md`); try { const content = await readFile(docPath, 'utf-8'); const stats = await fs.stat(docPath); result[doc] = { content, lastModified: stats.mtime.toISOString() }; } catch { result[doc] = null; } } return result; }); // Get all archived spec documents for read-only viewing this.app.get('/api/specs/:name/all/archived', async (request, reply) => { const { name } = request.params as { name: string }; const specDir = join(this.options.projectPath, '.spec-workflow', 'archive', 'specs', name); const documents = ['requirements', 'design', 'tasks']; const result: Record<string, { content: string; lastModified: string } | null> = {}; for (const doc of documents) { const docPath = join(specDir, `${doc}.md`); try { const content = await readFile(docPath, 'utf-8'); const stats = await fs.stat(docPath); result[doc] = { content, lastModified: stats.mtime.toISOString() }; } catch { result[doc] = null; } } return result; }); // Get steering document content this.app.get('/api/steering/:name', async (request, reply) => { const { name } = request.params as { name: string }; const allowedDocs = ['product', 'tech', 'structure']; if (!allowedDocs.includes(name)) { reply.code(400).send({ error: 'Invalid steering document name' }); return; } const docPath = join(this.options.projectPath, '.spec-workflow', 'steering', `${name}.md`); try { const content = await readFile(docPath, 'utf-8'); const stats = await fs.stat(docPath); return { content, lastModified: stats.mtime.toISOString() }; } catch { // Return empty content for non-existent documents to allow creation return { content: '', lastModified: new Date().toISOString() }; } }); // Save/update steering document content this.app.put('/api/steering/:name', async (request, reply) => { const { name } = request.params as { name: string }; const { content } = request.body as { content: string }; const allowedDocs = ['product', 'tech', 'structure']; if (!allowedDocs.includes(name)) { reply.code(400).send({ error: 'Invalid steering document name' }); return; } if (typeof content !== 'string') { reply.code(400).send({ error: 'Content must be a string' }); return; } const steeringDir = join(this.options.projectPath, '.spec-workflow', 'steering'); const docPath = join(steeringDir, `${name}.md`); try { // Ensure the steering directory exists await fs.mkdir(steeringDir, { recursive: true }); // Write the content to file await fs.writeFile(docPath, content, 'utf-8'); // Broadcast steering update to all connected clients await this.broadcastSteeringUpdate(); return { success: true, message: 'Steering document saved successfully' }; } catch (error: any) { reply.code(500).send({ error: `Failed to save steering document: ${error.message}` }); } }); // Get task progress for a specific spec this.app.get('/api/specs/:name/tasks/progress', async (request, reply) => { const { name } = request.params as { name: string }; try { const spec = await this.parser.getSpec(name); if (!spec || !spec.phases.tasks.exists) { return reply.code(404).send({ error: 'Spec or tasks not found' }); } // Parse tasks.md file for detailed task information const tasksPath = join(this.options.projectPath, '.spec-workflow', 'specs', name, 'tasks.md'); const tasksContent = await readFile(tasksPath, 'utf-8'); const parseResult = parseTasksFromMarkdown(tasksContent); // Count tasks from our detailed parsing (includes all subtasks) const totalTasks = parseResult.summary.total; const completedTasks = parseResult.summary.completed; const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; return { total: totalTasks, completed: completedTasks, inProgress: parseResult.inProgressTask, progress: progress, taskList: parseResult.tasks, lastModified: spec.phases.tasks.lastModified || spec.lastModified }; } catch (error: any) { reply.code(500).send({ error: `Failed to get task progress: ${error.message}` }); } }); // Update task status this.app.put('/api/specs/:name/tasks/:taskId/status', async (request, reply) => { const { name, taskId } = request.params as { name: string; taskId: string }; const { status } = request.body as { status: 'pending' | 'in-progress' | 'completed' }; if (!status || !['pending', 'in-progress', 'completed'].includes(status)) { return reply.code(400).send({ error: 'Invalid status. Must be pending, in-progress, or completed' }); } try { const tasksPath = join(this.options.projectPath, '.spec-workflow', 'specs', name, 'tasks.md'); // Check if tasks file exists let tasksContent: string; try { tasksContent = await readFile(tasksPath, 'utf-8'); } catch (error: any) { if (error.code === 'ENOENT') { return reply.code(404).send({ error: 'Tasks file not found' }); } throw error; } // Parse tasks to verify taskId exists const parseResult = parseTasksFromMarkdown(tasksContent); const task = parseResult.tasks.find(t => t.id === taskId); if (!task) { return reply.code(404).send({ error: `Task ${taskId} not found` }); } // Update task status in markdown const { updateTaskStatus } = await import('../core/task-parser.js'); const updatedContent = updateTaskStatus(tasksContent, taskId, status); if (updatedContent === tasksContent) { return reply.code(400).send({ error: `Could not update task ${taskId} status` }); } // Write updated content await fs.writeFile(tasksPath, updatedContent, 'utf-8'); // Broadcast task update to all connected clients this.broadcastTaskUpdate(name); return { success: true, message: `Task ${taskId} status updated to ${status}`, task: { ...task, status } }; } catch (error: any) { reply.code(500).send({ error: `Failed to update task status: ${error.message}` }); } }); // Approval endpoints this.app.post('/api/approvals/:id/approve', async (request, reply) => { const { id } = request.params as { id: string }; const { response, annotations, comments } = request.body as { response: string; annotations?: string; comments?: any[]; }; try { await this.approvalStorage.updateApproval(id, 'approved', response, annotations, comments); this.broadcastApprovalUpdate(); return { success: true }; } catch (error: any) { reply.code(404).send({ error: error.message }); } }); this.app.post('/api/approvals/:id/reject', async (request, reply) => { const { id } = request.params as { id: string }; const { response, annotations, comments } = request.body as { response: string; annotations?: string; comments?: any[]; }; try { await this.approvalStorage.updateApproval(id, 'rejected', response, annotations, comments); this.broadcastApprovalUpdate(); return { success: true }; } catch (error: any) { reply.code(404).send({ error: error.message }); } }); this.app.post('/api/approvals/:id/needs-revision', async (request, reply) => { const { id } = request.params as { id: string }; const { response, annotations, comments } = request.body as { response: string; annotations?: string; comments?: any[]; }; try { await this.approvalStorage.updateApproval(id, 'needs-revision', response, annotations, comments); this.broadcastApprovalUpdate(); return { success: true }; } catch (error: any) { reply.code(404).send({ error: error.message }); } }); // Set up file watcher for specs this.watcher.on('change', (event) => { // Broadcast to all connected clients const message = JSON.stringify({ type: 'update', data: event, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); }); // Set up task update watcher this.watcher.on('task-update', (event) => { // When task files change externally, broadcast proper task status update // This ensures the UI gets the same structured data as API updates this.broadcastTaskUpdate(event.specName); }); // Set up steering change watcher this.watcher.on('steering-change', (event) => { // Broadcast steering updates to all connected clients const message = JSON.stringify({ type: 'steering-update', data: event.steeringStatus, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); }); // Set up approval file watcher this.approvalStorage.on('approval-change', () => { this.broadcastApprovalUpdate(); }); // Start watcher - only starts for new dashboard instances await this.watcher.start(); await this.approvalStorage.start(); // Allocate port - use custom port if provided, otherwise use ephemeral port if (this.options.port) { // Validate and check custom port availability // We already checked for existing dashboard earlier, so just validate the port await validateAndCheckPort(this.options.port); this.actualPort = this.options.port; console.error(`Using custom port: ${this.actualPort}`); } else { // Find available ephemeral port this.actualPort = await findAvailablePort(); console.error(`Using ephemeral port: ${this.actualPort}`); } // Start server await this.app.listen({ port: this.actualPort, host: '0.0.0.0' }); // Open browser if requested if (this.options.autoOpen) { await open(`http://localhost:${this.actualPort}`); } return `http://localhost:${this.actualPort}`; } private async broadcastApprovalUpdate() { try { const approvals = await this.approvalStorage.getAllPendingApprovals(); const message = JSON.stringify({ type: 'approval-update', data: approvals, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); } catch (error) { // Error broadcasting approval update } } private async broadcastSpecUpdate() { try { const [specs, archivedSpecs] = await Promise.all([ this.parser.getAllSpecs(), this.parser.getAllArchivedSpecs() ]); const message = JSON.stringify({ type: 'spec-update', data: { specs, archivedSpecs }, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); } catch (error) { // Error broadcasting spec update } } private async broadcastSteeringUpdate() { try { const steeringStatus = await this.parser.getProjectSteeringStatus(); const message = JSON.stringify({ type: 'steering-update', data: steeringStatus, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); } catch (error) { // Error broadcasting steering update } } private async broadcastTaskUpdate(specName: string) { try { // Get updated task progress for the specific spec const tasksPath = join(this.options.projectPath, '.spec-workflow', 'specs', specName, 'tasks.md'); const tasksContent = await readFile(tasksPath, 'utf-8'); const parseResult = parseTasksFromMarkdown(tasksContent); const message = JSON.stringify({ type: 'task-status-update', data: { specName, taskList: parseResult.tasks, summary: parseResult.summary, inProgress: parseResult.inProgressTask }, }); this.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN client.send(message); } }); } catch (error) { // Error broadcasting task update } } async stop() { // If we connected to an existing dashboard, we shouldn't stop it // Only stop if we actually started our own server if (this.isUsingExistingDashboard) { console.error('Using existing dashboard instance - not stopping it'); return; } // Close all WebSocket connections with proper cleanup this.clients.forEach((client) => { try { client.removeAllListeners(); if (client.readyState === 1) { // WebSocket.OPEN client.close(); } } catch (error) { // Ignore cleanup errors } }); this.clients.clear(); // Remove all event listeners from watchers to prevent memory leaks this.watcher.removeAllListeners(); this.approvalStorage.removeAllListeners(); // Stop the watchers await this.watcher.stop(); await this.approvalStorage.stop(); // Close the Fastify server await this.app.close(); } getUrl(): string { return `http://localhost:${this.actualPort}`; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Pimzino/spec-workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server