UNPKG

echonodesync

Version:

Autonomous, pluggable, secure memory bridge for agent/human/ritual ecosystems (DreamWeaver, EchoThreads, etc.)

286 lines (267 loc) 9.58 kB
#!/usr/bin/env node // MCP server entrypoint for EchoNodeSync memory actions // 🧠🌸🦢 EchoNodeSync MCP: Memory Action Server // This server exposes getMemory, postMemory, and scanKeys as MCP actions. // To run: node src/mcp/server.js // // Actions are defined in src/mcp/actions/ and registered in mcp-manifest.json // // Example MCP call (POST): // curl -X POST http://localhost:4000/mcp/action/getMemory -H 'Content-Type: application/json' -d '{"key":"foo"}' // // See README for more. // --- Preflight Ritual: Dependency Check --- try { require.resolve('express'); require.resolve('body-parser'); } catch (e) { console.error('\u274C Ritual failed: Required dependencies not found.\n'); console.error('Please install them with: npm install express body-parser'); process.exit(1); } const { execSync } = require('child_process'); // --- Ritual: CLI Argument Parsing (Mia + Miette + Seraphine) --- const argv = process.argv.slice(2); const forceFlag = argv.includes('--force') || argv.includes('--cleanup'); if (argv.includes('--help') || argv.includes('-h')) { console.log(`\nEchoNodeSync MCP Server\n` + `\nUsage: node src/mcp/server.js [--port PORT] [--force]\n` + `\nOptions:\n` + ` --port, -p Set the port to listen on (default: 4000 or $MCP_PORT)\n` + ` --force Forcibly clean up stale processes and ports before starting\n` + ` --help, -h Show this help message and exit\n` + `\nThis server exposes agentic memory actions as MCP endpoints.\n` + `Invoke with intention.\n`); process.exit(0); } // --- Ritual: Port Discovery (with CLI override) --- let PORT = process.env.MCP_PORT || 4000; const portFlagIndex = argv.findIndex(arg => arg === '--port' || arg === '-p'); if (portFlagIndex !== -1 && argv[portFlagIndex + 1]) { const cliPort = parseInt(argv[portFlagIndex + 1], 10); if (!isNaN(cliPort)) PORT = cliPort; } const express = require('express'); const bodyParser = require('body-parser'); const http = require('http'); const { getMemoryAction } = require('./actions/getMemoryAction'); const { postMemoryAction } = require('./actions/postMemoryAction'); const { scanKeysAction } = require('./actions/scanKeysAction'); const fs = require('fs'); const path = require('path'); const manifestPath = require('path').resolve(__dirname, 'mcp-manifest.json'); let manifest = JSON.parse(require('fs').readFileSync(manifestPath, 'utf8')); const app = express(); app.use(bodyParser.json()); // MCP action registry const actions = { getMemory: getMemoryAction, postMemory: postMemoryAction, scanKeys: scanKeysAction, }; // --- Ritual: PID File Management --- const PID_FILE = path.resolve(process.cwd(), '.mcp-server.pid'); function writePidFile() { try { fs.writeFileSync(PID_FILE, process.pid.toString(), { flag: 'w' }); } catch (e) { console.error('[MCP] Failed to write PID file:', e.message); } } function removePidFile() { try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch (e) { console.error('[MCP] Failed to remove PID file:', e.message); } } // Generic MCP action endpoint app.post('/mcp/action/:action', async (req, res) => { const { action } = req.params; const handler = actions[action]; if (!handler) { // Like a memory key lost in the spiral—ritual fails gracefully. return res.status(404).json({ error: 'Unknown action' }); } try { const result = await handler(req.body); res.json({ result }); } catch (err) { // The ritual echoes with error—let the agent know. res.status(500).json({ error: err.message }); } }); // --- Ritual: Serve MCP Manifest for Agentic Discovery --- app.get('/mcp/manifest', (req, res) => { res.json(manifest); }); // Compatibility: /mcp/manifest.actions (root 'actions' array) app.get('/mcp/manifest.actions', (req, res) => { if (manifest.tools) { res.json({ actions: manifest.tools }); } else { res.status(500).json({ error: 'No tools in manifest' }); } }); // Compatibility: /mcp/manifest.tools (array at root) app.get('/mcp/manifest.tools', (req, res) => { if (manifest.tools) { res.json(manifest.tools); } else { res.status(500).json({ error: 'No tools in manifest' }); } }); // Placeholder: /mcp/openapi (501 Not Implemented) app.get('/mcp/openapi', (req, res) => { res.status(501).json({ error: 'OpenAPI manifest not implemented yet.' }); }); // --- Ritual: Graceful Exit Endpoint --- app.post('/mcp/exit', (req, res) => { res.json({ status: 'shutting down', pid: process.pid }); setTimeout(() => { removePidFile(); process.exit(0); }, 100); }); // --- Ritual: Preflight Port Occupancy Check (Agentic Self-Check) --- async function checkPortOccupied(port) { return new Promise((resolve) => { const req = http.get({ hostname: '127.0.0.1', port, path: '/mcp/manifest', timeout: 1000 }, (res) => { // If we get a 200, something is already serving MCP here resolve(res.statusCode === 200); res.resume(); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } // --- Ritual: Robust PID/Port Cleanup (Mia + Miette + Seraphine) --- async function cleanupStaleProcess(port, force) { // 1. If PID file exists, try to kill the process if (fs.existsSync(PID_FILE)) { const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8')); if (!isNaN(pid) && pid !== process.pid) { try { process.kill(pid, 'SIGTERM'); // Wait a moment for process to die await new Promise(r => setTimeout(r, 500)); } catch (e) { // Ignore if process already dead } } removePidFile(); } // 2. If port is still occupied and --force, kill any process using the port (Linux only) if (force && await checkPortOccupied(port)) { try { // Try lsof first const out = execSync(`lsof -t -i:${port}`).toString().trim(); if (out) { out.split('\n').forEach(pid => { if (pid && parseInt(pid) !== process.pid) { try { process.kill(parseInt(pid), 'SIGKILL'); } catch (e) {} } }); await new Promise(r => setTimeout(r, 500)); } } catch (e) { // lsof not available or no process found } } } // --- Ritual: Cleanup on SIGINT/SIGTERM --- process.on('SIGINT', () => { removePidFile(); process.exit(0); }); process.on('SIGTERM', () => { removePidFile(); process.exit(0); }); // --- Ritual: Server Start --- (async () => { await cleanupStaleProcess(PORT, forceFlag); if (await checkPortOccupied(PORT)) { console.error(`\u274C Ritual preflight: Another MCP server is already running on port ${PORT}.\n` + `Refusing to start a duplicate.\n` + `\nSolutions:` + `\n - Use --port to specify a different port.` + `\n - Or end the other process using this port.` + `\n - Or use --force to forcibly clean up the port.\n` + `\nThe spiral must be clear for a new invocation.\n`); process.exit(1); } const server = app.listen(PORT, () => { writePidFile(); // The server breathes, a new invocation on the lattice. console.log(`EchoNodeSync MCP server listening on port ${PORT}`); }) .on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`\u274C Ritual failed: Port ${PORT} is already in use.\n` + `Another invocation is echoing on this port.\n` + `\nSolutions:` + `\n - Use --port to specify a different port.` + `\n - Or end the other process using this port.\n` + `\nThe spiral must be clear for a new invocation.\n`); process.exit(1); } else { throw err; } }); })(); // --- Ritual: JSON-RPC/STDIO Mode for VSCode MCP Extension --- const isStdio = process.env.MCP_STDIO === '1' || argv.includes('--stdio'); if (isStdio) { // Minimal JSON-RPC 2.0 stdio loop const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); function send(msg) { const str = JSON.stringify(msg); process.stdout.write(`Content-Length: ${str.length}\r\n\r\n${str}`); } let buffer = ''; process.stdin.on('data', chunk => { buffer += chunk.toString(); while (true) { const headerEnd = buffer.indexOf('\r\n\r\n'); if (headerEnd === -1) break; const header = buffer.slice(0, headerEnd); const match = /Content-Length: (\d+)/i.exec(header); if (!match) break; const len = parseInt(match[1], 10); const bodyStart = headerEnd + 4; if (buffer.length < bodyStart + len) break; const body = buffer.slice(bodyStart, bodyStart + len); buffer = buffer.slice(bodyStart + len); let msg; try { msg = JSON.parse(body); } catch (e) { continue; } // Handle JSON-RPC requests if (msg.method === 'initialize') { // VS Code MCP extension expects { tools: [...] } at root if (manifest.tools) { send({ jsonrpc: '2.0', id: msg.id, result: { tools: manifest.tools } }); } else { send({ jsonrpc: '2.0', id: msg.id, result: manifest }); } } else if (msg.method === 'shutdown') { send({ jsonrpc: '2.0', id: msg.id, result: null }); process.exit(0); } else if (msg.method === 'exit') { process.exit(0); } else { send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } }); } } }); // Do not start HTTP server in stdio mode return; }