/home/arranoyd/energyclinic/wp-content/plugins/ai-engine/labs/mcp.js
#!/usr/bin/env node
/**
 * Claude ↔ AI-Engine MCP relay
 * --------------------------------
 * Connects Claude Desktop (JSON-RPC on stdin/stdout) to a WordPress site that
 * exposes:
 *   • GET  /wp-json/mcp/v1/sse        (Server-Sent Events stream)
 *   • POST /wp-json/mcp/v1/messages   (JSON-RPC ingress)
 *
 * If the site is protected by a Bearer token:
 *   • Store the token per-site in  ~/.mcp/sites.json
 *   • Relay adds  Authorization: Bearer <token>
 *   • 401 / 403 responses are converted to JSON-RPC errors −32001 / −32003
 *     so Claude shows an immediate, clear message instead of timing out.
 */

////////////////////////////////////////////////////////////////////////////////
// imports & tiny helpers
////////////////////////////////////////////////////////////////////////////////
const fs       = require('fs');
const os       = require('os');
const path     = require('path');
const readline = require('readline');
const { setTimeout: delay } = require('timers/promises');

const readJSON  = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } };
const writeJSON = (f, o) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(o, null, 2)); };

const toDomain = s => new URL(/^https?:/.test(s) ? s : `https://${s}`).hostname.toLowerCase();
const sseURL   = u => u.replace(/\/+$/, '') + '/wp-json/mcp/v1/sse/';
const die      = m => { console.error(m); process.exit(1); };

////////////////////////////////////////////////////////////////////////////////
// paths & persistent state
////////////////////////////////////////////////////////////////////////////////
const HOME       = os.homedir();
const MCP_DIR    = path.join(HOME, '.mcp'); fs.mkdirSync(MCP_DIR, { recursive: true });
const SITE_CFG   = path.join(MCP_DIR, 'sites.json');
const LOG_HDR    = path.join(MCP_DIR, 'mcp.log');
const LOG_BODY   = path.join(MCP_DIR, 'mcp-results.log');
const ERR_LOG    = path.join(MCP_DIR, 'error.log');
const CLAUDE_CFG = path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
const SELF       = path.resolve(__filename);

/* load sites config (upgrade legacy string → object) */
let sites = readJSON(SITE_CFG);
for (const [d, v] of Object.entries(sites))
  if (typeof v === 'string') sites[d] = { url: v, token: '' };
const saveSites = () => writeJSON(SITE_CFG, sites);

/* micro JSON-lines logger */
function logError(kind, err, extra = {}) {
  const entry = { ts: new Date().toISOString(), kind,
                  msg: err?.message || err, stack: err?.stack, ...extra };
  fs.appendFileSync(ERR_LOG, JSON.stringify(entry) + '\n');
}
process.on('uncaughtException',  e => logError('uncaught',  e));
process.on('unhandledRejection', e => logError('unhandled', e));

////////////////////////////////////////////////////////////////////////////////
// Claude Desktop integration (updates claude_desktop_config.json)
////////////////////////////////////////////////////////////////////////////////
function setClaudeTarget(domain) {
  const cfg = readJSON(CLAUDE_CFG);
  cfg.mcpServers ??= {};
  cfg.mcpServers['AI Engine'] = { command: SELF, args: ['relay', domain] };
  writeJSON(CLAUDE_CFG, cfg);
}
const activeDomain = () => readJSON(CLAUDE_CFG)?.mcpServers?.['AI Engine']?.args?.[1] || null;

////////////////////////////////////////////////////////////////////////////////
// CLI
////////////////////////////////////////////////////////////////////////////////
const [ , , cmd = 'help', ...args] = process.argv;

const HELP = `
add    <site-url> [token]      Register / update site (and set Claude target)
remove <domain|url>           Unregister site
list                          Show sites
claude [domain|url]           Show / change Claude target
start  [domain|url]           Verbose relay
relay  <domain|url>           Silent relay (for Claude Desktop)
post   <domain> <json> <sid>  Fire raw JSON-RPC (debug)
help                          This help
`.trim();

switch (cmd) {
  case 'add':    addSite(...args);      break;
  case 'remove': removeSite(args[0]);   break;
  case 'list':   listSites();           break;
  case 'claude': claudeCmd(args[0]);    break;
  case 'start':
  case 'relay':  launchRelay(cmd, args[0]); break;
  case 'post':   firePost(args);        break;
  default:       console.log(HELP);
}

