UNPKG

@pimzino/spec-workflow-mcp

Version:

MCP server for spec-driven development workflow with real-time web dashboard

226 lines 9.29 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { registerTools, handleToolCall } from './tools/index.js'; import { registerPrompts, handlePromptList, handlePromptGet } from './prompts/index.js'; import { validateProjectPath } from './core/path-utils.js'; import { DashboardServer } from './dashboard/server.js'; import { SessionManager } from './core/session-manager.js'; import { WorkspaceInitializer } from './core/workspace-initializer.js'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; export class SpecWorkflowMCPServer { server; projectPath; dashboardServer; dashboardUrl; sessionManager; lang; dashboardMonitoringInterval; constructor() { // Get version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); // Get all registered tools and prompts const tools = registerTools(); const prompts = registerPrompts(); // Create tools capability object with each tool name const toolsCapability = tools.reduce((acc, tool) => { acc[tool.name] = {}; return acc; }, {}); this.server = new Server({ name: 'spec-workflow-mcp', version: packageJson.version }, { capabilities: { tools: toolsCapability, prompts: { listChanged: true } } }); } async initialize(projectPath, dashboardOptions, lang) { this.projectPath = projectPath; this.lang = lang; try { // Validate project path await validateProjectPath(this.projectPath); // Initialize workspace const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); const workspaceInitializer = new WorkspaceInitializer(this.projectPath, packageJson.version); await workspaceInitializer.initializeWorkspace(); // Initialize session manager this.sessionManager = new SessionManager(this.projectPath); // Start dashboard if requested if (dashboardOptions?.autoStart) { this.dashboardServer = new DashboardServer({ projectPath: this.projectPath, autoOpen: true, // Auto-open browser when dashboard is auto-started port: dashboardOptions.port }); this.dashboardUrl = await this.dashboardServer.start(); // Create session tracking (overwrites any existing session.json) await this.sessionManager.createSession(this.dashboardUrl); // Log dashboard startup info console.error(`Dashboard auto-started at: ${this.dashboardUrl}`); } // Create context for tools const context = { projectPath: this.projectPath, dashboardUrl: this.dashboardUrl, sessionManager: this.sessionManager, lang: this.lang }; // Register handlers this.setupHandlers(context); // Connect to stdio transport const transport = new StdioServerTransport(); // Handle client disconnection - exit gracefully when transport closes transport.onclose = async () => { await this.stop(); process.exit(0); }; await this.server.connect(transport); // Monitor stdin for client disconnection (additional safety net) process.stdin.on('end', async () => { await this.stop(); process.exit(0); }); // Handle stdin errors process.stdin.on('error', async (error) => { console.error('stdin error:', error); await this.stop(); process.exit(1); }); // MCP server initialized successfully } catch (error) { throw error; } } setupHandlers(context) { // Tool handlers this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: registerTools() })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { // Create dynamic context with current dashboard URL const dynamicContext = { ...context, dashboardUrl: this.dashboardUrl }; return await handleToolCall(request.params.name, request.params.arguments || {}, dynamicContext); } catch (error) { throw new McpError(ErrorCode.InternalError, error.message); } }); // Prompt handlers this.server.setRequestHandler(ListPromptsRequestSchema, async () => { try { return await handlePromptList(); } catch (error) { throw new McpError(ErrorCode.InternalError, error.message); } }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { try { // Create dynamic context with current dashboard URL const dynamicContext = { ...context, dashboardUrl: this.dashboardUrl }; return await handlePromptGet(request.params.name, request.params.arguments || {}, dynamicContext); } catch (error) { throw new McpError(ErrorCode.InternalError, error.message); } }); } startDashboardMonitoring() { // Check immediately this.checkForDashboardSession(); // Then check every 2 seconds this.dashboardMonitoringInterval = setInterval(() => { this.checkForDashboardSession(); }, 2000); } async checkForDashboardSession() { if (!this.sessionManager) { return; // No session manager } try { const dashboardUrl = await this.sessionManager.getDashboardUrl(); if (dashboardUrl && dashboardUrl !== this.dashboardUrl) { // Test if the dashboard is actually reachable const isReachable = await this.testDashboardConnection(dashboardUrl); if (isReachable) { this.dashboardUrl = dashboardUrl; // Update context for tools that might need dashboard URL // Note: Dashboard URL is now available to MCP tools } } else if (this.dashboardUrl) { // We have a dashboard URL, but let's verify it's still reachable const isReachable = await this.testDashboardConnection(this.dashboardUrl); if (!isReachable) { // Dashboard is no longer reachable, clear it so we can discover a new one this.dashboardUrl = undefined; } } } catch (error) { // Session file doesn't exist yet, continue monitoring if (this.dashboardUrl) { // Clear stale dashboard URL if session file is gone this.dashboardUrl = undefined; } } } async testDashboardConnection(url) { try { // Try to fetch the dashboard's test endpoint with a short timeout const response = await fetch(`${url}/api/test`, { method: 'GET', signal: AbortSignal.timeout(1000) // 1 second timeout }); return response.ok; } catch (error) { // Connection failed return false; } } async stop() { try { // Stop dashboard monitoring if (this.dashboardMonitoringInterval) { clearInterval(this.dashboardMonitoringInterval); this.dashboardMonitoringInterval = undefined; } // Stop dashboard if (this.dashboardServer) { await this.dashboardServer.stop(); this.dashboardServer = undefined; } // Stop MCP server await this.server.close(); } catch (error) { console.error('Error during shutdown:', error); // Continue with shutdown even if there are errors } } getDashboardUrl() { return this.dashboardUrl; } } //# sourceMappingURL=server.js.map