UNPKG

lamplighter-mcp

Version:

An intelligent context engine for AI-assisted software development

350 lines (314 loc) 11.6 kB
import express, { Request, Response } from 'express'; import dotenv from 'dotenv'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { z } from 'zod'; // We'll need this for tool schemas import * as fs from 'fs/promises'; import * as path from 'path'; // --- Core Logic Modules --- import { HistoryLogger } from './modules/historyLogger.js'; import { CodebaseAnalyzer } from './modules/codebaseAnalyzer.js'; import { ConfluenceReader } from './modules/confluenceReader.js'; import { FeatureSpecProcessor } from './modules/featureSpecProcessor.js'; import { TaskManager, TaskStatus } from './modules/taskManager.js'; // Load environment variables from .env file dotenv.config(); // --- Configuration --- const PORT = process.env.PORT || 3001; const SERVER_NAME = 'Lamplighter-MCP'; const SERVER_VERSION = '1.0.0'; const CONTEXT_DIR = process.env.LAMPLIGHTER_CONTEXT_DIR || './lamplighter_context'; // File paths const codebaseSummaryPath = path.join(CONTEXT_DIR, 'codebase_summary.md'); const historyLogPath = path.join(CONTEXT_DIR, 'history_log.md'); const featureTasksDir = path.join(CONTEXT_DIR, 'feature_tasks'); // --- MCP Server Setup --- const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, }); // --- Core Logic Modules (Instantiation) --- const historyLogger = new HistoryLogger(); const codebaseAnalyzer = new CodebaseAnalyzer(); const confluenceReader = new ConfluenceReader(); const featureSpecProcessor = new FeatureSpecProcessor(); const taskManager = new TaskManager(); // Helper function to derive a feature ID from a URL function deriveFeatureId(url: string): string { return featureSpecProcessor.deriveFeatureId(url); } // --- MCP Tools --- // 1. analyze_codebase tool server.tool( 'analyze_codebase', 'Analyzes the current codebase structure and generates a summary', {}, // Empty schema since we don't need any parameters async () => { try { await codebaseAnalyzer.analyze(process.cwd()); await historyLogger.log('Codebase analysis completed successfully'); return { content: [{ type: 'text', text: 'Codebase analysis completed. Check codebase_summary.md for results.' }] }; } catch (error) { console.error('[analyze_codebase] Error:', error); return { content: [{ type: 'text', text: `Error analyzing codebase: ${error}` }], isError: true }; } } ); // 2. process_confluence_spec tool server.tool( 'process_confluence_spec', 'Processes a Confluence specification into actionable tasks', { confluence_url: z.string().url() }, async ({ confluence_url }) => { try { // Fetch content from Confluence const specText = await confluenceReader.fetchPageContent(confluence_url); // Read the codebase summary for context let codebaseSummary: string; try { codebaseSummary = await fs.readFile(codebaseSummaryPath, 'utf-8'); } catch (error) { // If summary doesn't exist, run the analyzer first await codebaseAnalyzer.analyze(process.cwd()); codebaseSummary = await fs.readFile(codebaseSummaryPath, 'utf-8'); } // Derive feature ID from URL const featureId = deriveFeatureId(confluence_url); // Process the specification into tasks const filePath = await featureSpecProcessor.processSpecification(specText, codebaseSummary, featureId); // Log the action await historyLogger.log(`Processed specification from ${confluence_url} as feature "${featureId}"`); return { content: [{ type: 'text', text: `Specification processed successfully. Tasks available at ${filePath}` }] }; } catch (error) { console.error('[process_confluence_spec] Error:', error); return { content: [{ type: 'text', text: `Error processing specification: ${error}` }], isError: true }; } } ); // 3. update_task_status tool server.tool( 'update_task_status', 'Updates the status of a specific task within a feature', { feature_identifier: z.string(), task_identifier: z.string(), new_status: z.enum(['ToDo', 'InProgress', 'Done']) }, async ({ feature_identifier, task_identifier, new_status }) => { try { await taskManager.updateTaskStatus( feature_identifier, task_identifier, new_status as TaskStatus ); await historyLogger.log(`Updated task "${task_identifier}" in feature "${feature_identifier}" to ${new_status}`); return { content: [{ type: 'text', text: `Task status updated to ${new_status}` }] }; } catch (error) { console.error('[update_task_status] Error:', error); return { content: [{ type: 'text', text: `Error updating task status: ${error}` }], isError: true }; } } ); // 4. suggest_next_task tool server.tool( 'suggest_next_task', 'Identifies and returns the next actionable task from a feature', { feature_identifier: z.string() }, async ({ feature_identifier }) => { try { const nextTask = await taskManager.suggestNextTask(feature_identifier); if (nextTask) { return { content: [{ type: 'text', text: `Next task: ${nextTask}` }] }; } else { return { content: [{ type: 'text', text: `No pending tasks found for feature "${feature_identifier}".` }] }; } } catch (error) { console.error('[suggest_next_task] Error:', error); return { content: [{ type: 'text', text: `Error suggesting next task: ${error}` }], isError: true }; } } ); // 5. get_codebase_summary tool server.tool( 'get_codebase_summary', 'Retrieves the codebase summary', {}, async () => { try { const content = await fs.readFile(codebaseSummaryPath, 'utf-8'); return { content: [{ type: 'text', text: content }] }; } catch (error) { console.error('[get_codebase_summary] Error:', error); return { content: [{ type: 'text', text: `Error retrieving codebase summary: ${error}` }], isError: true }; } } ); // 6. get_history_log tool server.tool( 'get_history_log', 'Retrieves the history log', {}, async () => { try { const content = await fs.readFile(historyLogPath, 'utf-8'); return { content: [{ type: 'text', text: content }] }; } catch (error) { console.error('[get_history_log] Error:', error); return { content: [{ type: 'text', text: `Error retrieving history log: ${error}` }], isError: true }; } } ); // 7. get_feature_tasks tool server.tool( 'get_feature_tasks', 'Retrieves tasks for a specific feature', { feature_identifier: z.string() }, async ({ feature_identifier }) => { try { const filePath = path.join(featureTasksDir, `feature_${feature_identifier}_tasks.md`); const content = await fs.readFile(filePath, 'utf-8'); return { content: [{ type: 'text', text: content }] }; } catch (error) { console.error('[get_feature_tasks] Error:', error); return { content: [{ type: 'text', text: `Error retrieving feature tasks: ${error}` }], isError: true }; } } ); // --- Express App Setup --- const app = express(); app.use(express.json()); // Middleware to parse JSON bodies // --- Health Check Endpoint --- app.get('/health', (req: Request, res: Response) => { res.status(200).json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString(), version: SERVER_VERSION, name: SERVER_NAME }); }); // --- Transport Management --- // Store transports keyed by sessionId to handle multiple clients const transports: { [sessionId: string]: SSEServerTransport } = {}; // --- SSE Endpoint --- // Client connects here to establish the MCP connection app.get('/sse', async (req: Request, res: Response) => { console.log(`[${new Date().toISOString()}] Client connected via SSE`); // Use '/messages' as the endpoint for the client to POST messages back to const transport = new SSEServerTransport('/messages', res); transports[transport.sessionId] = transport; // Handle client disconnection req.on('close', () => { console.log(`[${new Date().toISOString()}] Client disconnected (Session ID: ${transport.sessionId})`); delete transports[transport.sessionId]; transport.close(); // Ensure transport resources are cleaned up }); try { // Connect the McpServer to this specific client's transport await server.connect(transport); console.log(`[${new Date().toISOString()}] MCP Server connected to transport (Session ID: ${transport.sessionId})`); } catch (error) { console.error(`[${new Date().toISOString()}] Error connecting MCP Server to transport (Session ID: ${transport.sessionId}):`, error); // Ensure response ends if connection fails if (!res.writableEnded) { res.status(500).end(); } delete transports[transport.sessionId]; // Clean up failed connection } }); // --- Messages Endpoint --- // Client POSTs messages (like tool calls) to this endpoint app.post('/messages', async (req: Request, res: Response) => { const sessionId = req.query.sessionId as string; const transport = transports[sessionId]; if (transport) { try { console.log(`[${new Date().toISOString()}] Received message for Session ID: ${sessionId}`); await transport.handlePostMessage(req, res); console.log(`[${new Date().toISOString()}] Handled message for Session ID: ${sessionId}`); } catch (error) { console.error(`[${new Date().toISOString()}] Error handling POST message for Session ID: ${sessionId}:`, error); if (!res.headersSent) { res.status(500).send('Internal Server Error'); } } } else { console.warn(`[${new Date().toISOString()}] No active transport found for Session ID: ${sessionId}`); res.status(400).send(`No active MCP session found for ID: ${sessionId}`); } }); // --- Server Start --- app.listen(PORT, async () => { console.log(`[${new Date().toISOString()}] ${SERVER_NAME} v${SERVER_VERSION} listening on port ${PORT}`); console.log(`[${new Date().toISOString()}] SSE endpoint available at http://localhost:${PORT}/sse`); console.log(`[${new Date().toISOString()}] Messages endpoint available at http://localhost:${PORT}/messages`); // Run initial codebase analysis if needed try { console.log('[STARTUP] Running initial codebase analysis...'); await codebaseAnalyzer.analyze(process.cwd()); await historyLogger.log(`${SERVER_NAME} v${SERVER_VERSION} started and ran initial codebase analysis`); console.log('[STARTUP] Codebase analysis completed. Check lamplighter_context/codebase_summary.md'); } catch (error) { console.error('[STARTUP] Error during codebase analysis:', error); } }); // Basic error handling for uncaught exceptions process.on('uncaughtException', (error) => { console.error(`[${new Date().toISOString()}] Uncaught Exception:`, error); // Optionally exit gracefully - consider PM2 or similar for production // process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason); // Optionally exit gracefully // process.exit(1); });