echonodesync
Version:
Autonomous, pluggable, secure memory bridge for agent/human/ritual ecosystems (DreamWeaver, EchoThreads, etc.)
286 lines (267 loc) • 9.58 kB
JavaScript
// 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;
}