#!/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 * • 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 = (baseUrl, token) => `${baseUrl.replace(/\/+$/, '')}/wp-json/mcp/v1/${token}/sse`; const buildMessagesURL = (baseUrl, token, sessionId) => `${baseUrl.replace(/\/+$/, '')}/wp-json/mcp/v1/${token}/messages?session_id=${sessionId}`; const die = m => { console.error(m); process.exit(1); }; /* colors for terminal output */ const colors = { reset: '\x1b[0m', bright: '\x1b[1m', green: '\x1b[32m', // server data blue: '\x1b[34m', // info lightblue: '\x1b[94m', // test commands white: '\x1b[37m' // script messages }; const c = (color, text) => `${colors[color]}${text}${colors.reset}`; /* ASCII cat welcome */ const showWelcome = () => { console.error(c('white', '')); console.error(c('white', ' /\\_/\\')); console.error(c('white', ' ( o.o )')); console.error(c('white', ' > ^ < Welcome to MCP by AI Engine')); console.error(c('white', '')); }; //////////////////////////////////////////////////////////////////////////////// // 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'); // Cross-platform path for Claude Desktop configuration const CLAUDE_CFG = process.platform === 'win32' ? path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json') : 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 = ` AI Engine MCP Relay - Connects Claude Desktop to WordPress Config: ${CLAUDE_CFG} Quick Start: 1. Get No-Auth URL: WordPress → AI Engine → Dev Tools → MCP Settings 2. ./mcp.js add https://yoursite.com/wp-json/mcp/v1/YOUR_TOKEN/sse 3. Restart Claude Desktop (to load new config) 4. ./mcp.js relay (keeps running, shows tool calls) 5. Use Claude - tools appear automatically! Commands: add Register site (configures Claude Desktop) relay Start relay (keeps running, shows tool calls) start Test connection (verbose output) list Show registered sites select Switch between sites remove Unregister site reset Remove all sites Troubleshooting: • No tools in Claude → Restart Claude Desktop after "add" • Connection issues → Run "./mcp.js start" for details • Logs in ~/.mcp/ `.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 'select': selectSite(); break; case 'reset': resetAll(); break; case 'start': case 'relay': launchRelay(cmd, args[0]); break; case 'post': firePost(args); break; default: console.log(HELP); } /* ---------- CLI actions ---------- */ function addSite(noauthUrl) { if (!noauthUrl) die('add \n\nExample: ./mcp.js add https://example.com/wp-json/mcp/v1/abc123/sse\n\nGet your No-Auth URL from: WordPress → AI Engine → Dev Tools → MCP Settings'); // Parse No-Auth URL to extract base URL and token // Expected format: https://example.com/wp-json/mcp/v1/{token}/sse const match = noauthUrl.match(/^(https?:\/\/[^\/]+)(\/wp-json\/mcp\/v1\/)([^\/]+)(\/sse\/?)?$/); if (!match) { console.log('❌ Invalid No-Auth URL format.'); console.log(''); console.log('Expected format:'); console.log(' https://example.com/wp-json/mcp/v1/YOUR_TOKEN/sse'); console.log(''); console.log('Get your No-Auth URL from:'); console.log(' WordPress → AI Engine → Dev Tools → MCP Settings → Enable No-Auth URL'); return; } const baseUrl = match[1]; // https://example.com const token = match[3]; // abc123 const dom = toDomain(baseUrl); const existed = !!sites[dom]; sites[dom] = { url: baseUrl, token }; saveSites(); setClaudeTarget(dom); console.log(`✓ ${existed ? 'updated' : 'added'} ${baseUrl}`); console.log(` Token: ${token.substring(0, 8)}...`); // Provide guidance about HTTPS vs HTTP if (baseUrl.startsWith('https://')) { console.log('\n📌 Using HTTPS - make sure your SSL certificate is valid.'); console.log(' If you encounter connection issues, ask for the HTTP No-Auth URL.'); } else if (baseUrl.startsWith('http://')) { console.log('\n⚠️ Using HTTP (unencrypted). Consider using HTTPS if available.'); } } function removeSite(ref) { if (!ref) die('remove '); 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)'); const active = activeDomain(); if (!active) { console.log('Claude is not configured to use any of your sites.'); } for (const [domain, site] of Object.entries(sites)) { const marker = active === domain ? '→' : '•'; console.log(marker, site.url); } } 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); if (!sites[dom]) { die('Site not registered. Use "add " first.'); } setClaudeTarget(dom); console.log('✓ Claude →', sites[dom].url); } function selectSite() { const siteList = Object.entries(sites); if (!siteList.length) return console.log('No sites registered. Use "add" to register a site first.'); if (siteList.length === 1) { const [domain, site] = siteList[0]; setClaudeTarget(domain); return console.log('✓ Claude →', site.url); } console.log('Select a site for Claude:'); siteList.forEach(([domain, site], i) => { const current = activeDomain() === domain ? ' (current)' : ''; console.log(` ${i + 1}. ${site.url}${current}`); }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('\nEnter selection (1-' + siteList.length + '): ', (answer) => { const choice = parseInt(answer) - 1; if (choice >= 0 && choice < siteList.length) { const [domain, site] = siteList[choice]; setClaudeTarget(domain); console.log('✓ Claude →', site.url); } else { console.log('Invalid selection'); } rl.close(); }); } function resetAll() { // Clear all sites sites = {}; saveSites(); // Remove AI Engine from Claude config const cfg = readJSON(CLAUDE_CFG); if (cfg.mcpServers && cfg.mcpServers['AI Engine']) { delete cfg.mcpServers['AI Engine']; writeJSON(CLAUDE_CFG, cfg); } // Clear log files try { if (fs.existsSync(LOG_HDR)) fs.unlinkSync(LOG_HDR); if (fs.existsSync(LOG_BODY)) fs.unlinkSync(LOG_BODY); if (fs.existsSync(ERR_LOG)) fs.unlinkSync(ERR_LOG); } catch (e) { // Ignore errors when deleting log files } console.log('✓ All sites removed'); console.log('✓ Claude configuration cleared'); console.log('✓ Log files deleted'); console.log('\nMCP configuration has been reset.'); } //////////////////////////////////////////////////////////////////////////////// // manual POST (debug) //////////////////////////////////////////////////////////////////////////////// async function firePost([dom, json, sid]) { if (!dom || !json || !sid) die('post '); const site = sites[toDomain(dom)]; if (!site) die('unknown site'); const fetchFn = global.fetch || (await import('node-fetch')).default; const url = buildMessagesURL(site.url, site.token, sid); const headers = { 'content-type': 'application/json' }; 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); const isVerbose = mode === 'start'; const showRaw = process.argv.includes('--raw'); runRelay(sites[dom], isVerbose, showRaw) .catch(e => { logError('fatal', e); process.exit(1); }); } function pickSite(ref) { if (ref) return toDomain(ref); const active = activeDomain(); if (active && sites[active]) return active; const keys = Object.keys(sites); if (!keys.length) die('no sites registered'); if (keys.length === 1) return keys[0]; die('multiple sites: ' + keys.join(', ') + ' (use "select" to choose)'); } //////////////////////////////////////////////////////////////////////////////// // relay core //////////////////////////////////////////////////////////////////////////////// async function runRelay(site, verbose, showRaw = false) { if (!site.token) { die('No token configured for this site. Use "add " to register.'); } 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); /* log tool calls in relay mode */ if (!verbose && method === 'tools/call' && params?.name) { const now = new Date().toISOString().replace('T', ' ').substring(0, 19); process.stderr.write(`[${now}] ${params.name}\n`); } 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' }; 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, site.token); if (verbose) { showWelcome(); console.error(c('white', '▶ Connecting to MCP server')); console.error(c('blue', endpoint)); console.error(''); } else { const now = new Date().toISOString().replace('T', ' ').substring(0, 19); process.stderr.write(`[${now}] Relay started → ${site.url}\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' }; 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(c('white', '✓ SSE connection established')); 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') { // Extract session_id from the returned endpoint URL const sessionMatch = data.match(/session_id=([^&]+)/); if (!sessionMatch) { console.error('✗ Failed to extract session_id from endpoint'); return; } const sessionId = sessionMatch[1]; // Build No-Auth messages URL messagesURL = buildMessagesURL(site.url, site.token, sessionId); if (verbose) { console.error(c('white', '✓ MCP server connected')); console.error(c('green', messagesURL)); console.error(''); const domain = toDomain(site.url); const toolsCmd = `${SELF} post ${domain} '{"jsonrpc":"2.0","method":"tools/list","id":1}' ${sessionId}`; const pingCmd = `${SELF} post ${domain} '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"mcp_ping","arguments":{}},"id":2}' ${sessionId}`; console.error(c('white', 'Test the connection in another terminal:')); console.error(c('white', 'Simple ping test:')); console.error(c('lightblue', pingCmd)); console.error(''); console.error(c('white', 'List all available tools:')); console.error(c('lightblue', toolsCmd)); console.error(''); console.error(c('white', 'For raw JSON responses, add'), c('lightblue', '--raw'), c('white', 'to any command')); console.error(c('blue', 'Results will appear in this terminal.')); console.error(''); } backlog.splice(0).forEach(b => forward(b.rawLine, b.id)); return; } if (evt === 'message' && !data) return; // heartbeat // Show received data message in verbose mode if (verbose) { try { const obj = JSON.parse(data); if ('id' in obj) { console.error(c('white', `▼ Response for ID ${obj.id}:`)); // Show raw JSON if requested if (showRaw) { console.error(c('green', JSON.stringify(obj, null, 2))); } // Format MCP tool results nicely else if (obj.result) { console.error(c('green', '✓ Success')); // Special formatting for mcp_ping if (obj.result.data && obj.result.data.time && obj.result.data.name) { console.error(c('white', `Time: ${obj.result.data.time}`)); console.error(c('white', `Site: ${obj.result.data.name}`)); if (obj.result.data.tools_count !== undefined) { console.error(c('white', `Tools: ${obj.result.data.tools_count} available`)); } } // Special formatting for tools/list else if (obj.result.tools && Array.isArray(obj.result.tools)) { console.error(c('white', `Found ${obj.result.tools.length} tools:\n`)); obj.result.tools.forEach((tool, index) => { // Tool name and description console.error(c('bright', `${index + 1}. ${tool.name || 'unnamed'}`)); if (tool.description) { console.error(c('white', ` ${tool.description}`)); } // Input parameters if (tool.inputSchema && tool.inputSchema.properties) { const props = tool.inputSchema.properties; const required = tool.inputSchema.required || []; const propKeys = Object.keys(props); if (propKeys.length > 0) { console.error(c('blue', ' Parameters:')); propKeys.forEach(key => { const prop = props[key]; const isRequired = required.includes(key); const reqIcon = isRequired ? '*' : '-'; const typeInfo = prop.type ? ` (${prop.type})` : ''; const desc = prop.description ? ` - ${prop.description}` : ''; console.error(c('white', ` ${reqIcon} ${key}${typeInfo}${desc}`)); }); } else { console.error(c('blue', ' No parameters required')); } } else { console.error(c('blue', ' No parameters required')); } console.error(''); // Empty line between tools }); } // Fallback for other structured data else if (obj.result.data) { console.error(c('white', 'Data:')); console.error(c('green', JSON.stringify(obj.result.data, null, 2))); } // Generic result display else { console.error(c('green', JSON.stringify(obj.result, null, 2))); } } // Handle errors else if (obj.error) { console.error(c('white', '✗ Error:')); console.error(c('green', `${obj.error.code}: ${obj.error.message}`)); } // Fallback to raw JSON for other responses else { console.error(c('green', JSON.stringify(obj, null, 2))); } console.error(''); // Don't forward to console.log in verbose mode for responses with IDs return; } } catch (e) { console.error(c('white', 'Received Data:')); console.error(c('green', data)); console.error(''); } } 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' }; await fetchFn(messagesURL, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'mwai/kill' }) }); } catch {/* ignore */} } sseAbort?.abort(); process.exit(0); } }