mcp.conf
1 year ago
mcp.js
1 year ago
mcp.md
1 year ago
mcp.php
1 year ago
mcp_core.php
1 year ago
mcp_rest.php
1 year ago
realtime.php
1 year ago
mcp.js
361 lines
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Claude ↔ AI-Engine MCP relay |
| 4 | * -------------------------------- |
| 5 | * Connects Claude Desktop (JSON-RPC on stdin/stdout) to a WordPress site that |
| 6 | * exposes: |
| 7 | * • GET /wp-json/mcp/v1/sse (Server-Sent Events stream) |
| 8 | * • POST /wp-json/mcp/v1/messages (JSON-RPC ingress) |
| 9 | * |
| 10 | * If the site is protected by a Bearer token: |
| 11 | * • Store the token per-site in ~/.mcp/sites.json |
| 12 | * • Relay adds Authorization: Bearer <token> |
| 13 | * • 401 / 403 responses are converted to JSON-RPC errors −32001 / −32003 |
| 14 | * so Claude shows an immediate, clear message instead of timing out. |
| 15 | */ |
| 16 | |
| 17 | //////////////////////////////////////////////////////////////////////////////// |
| 18 | // imports & tiny helpers |
| 19 | //////////////////////////////////////////////////////////////////////////////// |
| 20 | const fs = require('fs'); |
| 21 | const os = require('os'); |
| 22 | const path = require('path'); |
| 23 | const readline = require('readline'); |
| 24 | const { setTimeout: delay } = require('timers/promises'); |
| 25 | |
| 26 | const readJSON = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } }; |
| 27 | const writeJSON = (f, o) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(o, null, 2)); }; |
| 28 | |
| 29 | const toDomain = s => new URL(/^https?:/.test(s) ? s : `https://${s}`).hostname.toLowerCase(); |
| 30 | const sseURL = u => u.replace(/\/+$/, '') + '/wp-json/mcp/v1/sse/'; |
| 31 | const die = m => { console.error(m); process.exit(1); }; |
| 32 | |
| 33 | //////////////////////////////////////////////////////////////////////////////// |
| 34 | // paths & persistent state |
| 35 | //////////////////////////////////////////////////////////////////////////////// |
| 36 | const HOME = os.homedir(); |
| 37 | const MCP_DIR = path.join(HOME, '.mcp'); fs.mkdirSync(MCP_DIR, { recursive: true }); |
| 38 | const SITE_CFG = path.join(MCP_DIR, 'sites.json'); |
| 39 | const LOG_HDR = path.join(MCP_DIR, 'mcp.log'); |
| 40 | const LOG_BODY = path.join(MCP_DIR, 'mcp-results.log'); |
| 41 | const ERR_LOG = path.join(MCP_DIR, 'error.log'); |
| 42 | const CLAUDE_CFG = path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); |
| 43 | const SELF = path.resolve(__filename); |
| 44 | |
| 45 | /* load sites config (upgrade legacy string → object) */ |
| 46 | let sites = readJSON(SITE_CFG); |
| 47 | for (const [d, v] of Object.entries(sites)) |
| 48 | if (typeof v === 'string') sites[d] = { url: v, token: '' }; |
| 49 | const saveSites = () => writeJSON(SITE_CFG, sites); |
| 50 | |
| 51 | /* micro JSON-lines logger */ |
| 52 | function logError(kind, err, extra = {}) { |
| 53 | const entry = { ts: new Date().toISOString(), kind, |
| 54 | msg: err?.message || err, stack: err?.stack, ...extra }; |
| 55 | fs.appendFileSync(ERR_LOG, JSON.stringify(entry) + '\n'); |
| 56 | } |
| 57 | process.on('uncaughtException', e => logError('uncaught', e)); |
| 58 | process.on('unhandledRejection', e => logError('unhandled', e)); |
| 59 | |
| 60 | //////////////////////////////////////////////////////////////////////////////// |
| 61 | // Claude Desktop integration (updates claude_desktop_config.json) |
| 62 | //////////////////////////////////////////////////////////////////////////////// |
| 63 | function setClaudeTarget(domain) { |
| 64 | const cfg = readJSON(CLAUDE_CFG); |
| 65 | cfg.mcpServers ??= {}; |
| 66 | cfg.mcpServers['AI Engine'] = { command: SELF, args: ['relay', domain] }; |
| 67 | writeJSON(CLAUDE_CFG, cfg); |
| 68 | } |
| 69 | const activeDomain = () => readJSON(CLAUDE_CFG)?.mcpServers?.['AI Engine']?.args?.[1] || null; |
| 70 | |
| 71 | //////////////////////////////////////////////////////////////////////////////// |
| 72 | // CLI |
| 73 | //////////////////////////////////////////////////////////////////////////////// |
| 74 | const [ , , cmd = 'help', ...args] = process.argv; |
| 75 | |
| 76 | const HELP = ` |
| 77 | add <site-url> [token] Register / update site (and set Claude target) |
| 78 | remove <domain|url> Unregister site |
| 79 | list Show sites |
| 80 | claude [domain|url] Show / change Claude target |
| 81 | start [domain|url] Verbose relay |
| 82 | relay <domain|url> Silent relay (for Claude Desktop) |
| 83 | post <domain> <json> <sid> Fire raw JSON-RPC (debug) |
| 84 | help This help |
| 85 | `.trim(); |
| 86 | |
| 87 | switch (cmd) { |
| 88 | case 'add': addSite(...args); break; |
| 89 | case 'remove': removeSite(args[0]); break; |
| 90 | case 'list': listSites(); break; |
| 91 | case 'claude': claudeCmd(args[0]); break; |
| 92 | case 'start': |
| 93 | case 'relay': launchRelay(cmd, args[0]); break; |
| 94 | case 'post': firePost(args); break; |
| 95 | default: console.log(HELP); |
| 96 | } |
| 97 | |
| 98 | /* ---------- CLI actions ---------- */ |
| 99 | function addSite(url, token = '') { |
| 100 | if (!url) die('add <site-url> [token]'); |
| 101 | const norm = url.replace(/\/+$/, ''); |
| 102 | const dom = toDomain(norm); |
| 103 | const existed = !!sites[dom]; |
| 104 | sites[dom] = { url: norm, token }; |
| 105 | saveSites(); setClaudeTarget(dom); |
| 106 | console.log(`✓ ${existed ? 'updated' : 'added'} ${norm}`); |
| 107 | } |
| 108 | function removeSite(ref) { |
| 109 | if (!ref) die('remove <domain|url>'); |
| 110 | const dom = toDomain(ref); |
| 111 | if (!sites[dom]) die('unknown site'); |
| 112 | delete sites[dom]; saveSites(); |
| 113 | if (activeDomain() === dom) setClaudeTarget(Object.keys(sites)[0] || 'missing'); |
| 114 | console.log('✓ removed', ref); |
| 115 | } |
| 116 | function listSites() { |
| 117 | if (!Object.keys(sites).length) return console.log('(no sites)'); |
| 118 | for (const s of Object.values(sites)) |
| 119 | console.log('•', s.url, s.token ? '(token set)' : ''); |
| 120 | } |
| 121 | function claudeCmd(ref) { |
| 122 | if (!ref) return console.log(activeDomain() |
| 123 | ? `Claude: ${sites[activeDomain()].url}` : '(no site)'); |
| 124 | const full = /^https?:/.test(ref) ? ref : `https://${ref}`; |
| 125 | const dom = toDomain(full); |
| 126 | sites[dom] = sites[dom] || { url: full, token: '' }; |
| 127 | saveSites(); setClaudeTarget(dom); |
| 128 | console.log('✓ Claude →', sites[dom].url); |
| 129 | } |
| 130 | |
| 131 | //////////////////////////////////////////////////////////////////////////////// |
| 132 | // manual POST (debug) |
| 133 | //////////////////////////////////////////////////////////////////////////////// |
| 134 | async function firePost([dom, json, sid]) { |
| 135 | if (!dom || !json || !sid) die('post <domain> <json> <sid>'); |
| 136 | const site = sites[toDomain(dom)]; |
| 137 | if (!site) die('unknown site'); |
| 138 | |
| 139 | const fetchFn = global.fetch || (await import('node-fetch')).default; |
| 140 | const url = `${site.url.replace(/\/+$/, '')}/wp-json/mcp/v1/messages?session_id=${sid}`; |
| 141 | const headers = { 'content-type': 'application/json' }; |
| 142 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 143 | |
| 144 | const res = await fetchFn(url, { method: 'POST', headers, body: json }); |
| 145 | console.log('HTTP', res.status); |
| 146 | console.log(await res.text()); |
| 147 | } |
| 148 | |
| 149 | //////////////////////////////////////////////////////////////////////////////// |
| 150 | // launch relay |
| 151 | //////////////////////////////////////////////////////////////////////////////// |
| 152 | function launchRelay(mode, ref) { |
| 153 | const dom = pickSite(ref); |
| 154 | runRelay(sites[dom], mode === 'start') |
| 155 | .catch(e => { logError('fatal', e); process.exit(1); }); |
| 156 | } |
| 157 | function pickSite(ref) { |
| 158 | if (ref) return toDomain(ref); |
| 159 | const keys = Object.keys(sites); |
| 160 | if (!keys.length) die('no sites registered'); |
| 161 | if (keys.length === 1) return keys[0]; |
| 162 | die('multiple sites: ' + keys.join(', ')); |
| 163 | } |
| 164 | |
| 165 | //////////////////////////////////////////////////////////////////////////////// |
| 166 | // relay core |
| 167 | //////////////////////////////////////////////////////////////////////////////// |
| 168 | async function runRelay(site, verbose) { |
| 169 | const fetchFn = global.fetch || (await import('node-fetch')).default; |
| 170 | |
| 171 | /* ---- tiny disk logs ---- */ |
| 172 | fs.writeFileSync(LOG_HDR, ''); fs.writeFileSync(LOG_BODY, ''); |
| 173 | const hdr = fs.createWriteStream(LOG_HDR, { flags: 'a' }); |
| 174 | const bod = fs.createWriteStream(LOG_BODY, { flags: 'a' }); |
| 175 | const logH = (dir, id, msg='') => hdr.write(`${new Date().toISOString()} ${dir} id=${id ?? '-'} ${msg}\n`); |
| 176 | const logB = (dir, id, msg, obj) => { logH(dir, id, msg); bod.write(JSON.stringify(obj, null, 2) + '\n\n'); }; |
| 177 | |
| 178 | /* ---- runtime state ---- */ |
| 179 | let messagesURL = null; // set after “endpoint” event |
| 180 | const backlog = []; // queued before endpoint known |
| 181 | const pending = new Set(); // ids waiting reply |
| 182 | const id2method = new Map(); // for nicer logs |
| 183 | let authFail = 0; // 0 = OK, 401 / 403 when auth failed |
| 184 | let closing = false; |
| 185 | let sseAbort = null; |
| 186 | |
| 187 | /* ---- stdin from Claude ---- */ |
| 188 | const rl = readline.createInterface({ input: process.stdin }); |
| 189 | rl.on('line', onStdin).on('close', gracefulExit); |
| 190 | process.stdin.on('end', gracefulExit); |
| 191 | |
| 192 | function onStdin(line) { |
| 193 | let msg; try { msg = JSON.parse(line); } catch { return; } |
| 194 | for (const rpc of (Array.isArray(msg) ? msg : [msg])) |
| 195 | handleRpc(rpc, line); |
| 196 | } |
| 197 | |
| 198 | function handleRpc(rpc, rawLine) { |
| 199 | const { id, method, params } = rpc; |
| 200 | |
| 201 | /* Claude handshake */ |
| 202 | if (method === 'initialize') { |
| 203 | const res = { protocolVersion: params?.protocolVersion || '2024-11-05', |
| 204 | capabilities: {}, serverInfo: { name: 'AI Relay', version: '1.5' } }; |
| 205 | console.log(JSON.stringify({ jsonrpc: '2.0', id, result: res })); |
| 206 | logB('server', id, method, res); |
| 207 | return; |
| 208 | } |
| 209 | |
| 210 | /* auth already failed → instant error */ |
| 211 | if (authFail && id !== undefined) return authError(id, authFail); |
| 212 | |
| 213 | id2method.set(id, method); |
| 214 | messagesURL ? forward(rawLine, id) // endpoint known → send now |
| 215 | : backlog.push({ rawLine, id }); |
| 216 | } |
| 217 | |
| 218 | /* ---- helpers to emit JSON-RPC errors ---- */ |
| 219 | function sendError(id, code, message) { |
| 220 | if (id === null || id === undefined) return; // never reply to notifications |
| 221 | const err = { code, message }; |
| 222 | console.log(JSON.stringify({ jsonrpc: '2.0', id, error: err })); |
| 223 | logB('server', id, '', err); |
| 224 | } |
| 225 | const authError = (id, s) => sendError(id, s === 401 ? -32001 : -32003, |
| 226 | s === 401 ? 'Authentication required (401)' |
| 227 | : 'Invalid or insufficient token (403)'); |
| 228 | const transportError = (id, m) => sendError(id, -32000, m); |
| 229 | |
| 230 | /* ---- POST /messages ---- */ |
| 231 | async function forward(rawLine, id) { |
| 232 | const headers = { 'content-type': 'application/json' }; |
| 233 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 234 | |
| 235 | logB('client', id, id2method.get(id), {}); |
| 236 | try { |
| 237 | pending.add(id); |
| 238 | const res = await fetchFn(messagesURL, { method: 'POST', headers, body: rawLine }); |
| 239 | |
| 240 | if (res.status === 401 || res.status === 403) return authError(id, res.status); |
| 241 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 242 | } catch (e) { |
| 243 | logError('post', e, { url: messagesURL }); |
| 244 | transportError(id, '/messages unreachable'); |
| 245 | } finally { |
| 246 | pending.delete(id); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /* ---- connect to SSE ---- */ |
| 251 | const endpoint = sseURL(site.url); |
| 252 | verbose ? console.error('▶ connect', endpoint) : process.stderr.write('AI Engine relay started\n'); |
| 253 | |
| 254 | while (!closing) { |
| 255 | messagesURL = null; |
| 256 | try { |
| 257 | sseAbort = new AbortController(); |
| 258 | const headers = { |
| 259 | accept: 'text/event-stream', |
| 260 | 'cache-control': 'no-cache', |
| 261 | connection: 'keep-alive', |
| 262 | 'user-agent': 'Mozilla/5.0' |
| 263 | }; |
| 264 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 265 | |
| 266 | const res = await fetchFn(endpoint, { headers, signal: sseAbort.signal }); |
| 267 | |
| 268 | /* --- auth failure --- */ |
| 269 | if (res.status === 401 || res.status === 403) { |
| 270 | authFail = res.status; |
| 271 | if (verbose) console.error('✗ Unauthorized', res.status); |
| 272 | logError('sse-auth', 'unauthorized', { status: res.status }); |
| 273 | backlog.forEach(b => authError(b.id, authFail)); |
| 274 | backlog.length = 0; |
| 275 | pending.forEach(id => authError(id, authFail)); |
| 276 | pending.clear(); |
| 277 | await delay(1000); |
| 278 | continue; // stay alive → later RPCs short-circuit |
| 279 | } |
| 280 | |
| 281 | /* --- wrong content-type --- */ |
| 282 | const ctype = res.headers.get('content-type') || ''; |
| 283 | if (!ctype.startsWith('text/event-stream')) { |
| 284 | if (verbose) console.error('✗ unexpected content-type', ctype || 'none'); |
| 285 | logError('sse-ctype', ctype, {}); |
| 286 | backlog.forEach(b => transportError(b.id, 'SSE route inactive')); |
| 287 | backlog.length = 0; |
| 288 | pending.forEach(id => transportError(id, 'SSE route inactive')); |
| 289 | pending.clear(); |
| 290 | return; |
| 291 | } |
| 292 | |
| 293 | verbose && console.error('SSE connected'); |
| 294 | |
| 295 | const dec = new TextDecoder(); |
| 296 | let buf = ''; |
| 297 | for await (const chunk of res.body) { |
| 298 | buf += dec.decode(chunk, { stream: true }); |
| 299 | let i; while ((i = buf.indexOf('\n\n')) !== -1) { |
| 300 | handleSseFrame(buf.slice(0, i)); |
| 301 | buf = buf.slice(i + 2); |
| 302 | } |
| 303 | } |
| 304 | } catch (e) { |
| 305 | if (!closing) { |
| 306 | verbose && console.error('SSE', e.message); |
| 307 | logError('sse', e, { endpoint }); |
| 308 | backlog.forEach(b => transportError(b.id, 'SSE unreachable')); |
| 309 | backlog.length = 0; |
| 310 | pending.forEach(id => transportError(id, 'Server disconnected')); |
| 311 | pending.clear(); |
| 312 | } |
| 313 | } |
| 314 | if (!closing) await delay(2000); // retry |
| 315 | } |
| 316 | |
| 317 | /* ---- SSE frame handler ---- */ |
| 318 | function handleSseFrame(frame) { |
| 319 | const evt = frame.match(/^event:(.*)/m)?.[1].trim() || 'message'; |
| 320 | const data = frame.match(/(?:^data:|\ndata:)([\s\S]*)/m)?. [1]?.replace(/\ndata:/g, '').trim() || ''; |
| 321 | |
| 322 | if (evt === 'endpoint') { |
| 323 | messagesURL = data; |
| 324 | verbose && console.error('↪ messages', data); |
| 325 | backlog.splice(0).forEach(b => forward(b.rawLine, b.id)); |
| 326 | return; |
| 327 | } |
| 328 | |
| 329 | if (evt === 'message' && !data) return; // heartbeat |
| 330 | console.log(data); // forward as-is |
| 331 | |
| 332 | try { |
| 333 | const obj = JSON.parse(data); |
| 334 | if ('id' in obj) pending.delete(obj.id); |
| 335 | logB('server', obj.id, '', obj.result ? { result: obj.result } |
| 336 | : { error: obj.error }); |
| 337 | } catch (e) { |
| 338 | logError('sse-json', e, { raw: data }); |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | /* ---- graceful exit ---- */ |
| 343 | async function gracefulExit() { |
| 344 | if (closing) return; closing = true; |
| 345 | |
| 346 | if (messagesURL) { |
| 347 | try { |
| 348 | const headers = { 'content-type': 'application/json' }; |
| 349 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 350 | await fetchFn(messagesURL, { |
| 351 | method: 'POST', |
| 352 | headers, |
| 353 | body: JSON.stringify({ jsonrpc: '2.0', method: 'mwai/kill' }) |
| 354 | }); |
| 355 | } catch {/* ignore */} |
| 356 | } |
| 357 | sseAbort?.abort(); |
| 358 | process.exit(0); |
| 359 | } |
| 360 | } |
| 361 |