lamplighter-mcp
Version:
An intelligent context engine for AI-assisted software development
350 lines (314 loc) • 11.6 kB
text/typescript
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);
});