UNPKG

@agentics.org/sparc2

Version:

SPARC 2.0 - Autonomous Vector Coding Agent + MCP. SPARC 2.0, vectorized AI code analysis, is an intelligent coding agent framework built to automate and streamline software development. It combines secure execution environments, and version control into a

1,006 lines (885 loc) 30 kB
#!/usr/bin/env node /** * MCP Server Wrapper for SPARC 2.0 * This script uses the MCP SDK to create a server that communicates over stdio * and forwards requests to the SPARC2 HTTP API server. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { spawn } from "node:child_process"; import path from "node:path"; import http from "node:http"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; import fs from "node:fs"; import { execSync } from "node:child_process"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // Get the directory of this script const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Start the SPARC2 HTTP server let httpServerProcess = null; // Global flag to track if we're shutting down intentionally let isShuttingDown = false; // Create an HTTP server for SSE let sseHttpServer = null; /** * SSE Response class for Node.js */ class EventSourceResponse { constructor(res) { this.res = res; this.closed = false; this.startTime = new Date().toISOString(); // Set SSE headers this.startTime = new Date().toISOString(); // Set SSE headers this.res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // Handle client disconnect this.res.on('close', () => { this.closed = true; }); } /** * Send data as an SSE event * @param {any} data The data to send * @param {string} eventName Optional event name */ send(data, eventName) { if (this.closed) return; let message = ''; // Add event name if provided if (eventName) { message += `event: ${eventName}\n`; } // Add data as JSON string message += `data: ${JSON.stringify(data)}\n\n`; // Send the message this.res.write(message); } /** * Close the SSE connection */ close() { if (this.closed) return; this.res.end(); this.closed = true; } } /** * Find the Deno executable by checking multiple common installation locations */ function findDenoExecutable() { const possiblePaths = [ "/home/codespace/.deno/bin/deno", // GitHub Codespaces "/usr/local/bin/deno", // Standard Linux/macOS location "/usr/bin/deno", // Alternative Linux location process.env.HOME ? path.join(process.env.HOME, ".deno/bin/deno") : null, // User home directory process.env.DENO_INSTALL_ROOT ? path.join(process.env.DENO_INSTALL_ROOT, "bin/deno") : null, process.env.USERPROFILE ? path.join(process.env.USERPROFILE, ".deno", "bin", "deno.exe") : null, "C:\\Program Files\\deno\\deno.exe", "deno", // Try the command directly (relies on PATH) ].filter(Boolean); // Remove null entries // First check if specific paths exist for (const denoPath of possiblePaths.slice(0, -1)) { // All except the last one try { if (existsSync(denoPath)) { console.error(`[MCP Wrapper] Found Deno at: ${denoPath}`); return denoPath; } } catch (error) { // Ignore errors checking file existence } } // Finally, try 'deno' command directly (which relies on PATH) try { execSync("deno --version", { stdio: "ignore" }); console.error("[MCP Wrapper] Using Deno from PATH"); return "deno"; } catch (error) { console.error("[MCP Wrapper] Deno not found in any standard location"); return null; } } /** * Check if a file exists and is readable */ /** * Kill any process using the specified port * @param {number} port The port to check * @returns {boolean} True if a process was killed, false otherwise */ function killProcessOnPort(port) { console.error(`[HTTP Server] Checking for processes using port ${port}...`); try { let pids = []; let killCommands = []; if (process.platform === 'win32') { // Windows: Get PIDs using netstat try { const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: 'utf8' }); const lines = output.split('\n'); for (const line of lines) { const match = line.match(/\s+(\d+)\s*$/m); if (match && match[1]) { pids.push(match[1]); killCommands.push(`taskkill /F /PID ${match[1]}`); } } } catch (e) { // No process found return false; } } else { // Linux/macOS: Get PIDs using lsof try { const output = execSync(`lsof -i :${port} -t`, { encoding: 'utf8' }).trim(); if (output) { pids = output.split('\n').filter(Boolean); killCommands = pids.map(pid => `kill -9 ${pid}`); } } catch (e) { // No process found return false; } } if (pids.length > 0 && killCommands.length > 0) { console.error(`[HTTP Server] Found process${pids.length > 1 ? 'es' : ''} with PID ${pids.join(', ')} using port ${port}. Killing ${pids.length > 1 ? 'them' : 'it'}...`); // Execute each kill command let success = false; for (const cmd of killCommands) { try { execSync(cmd, { stdio: 'ignore' }); success = true; } catch (error) { console.error(`[HTTP Server Error] Failed to execute command '${cmd}': ${error.message}`); } } if (success) { console.error(`[HTTP Server] Successfully killed process${pids.length > 1 ? 'es' : ''} using port ${port}.`); // Wait a moment for the port to be released try { if (process.platform === 'win32') { // Windows: use timeout command execSync('timeout /t 1 /nobreak > nul', { stdio: 'ignore' }); } else { // Linux/macOS: use sleep command execSync('sleep 1', { stdio: 'ignore' }); } } catch (error) { // If sleep/timeout fails, use setTimeout as a fallback console.error(`[HTTP Server] Warning: Could not use system sleep command: ${error.message}`); // Use a synchronous delay as a fallback const start = new Date().getTime(); while (new Date().getTime() - start < 1000) { // Busy wait for 1 second } } return true; } } return false; } catch (error) { console.error(`[HTTP Server Error] Failed to kill process: ${error.message}`); return false; } } /** * Check if a file exists and is readable * @param {string} filePath Path to the file * @returns {boolean} True if the file exists and is readable */ function fileExists(filePath) { try { fs.accessSync(filePath, fs.constants.R_OK); return true; } catch (error) { return false; } } /** * Start the HTTP server */ async function startHttpServer() { // First, check if port 3001 is already in use and kill the process try { console.error('[HTTP Server] Checking if port 3001 is already in use...'); killProcessOnPort(3001); } catch (error) { console.error(`[HTTP Server] Error checking port: ${error.message}`); } return new Promise((resolve, reject) => { const scriptDir = path.resolve(__dirname, "../.."); const denoPath = findDenoExecutable(); if (!denoPath) { reject(new Error("Deno is not installed. Please install Deno and try again.")); return; } httpServerProcess = spawn(denoPath, [ "run", "--allow-read", "--allow-write", "--allow-env", "--allow-net", "--allow-run", path.join(scriptDir, "src", "cli", "cli.ts"), "api", // Use the API command instead of MCP command "--port", "3001", ], { cwd: scriptDir, stdio: "pipe", env: { ...process.env, SPARC2_DIRECT_API: "true", // Prevent circular dependency }, }); const serverStartTimeout = setTimeout(() => { reject(new Error("HTTP server failed to start within 30 seconds")); }, 30000); httpServerProcess.stdout.on("data", (data) => { console.error(`[HTTP Server] ${data.toString().trim()}`); if (data.toString().includes("Listening on http://localhost:3001")) { clearTimeout(serverStartTimeout); resolve(); } }); httpServerProcess.stderr.on("data", (data) => { const message = data.toString().trim(); if (!message.includes("[MCP Wrapper]")) { console.error(`[HTTP Server Error] ${message}`); } }); httpServerProcess.on("error", (error) => { console.error(`[HTTP Server Process Error] ${error.message}`); clearTimeout(serverStartTimeout); reject(error); }); httpServerProcess.on("close", (code) => { console.error(`[HTTP Server Process Exited] with code ${code}`); clearTimeout(serverStartTimeout); if (code !== 0 && !isShuttingDown) { console.error(`[MCP Wrapper] HTTP server exited unexpectedly.`); reject(new Error(`HTTP server exited with code ${code}`)); } httpServerProcess = null; }); }); } /** * Make an HTTP request to the SPARC2 HTTP server */ async function makeHttpRequest(endpoint, method = "GET", body = null) { const maxRetries = 3; let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await new Promise((resolve, reject) => { const options = { hostname: "localhost", port: 3001, path: `/${endpoint}`, method: method, headers: { "Content-Type": "application/json", }, timeout: 30000, // 30 seconds timeout }; const req = http.request(options, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { resolve(JSON.parse(data)); } catch (error) { resolve(data); } } else { reject(new Error(`HTTP Error: ${res.statusCode} - ${data}`)); } }); }); req.on("error", (error) => { reject(error); }); req.on("timeout", () => { req.destroy(); reject(new Error("Request timed out")); }); if (body) { req.write(JSON.stringify(body)); } req.end(); }); } catch (error) { lastError = error; if (attempt === maxRetries) { throw error; } console.error(`[MCP Wrapper] Request to ${endpoint} failed (attempt ${attempt}/${maxRetries}): ${error.message}. Retrying...`); // Wait before retrying with exponential backoff await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); } } throw lastError; } /** * Handle analyze operation with SSE progress updates */ async function analyzeWithSSE(args, sse) { const files = args.files || []; try { // Step 1: Reading files (already done in handleOperationWithSSE) // Step 2: Parsing code structure sse.send({ status: 'step', message: 'Parsing code structure', progress: 15, step: 2, totalSteps: 7, details: 'Analyzing syntax and structure of code files' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 300)); // Step 3: Static analysis sse.send({ status: 'step', message: 'Performing static analysis', progress: 30, step: 3, totalSteps: 7, details: 'Checking for syntax errors, linting issues, and code style' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 4: Code quality assessment sse.send({ status: 'step', message: 'Assessing code quality', progress: 45, step: 4, totalSteps: 7, details: 'Evaluating complexity, maintainability, and readability' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 5: Identifying patterns sse.send({ status: 'step', message: 'Identifying code patterns', progress: 60, step: 5, totalSteps: 7, details: 'Looking for common patterns, anti-patterns, and code smells' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 6: Generating recommendations sse.send({ status: 'step', message: 'Generating recommendations', progress: 75, step: 6, totalSteps: 7, details: 'Creating suggestions for code improvements' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 500)); // Step 7: Finalizing sse.send({ status: 'step', message: 'Finalizing analysis', progress: 90, step: 7, totalSteps: 7, details: 'Compiling final analysis report' }, 'progress'); // Make the actual HTTP request to get the analysis result const response = await makeHttpRequest('analyze', 'POST', { files }); // Send final progress update sse.send({ status: 'completed', message: 'Analysis completed successfully', progress: 100, timestamp: new Date().toISOString() }, 'progress'); // Send the analysis result sse.send({ result: response, operation: 'analyze', files: files, timestamp: new Date().toISOString(), executionTime: `${(new Date() - new Date(sse.startTime))/1000} seconds` }, 'result'); sse.close(); } catch (error) { sse.send({ status: 'error', message: `Error during analysis: ${error.message}`, error: error.message, operation: 'analyze', timestamp: new Date().toISOString() }, 'error'); sse.close(); } } /** * Handle modify operation with SSE progress updates */ async function modifyWithSSE(args, sse) { const files = args.files || []; const task = args.task || ''; try { // Step 1: Reading files (already done in handleOperationWithSSE) // Step 2: Understanding the task sse.send({ status: 'step', message: 'Understanding modification task', progress: 15, step: 2, totalSteps: 8, details: `Analyzing task: ${task}` }, 'progress'); await new Promise(resolve => setTimeout(resolve, 300)); // Step 3: Parsing code files sse.send({ status: 'step', message: 'Parsing code files', progress: 25, step: 3, totalSteps: 8, details: 'Analyzing syntax and structure of code to be modified' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 4: Planning modifications sse.send({ status: 'step', message: 'Planning modifications', progress: 40, step: 4, totalSteps: 8, details: 'Determining what changes need to be made' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 5: Generating changes sse.send({ status: 'step', message: 'Generating code changes', progress: 55, step: 5, totalSteps: 8, details: 'Creating the actual code modifications' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 500)); // Step 6: Validating changes sse.send({ status: 'step', message: 'Validating changes', progress: 70, step: 6, totalSteps: 8, details: 'Ensuring modifications are syntactically correct' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 400)); // Step 7: Applying changes sse.send({ status: 'step', message: 'Applying changes', progress: 85, step: 7, totalSteps: 8, details: 'Writing modifications to files' }, 'progress'); // Make the actual HTTP request to modify the code const response = await makeHttpRequest('modify', 'POST', { files, task }); // Step 8: Finalizing sse.send({ status: 'step', message: 'Finalizing modifications', progress: 95, step: 8, totalSteps: 8, details: 'Completing the modification process' }, 'progress'); await new Promise(resolve => setTimeout(resolve, 200)); // Send final progress update sse.send({ status: 'completed', message: 'Modifications completed successfully', progress: 100, timestamp: new Date().toISOString() }, 'progress'); // Send the modification result sse.send({ result: response, operation: 'modify', files: files, task: task, timestamp: new Date().toISOString(), executionTime: `${(new Date() - new Date(sse.startTime))/1000} seconds` }, 'result'); sse.close(); } catch (error) { sse.send({ status: 'error', message: `Error during modification: ${error.message}`, error: error.message, operation: 'modify', timestamp: new Date().toISOString() }, 'error'); sse.close(); } } /** * Generic function to handle MCP operations with SSE progress updates */ async function handleOperationWithSSE(operation, args, sse) { try { // Send initial progress update sse.send({ status: 'started', message: `Starting ${operation} operation`, progress: 0, operation: operation }, 'progress'); // Common steps for all operations const files = args.files || []; const task = args.task || ''; // Send detailed information about the operation sse.send({ status: 'info', message: `Operation: ${operation}`, details: { files: files, task: task, timestamp: new Date().toISOString() } }, 'info'); // Read files content with progress updates if (files.length > 0) { sse.send({ status: 'step', message: 'Reading files', progress: 5, step: 1, totalSteps: operation === 'analyze' ? 7 : 8 }, 'progress'); // Process each file with detailed updates for (let i = 0; i < files.length; i++) { const file = files[i]; sse.send({ status: 'reading', message: `Reading file ${file} (${i+1}/${files.length})`, progress: 5 + (i / files.length) * 10, file: file, fileIndex: i, totalFiles: files.length }, 'progress'); // Simulate file reading time await new Promise(resolve => setTimeout(resolve, 200)); } } // Operation-specific steps if (operation === 'analyze') { await analyzeWithSSE(args, sse); } else if (operation === 'modify') { await modifyWithSSE(args, sse); } else { throw new Error(`Unsupported operation: ${operation}`); } } catch (error) { console.error(`[SSE Error] ${error.message}`); if (!sse.closed) { sse.send({ status: 'error', message: `Error during ${operation}: ${error.message}`, error: error.message, operation: operation }, 'error'); sse.close(); } } } /** * Start the SSE HTTP server */ function startSseHttpServer() { // First, check if port 3002 is already in use and kill the process try { console.error('[SSE Server] Checking if port 3002 is already in use...'); killProcessOnPort(3002); } catch (error) { console.error(`[SSE Server] Error checking port: ${error.message}`); } // Create the HTTP server sseHttpServer = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); // Handle analyze SSE stream requests if (url.pathname === '/stream/analyze') { const id = url.searchParams.get('id'); const filesParam = url.searchParams.get('files'); const files = filesParam ? filesParam.split(',') : []; // Resolve file paths relative to the current working directory const resolvedFiles = files.map(file => path.resolve(process.cwd(), file)); console.error(`[SSE Server] Received analyze request for files: ${resolvedFiles.join(', ')}`); // Set up SSE response const sse = new EventSourceResponse(res); // Check if all files exist const nonExistentFiles = resolvedFiles.filter(file => !fileExists(file)); if (nonExistentFiles.length > 0) { const errorMsg = `The following files do not exist or are not readable: ${nonExistentFiles.join(', ')}`; console.error(`[SSE Server Error] ${errorMsg}`); // Send error message via SSE sse.send({ status: 'error', message: errorMsg, error: errorMsg }, 'error'); // Close the SSE connection sse.close(); return; } // Start analysis with SSE updates handleOperationWithSSE('analyze', { files: resolvedFiles }, sse); return; } // Handle modify SSE stream requests if (url.pathname === '/stream/modify') { const id = url.searchParams.get('id'); const filesParam = url.searchParams.get('files'); const files = filesParam ? filesParam.split(',') : []; const task = url.searchParams.get('task') || ''; // Resolve file paths relative to the current working directory const resolvedFiles = files.map(file => path.resolve(process.cwd(), file)); console.error(`[SSE Server] Received modify request for files: ${resolvedFiles.join(', ')} with task: ${task}`); // Set up SSE response const sse = new EventSourceResponse(res); // Check if all files exist const nonExistentFiles = resolvedFiles.filter(file => !fileExists(file)); if (nonExistentFiles.length > 0) { const errorMsg = `The following files do not exist or are not readable: ${nonExistentFiles.join(', ')}`; console.error(`[SSE Server Error] ${errorMsg}`); // Send error message via SSE sse.send({ status: 'error', message: errorMsg, error: errorMsg }, 'error'); // Close the SSE connection sse.close(); return; } // Start modification with SSE updates handleOperationWithSSE('modify', { files: resolvedFiles, task }, sse); return; } // Handle other requests res.writeHead(404); res.end('Not Found'); }); // Start HTTP server on port 3002 (different from the API server) try { sseHttpServer.listen(3002, () => { console.error(`[MCP Wrapper] SSE server listening on port 3002`); }); // Handle HTTP server errors sseHttpServer.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(`[SSE Server Error] Port 3002 is already in use. The SSE functionality will not be available.`); console.error(`[SSE Server Error] You can still use the MCP server without SSE support.`); } else { console.error(`[SSE Server Error] ${error.message}`); } }); } catch (error) { console.error(`[SSE Server Error] Failed to start SSE server: ${error.message}`); console.error(`[SSE Server Error] The MCP server will continue to run without SSE support.`); } } // Create the MCP server const server = new Server( { name: "sparc2-mcp", version: "2.0.5", }, { capabilities: { tools: {}, }, }, ); // Set up the ListTools request handler server.setRequestHandler(ListToolsRequestSchema, async () => { try { // Get the tools from the HTTP server const response = await makeHttpRequest("discover", "GET"); return { tools: response.tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters, })), }; } catch (error) { console.error(`[MCP Server Error] Failed to list tools: ${error.message}`); throw new McpError(ErrorCode.InternalError, `Failed to list tools: ${error.message}`); } }); // Set up the CallTool request handler server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const toolName = request.params.name; const args = request.params.arguments; // Resolve file paths for any tool that uses files if (args && args.files && Array.isArray(args.files)) { args.files = args.files.map(file => path.resolve(process.cwd(), file)); console.error(`[MCP Server] Resolved file paths for ${toolName}: ${args.files.join(', ')}`); // Check if all files exist const nonExistentFiles = args.files.filter(file => !fileExists(file)); if (nonExistentFiles.length > 0) { const errorMsg = `The following files do not exist or are not readable: ${nonExistentFiles.join(', ')}`; console.error(`[MCP Server Error] ${errorMsg}`); throw new McpError(ErrorCode.InvalidParams, errorMsg); } } // Special handling for analyze_code to support SSE if (toolName === "analyze_code") { // Return a URL for the SSE stream const filesParam = args.files ? args.files.join(',') : ''; return { content: [ { type: "text", text: "Analysis started. Connect to the SSE stream to receive progress updates.", }, ], streamUrl: `http://localhost:3002/stream/analyze?id=${Date.now()}&files=${encodeURIComponent(filesParam)}`, }; } // Special handling for modify_code to support SSE if (toolName === "modify_code") { // Return a URL for the SSE stream const filesParam = args.files ? args.files.join(',') : ''; const taskParam = args.task || ''; return { content: [ { type: "text", text: "Modification started. Connect to the SSE stream to receive progress updates.", }, ], streamUrl: `http://localhost:3002/stream/modify?id=${Date.now()}&files=${encodeURIComponent(filesParam)}&task=${encodeURIComponent(taskParam)}`, }; } // Map the tool name to the corresponding HTTP endpoint let endpoint; switch (toolName) { case "analyze_code": endpoint = "analyze"; break; case "modify_code": endpoint = "modify"; break; case "execute_code": endpoint = "execute"; break; case "search_code": endpoint = "search"; break; case "create_checkpoint": endpoint = "checkpoint"; break; case "rollback": endpoint = "rollback"; break; case "config": endpoint = "config"; break; default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); } // Make the HTTP request to the SPARC2 HTTP server const response = await makeHttpRequest(endpoint, "POST", args); // Format the response return { content: [ { type: "text", text: typeof response === "string" ? response : JSON.stringify(response, null, 2), }, ], }; } catch (error) { console.error(`[MCP Server Error] Failed to call tool: ${error.message}`); throw new McpError(ErrorCode.InternalError, `Failed to call tool: ${error.message}`); } }); // Set up error handler server.onerror = (error) => { console.error(`[MCP Server Error] ${error}`); }; // Cleanup function to ensure all processes are terminated function cleanup() { console.error('[MCP Wrapper] Cleaning up resources...'); isShuttingDown = true; // Close the SSE server if it exists if (sseHttpServer) { try { sseHttpServer.close(); console.error('[MCP Wrapper] SSE server closed.'); } catch (error) { console.error(`[MCP Wrapper] Error closing SSE server: ${error.message}`); } sseHttpServer = null; } // Kill the HTTP server process if it exists if (httpServerProcess) { try { httpServerProcess.kill(); console.error('[MCP Wrapper] HTTP server process terminated.'); } catch (error) { console.error(`[MCP Wrapper] Error terminating HTTP server process: ${error.message}`); } httpServerProcess = null; } console.error('[MCP Wrapper] Cleanup complete.'); } // Register cleanup handlers process.on('exit', cleanup); process.on('SIGINT', () => { console.error('[MCP Wrapper] Received SIGINT signal.'); cleanup(); process.exit(0); }); process.on('SIGTERM', () => { console.error('[MCP Wrapper] Received SIGTERM signal.'); cleanup(); process.exit(0); }); process.on('uncaughtException', (error) => { console.error(`[MCP Wrapper] Uncaught exception: ${error.message}`); console.error(error.stack); cleanup(); process.exit(1); }); // Main function to start everything async function main() { try { // Start the HTTP server await startHttpServer(); console.error("[MCP Wrapper] HTTP server started successfully"); // Start the SSE HTTP server startSseHttpServer(); console.error("[MCP Wrapper] SSE server started successfully"); // Create the STDIO transport for the MCP server const transport = new StdioServerTransport(); // Start the server with the transport transport.start(server); console.error("[MCP Wrapper] MCP server started successfully"); } catch (error) { console.error(`[MCP Wrapper Error] Failed to start: ${error.message}`); process.exit(1); } } // Start the server main();