/* ---------- CLI actions ---------- */
function addSite(url, token = '') {
  if (!url) die('add <site-url> [token]');
  const norm = url.replace(/\/+$/, '');
  const dom  = toDomain(norm);
  const existed = !!sites[dom];
  sites[dom] = { url: norm, token };
  saveSites(); setClaudeTarget(dom);
  console.log(`✓ ${existed ? 'updated' : 'added'} ${norm}`);
}
function removeSite(ref) {
  if (!ref) die('remove <domain|url>');
  const dom = toDomain(ref);
  if (!sites[dom]) die('unknown site');
  delete sites[dom]; saveSites();
  if (activeDomain() === dom) setClaudeTarget(Object.keys(sites)[0] || 'missing');
  console.log('✓ removed', ref);
}
function listSites() {
  if (!Object.keys(sites).length) return console.log('(no sites)');
  for (const s of Object.values(sites))
    console.log('•', s.url, s.token ? '(token set)' : '');
}
function claudeCmd(ref) {
  if (!ref) return console.log(activeDomain()
    ? `Claude: ${sites[activeDomain()].url}` : '(no site)');
  const full = /^https?:/.test(ref) ? ref : `https://${ref}`;
  const dom  = toDomain(full);
  sites[dom] = sites[dom] || { url: full, token: '' };
  saveSites(); setClaudeTarget(dom);
  console.log('✓ Claude →', sites[dom].url);
}

////////////////////////////////////////////////////////////////////////////////
// manual POST (debug)
////////////////////////////////////////////////////////////////////////////////
async function firePost([dom, json, sid]) {
  if (!dom || !json || !sid) die('post <domain> <json> <sid>');
  const site = sites[toDomain(dom)];
  if (!site) die('unknown site');

  const fetchFn = global.fetch || (await import('node-fetch')).default;
  const url = `${site.url.replace(/\/+$/, '')}/wp-json/mcp/v1/messages?session_id=${sid}`;
  const headers = { 'content-type': 'application/json' };
  if (site.token) headers.authorization = `Bearer ${site.token}`;

  const res = await fetchFn(url, { method: 'POST', headers, body: json });
  console.log('HTTP', res.status);
  console.log(await res.text());
}

////////////////////////////////////////////////////////////////////////////////
// launch relay
////////////////////////////////////////////////////////////////////////////////
function launchRelay(mode, ref) {
  const dom = pickSite(ref);
  runRelay(sites[dom], mode === 'start')
    .catch(e => { logError('fatal', e); process.exit(1); });
}
function pickSite(ref) {
  if (ref) return toDomain(ref);
  const keys = Object.keys(sites);
  if (!keys.length) die('no sites registered');
  if (keys.length === 1) return keys[0];
  die('multiple sites: ' + keys.join(', '));
}

