UNPKG

roam-research-mcp

Version:
241 lines (238 loc) • 10.8 kB
import { Command } from 'commander'; import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync, } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { get as httpGet } from 'node:http'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Path to the MCP server entry (build/index.js) relative to this command // (build/cli/commands/server.js -> build/index.js). const SERVER_ENTRY = join(__dirname, '../../index.js'); // State dir for the CLI-managed daemon (pidfile + logfile). Local path by // default — overridable via ROAM_HOME. Kept off external volumes so launchd / // the daemon never hits the macOS provenance-xattr EPERM trap. const STATE_DIR = process.env.ROAM_HOME || join(homedir(), '.roam'); const PID_FILE = join(STATE_DIR, 'server.pid'); const LOG_FILE = join(STATE_DIR, 'server.log'); const DEFAULT_PORT = process.env.HTTP_STREAM_PORT || '8088'; const DEFAULT_HOST = process.env.HTTP_STREAM_HOST || '127.0.0.1'; /** Probe GET /health. Returns parsed info, or null if nothing is serving. */ function checkHealth(host, port, timeoutMs = 2000) { // 0.0.0.0 is a bind address, not a connect address — probe loopback instead. const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; return new Promise((resolve) => { const req = httpGet({ host: target, port, path: '/health', timeout: timeoutMs }, (res) => { let body = ''; res.on('data', (c) => (body += c)); res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(null); } }); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); } function readPid() { if (!existsSync(PID_FILE)) return null; const raw = readFileSync(PID_FILE, 'utf8').trim(); const pid = parseInt(raw, 10); return Number.isFinite(pid) ? pid : null; } function isAlive(pid) { try { process.kill(pid, 0); return true; } catch (err) { // EPERM means it exists but we can't signal it; ESRCH means gone. return err.code === 'EPERM'; } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function createStartCommand() { return new Command('start') .description('Start the shared HTTP MCP server (HTTP-only daemon)') .option('-p, --port <port>', 'Port to bind', DEFAULT_PORT) .option('-H, --host <host>', 'Host to bind (use 0.0.0.0 to expose on LAN)', DEFAULT_HOST) .option('-f, --foreground', 'Run in the foreground instead of detaching', false) .action(async (options) => { const { port, host, foreground } = options; // Don't start a second copy if one is already serving this address. const existing = await checkHealth(host, port); if (existing) { console.log(`Already running on http://${host}:${port}/ (v${existing.version ?? '?'}, ${existing.activeSessions ?? 0} session(s)).`); return; } if (!existsSync(SERVER_ENTRY)) { console.error(`Error: server entry not found at ${SERVER_ENTRY}. Run "npm run build" first.`); process.exit(1); } const env = { ...process.env, HTTP_STREAM_PORT: port, HTTP_STREAM_HOST: host }; if (foreground) { const child = spawn(process.execPath, [SERVER_ENTRY, '--server'], { env, stdio: 'inherit' }); child.on('exit', (code) => process.exit(code ?? 0)); return; } // Detached background launch: logs to LOG_FILE, pid recorded in PID_FILE. if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true }); const out = openSync(LOG_FILE, 'a'); const child = spawn(process.execPath, [SERVER_ENTRY, '--server'], { env, detached: true, stdio: ['ignore', out, out], }); child.unref(); if (child.pid) writeFileSync(PID_FILE, String(child.pid)); // Confirm it actually came up (port-in-use fails loudly and exits). let health = null; for (let i = 0; i < 10 && !health; i++) { await sleep(300); if (child.pid && !isAlive(child.pid)) break; health = await checkHealth(host, port); } if (health) { console.log(`Started (pid ${child.pid}) on http://${host}:${port}/`); console.log(` health: http://${host}:${port}/health`); console.log(` logs: ${LOG_FILE} (roam server logs -f)`); } else { console.error(`Failed to confirm startup. Check logs: ${LOG_FILE}`); if (existsSync(PID_FILE)) unlinkSync(PID_FILE); process.exit(1); } }); } function createStopCommand() { return new Command('stop') .description('Stop a CLI-started server (see notes for service-managed daemons)') .option('-H, --host <host>', 'Host to probe', DEFAULT_HOST) .option('-p, --port <port>', 'Port to probe', DEFAULT_PORT) .action(async (options) => { const pid = readPid(); if (pid && isAlive(pid)) { process.kill(pid, 'SIGTERM'); for (let i = 0; i < 20 && isAlive(pid); i++) await sleep(100); if (isAlive(pid)) { console.error(`Process ${pid} did not stop after SIGTERM.`); process.exit(1); } if (existsSync(PID_FILE)) unlinkSync(PID_FILE); console.log(`Stopped (pid ${pid}).`); return; } if (existsSync(PID_FILE)) unlinkSync(PID_FILE); // No CLI-managed process — but something may still be serving via a // service manager (launchd/systemd). Don't pretend we stopped it. const health = await checkHealth(options.host, options.port); if (health) { console.log(`Not started by this CLI, but a server is responding on http://${options.host}:${options.port}/.`); console.log(`It is likely managed by a service manager (e.g. launchd/systemd) — stop it there.`); return; } console.log('Not running.'); }); } function createStatusCommand() { return new Command('status') .description('Check whether the shared HTTP server is running') .option('-H, --host <host>', 'Host to probe', DEFAULT_HOST) .option('-p, --port <port>', 'Port to probe', DEFAULT_PORT) .option('--json', 'Output as JSON', false) .action(async (options) => { const { host, port } = options; const health = await checkHealth(host, port); const pid = readPid(); const pidAlive = pid !== null && isAlive(pid); if (options.json) { console.log(JSON.stringify({ running: !!health, url: `http://${host}:${port}/mcp`, managedPid: pidAlive ? pid : null, health: health ?? null, }, null, 2)); return; } if (health) { console.log(`● running — http://${host}:${port}/`); console.log(` version: ${health.version ?? '?'}`); console.log(` mode: ${health.mode ?? '?'}`); console.log(` auth: ${health.auth ?? 'none'}`); console.log(` graphs: ${(health.graphs ?? []).join(', ')}`); console.log(` default graph: ${health.defaultGraph ?? '?'}`); console.log(` active sessions:${' '}${health.activeSessions ?? 0}`); console.log(` endpoint: http://${host}:${port}/mcp`); console.log(pidAlive ? ` managed by: this CLI (pid ${pid})` : ` managed by: external supervisor (not this CLI)`); } else { console.log(`○ not running on http://${host}:${port}/`); if (pidAlive) console.log(` (stale pidfile process ${pid} alive but not serving — try: roam server stop)`); } }); } function createLogsCommand() { return new Command('logs') .description('Tail the CLI-managed server log') .option('-f, --follow', 'Follow the log (like tail -f)', false) .option('-n, --lines <n>', 'Number of lines to show', '50') .action((options) => { if (!existsSync(LOG_FILE)) { console.error(`No CLI log at ${LOG_FILE}.`); console.error('The server may not have been started via "roam server start"'); console.error('(e.g. a launchd/systemd service logs to its own configured path).'); process.exit(1); } const args = ['-n', options.lines]; if (options.follow) args.push('-f'); args.push(LOG_FILE); const child = spawn('tail', args, { stdio: 'inherit' }); child.on('exit', (code) => process.exit(code ?? 0)); }); } export function createServerCommand() { const server = new Command('server') .description('Run/manage the shared HTTP MCP server (start/stop/status/logs)') .addHelpText('after', ` The shared server is a single long-lived, HTTP-only MCP daemon that multiple clients connect to over HTTP — instead of each session spawning its own copy. Point clients at it with: { "type": "http", "url": "http://${DEFAULT_HOST}:${DEFAULT_PORT}/mcp" } Examples: roam server start # start in the background (port ${DEFAULT_PORT}) roam server start -p 9000 -f # foreground on port 9000 roam server start -H 0.0.0.0 # expose on the LAN (no transport auth!) roam server status # is it up? version, graphs, sessions roam server logs -f # follow the log roam server stop # stop a CLI-started daemon Config comes from the environment (ROAM_GRAPHS / ROAM_API_TOKEN, etc.), same as the rest of the CLI. State dir: ${STATE_DIR} (override with ROAM_HOME). For an always-on daemon, run "roam server start" from a launchd/systemd unit. `); server.addCommand(createStartCommand()); server.addCommand(createStopCommand()); server.addCommand(createStatusCommand()); server.addCommand(createLogsCommand()); // No subcommand → show help rather than erroring. server.action(() => server.help()); return server; }