////////////////////////////////////////////////////////////////////////////////
// relay core
////////////////////////////////////////////////////////////////////////////////
async function runRelay(site, verbose) {
  const fetchFn = global.fetch || (await import('node-fetch')).default;

  /* ---- tiny disk logs ---- */
  fs.writeFileSync(LOG_HDR, ''); fs.writeFileSync(LOG_BODY, '');
  const hdr = fs.createWriteStream(LOG_HDR, { flags: 'a' });
  const bod = fs.createWriteStream(LOG_BODY, { flags: 'a' });
  const logH = (dir, id, msg='') => hdr.write(`${new Date().toISOString()}  ${dir} id=${id ?? '-'}  ${msg}\n`);
  const logB = (dir, id, msg, obj) => { logH(dir, id, msg); bod.write(JSON.stringify(obj, null, 2) + '\n\n'); };

  /* ---- runtime state ---- */
  let messagesURL  = null;        // set after “endpoint” event
  const backlog    = [];          // queued before endpoint known
  const pending    = new Set();   // ids waiting reply
  const id2method  = new Map();   // for nicer logs
  let authFail     = 0;           // 0 = OK, 401 / 403 when auth failed
  let closing      = false;
  let sseAbort     = null;

  /* ---- stdin from Claude ---- */
  const rl = readline.createInterface({ input: process.stdin });
  rl.on('line', onStdin).on('close', gracefulExit);
  process.stdin.on('end', gracefulExit);

  function onStdin(line) {
    let msg; try { msg = JSON.parse(line); } catch { return; }
    for (const rpc of (Array.isArray(msg) ? msg : [msg]))
      handleRpc(rpc, line);
  }

  function handleRpc(rpc, rawLine) {
    const { id, method, params } = rpc;

    /* Claude handshake */
    if (method === 'initialize') {
      const res = { protocolVersion: params?.protocolVersion || '2024-11-05',
                    capabilities: {}, serverInfo: { name: 'AI Relay', version: '1.5' } };
      console.log(JSON.stringify({ jsonrpc: '2.0', id, result: res }));
      logB('server', id, method, res);
      return;
    }

    /* auth already failed → instant error */
    if (authFail && id !== undefined) return authError(id, authFail);

    id2method.set(id, method);
    messagesURL ? forward(rawLine, id)     // endpoint known → send now
                : backlog.push({ rawLine, id });
  }

  /* ---- helpers to emit JSON-RPC errors ---- */
  function sendError(id, code, message) {
    if (id === null || id === undefined) return;   // never reply to notifications
    const err = { code, message };
    console.log(JSON.stringify({ jsonrpc: '2.0', id, error: err }));
    logB('server', id, '', err);
  }
  const authError      = (id, s) => sendError(id, s === 401 ? -32001 : -32003,
                                              s === 401 ? 'Authentication required (401)'
                                                         : 'Invalid or insufficient token (403)');
  const transportError = (id, m) => sendError(id, -32000, m);

  /* ---- POST /messages ---- */
  async function forward(rawLine, id) {
    const headers = { 'content-type': 'application/json' };
    if (site.token) headers.authorization = `Bearer ${site.token}`;

    logB('client', id, id2method.get(id), {});
    try {
      pending.add(id);
      const res = await fetchFn(messagesURL, { method: 'POST', headers, body: rawLine });

      if (res.status === 401 || res.status === 403) return authError(id, res.status);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
    } catch (e) {
      logError('post', e, { url: messagesURL });
      transportError(id, '/messages unreachable');
    } finally {
      pending.delete(id);
    }
  }

  /* ---- connect to SSE ---- */
  const endpoint = sseURL(site.url);
  verbose ? console.error('▶ connect', endpoint) : process.stderr.write('AI Engine relay started\n');

  while (!closing) {
    messagesURL = null;
    try {
      sseAbort = new AbortController();
      const headers = {
        accept: 'text/event-stream',
        'cache-control': 'no-cache',
        connection: 'keep-alive',
        'user-agent': 'Mozilla/5.0'
      };
      if (site.token) headers.authorization = `Bearer ${site.token}`;

      const res = await fetchFn(endpoint, { headers, signal: sseAbort.signal });

      /* --- auth failure --- */
      if (res.status === 401 || res.status === 403) {
        authFail = res.status;
        if (verbose) console.error('✗ Unauthorized', res.status);
        logError('sse-auth', 'unauthorized', { status: res.status });
        backlog.forEach(b => authError(b.id, authFail));
        backlog.length = 0;
        pending.forEach(id => authError(id, authFail));
        pending.clear();
        await delay(1000);
        continue;          // stay alive → later RPCs short-circuit
      }

      /* --- wrong content-type --- */
      const ctype = res.headers.get('content-type') || '';
      if (!ctype.startsWith('text/event-stream')) {
        if (verbose) console.error('✗ unexpected content-type', ctype || 'none');
        logError('sse-ctype', ctype, {});
        backlog.forEach(b => transportError(b.id, 'SSE route inactive'));
        backlog.length = 0;
        pending.forEach(id => transportError(id, 'SSE route inactive'));
        pending.clear();
        return;
      }

      verbose && console.error('SSE connected');

      const dec = new TextDecoder();
      let buf = '';
      for await (const chunk of res.body) {
        buf += dec.decode(chunk, { stream: true });
        let i; while ((i = buf.indexOf('\n\n')) !== -1) {
          handleSseFrame(buf.slice(0, i));
          buf = buf.slice(i + 2);
        }
      }
    } catch (e) {
      if (!closing) {
        verbose && console.error('SSE', e.message);
        logError('sse', e, { endpoint });
        backlog.forEach(b => transportError(b.id, 'SSE unreachable'));
        backlog.length = 0;
        pending.forEach(id => transportError(id, 'Server disconnected'));
        pending.clear();
      }
    }
    if (!closing) await delay(2000);   // retry
  }

  /* ---- SSE frame handler ---- */
  function handleSseFrame(frame) {
    const evt  = frame.match(/^event:(.*)/m)?.[1].trim() || 'message';
    const data = frame.match(/(?:^data:|\ndata:)([\s\S]*)/m)?. [1]?.replace(/\ndata:/g, '').trim() || '';

    if (evt === 'endpoint') {
      messagesURL = data;
      verbose && console.error('↪ messages', data);
      backlog.splice(0).forEach(b => forward(b.rawLine, b.id));
      return;
    }

    if (evt === 'message' && !data) return;     // heartbeat
    console.log(data);                          // forward as-is

    try {
      const obj = JSON.parse(data);
      if ('id' in obj) pending.delete(obj.id);
      logB('server', obj.id, '', obj.result ? { result: obj.result }
                                            : { error: obj.error });
    } catch (e) {
      logError('sse-json', e, { raw: data });
    }
  }

  /* ---- graceful exit ---- */
  async function gracefulExit() {
    if (closing) return; closing = true;

    if (messagesURL) {
      try {
        const headers = { 'content-type': 'application/json' };
        if (site.token) headers.authorization = `Bearer ${site.token}`;
        await fetchFn(messagesURL, {
          method: 'POST',
          headers,
          body: JSON.stringify({ jsonrpc: '2.0', method: 'mwai/kill' })
        });
      } catch {/* ignore */}
    }
    sseAbort?.abort();
    process.exit(0);
  }
}