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
oauth.php
1 year ago
realtime.php
1 year ago
mcp.js
597 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 | /* colors for terminal output */ |
| 34 | const colors = { |
| 35 | reset: '\x1b[0m', |
| 36 | bright: '\x1b[1m', |
| 37 | green: '\x1b[32m', // server data |
| 38 | blue: '\x1b[34m', // info |
| 39 | lightblue: '\x1b[94m', // test commands |
| 40 | white: '\x1b[37m' // script messages |
| 41 | }; |
| 42 | const c = (color, text) => `${colors[color]}${text}${colors.reset}`; |
| 43 | |
| 44 | /* ASCII cat welcome */ |
| 45 | const showWelcome = () => { |
| 46 | console.error(c('white', '')); |
| 47 | console.error(c('white', ' /\\_/\\')); |
| 48 | console.error(c('white', ' ( o.o )')); |
| 49 | console.error(c('white', ' > ^ < Welcome to MCP by AI Engine')); |
| 50 | console.error(c('white', '')); |
| 51 | }; |
| 52 | |
| 53 | //////////////////////////////////////////////////////////////////////////////// |
| 54 | // paths & persistent state |
| 55 | //////////////////////////////////////////////////////////////////////////////// |
| 56 | const HOME = os.homedir(); |
| 57 | const MCP_DIR = path.join(HOME, '.mcp'); fs.mkdirSync(MCP_DIR, { recursive: true }); |
| 58 | const SITE_CFG = path.join(MCP_DIR, 'sites.json'); |
| 59 | const LOG_HDR = path.join(MCP_DIR, 'mcp.log'); |
| 60 | const LOG_BODY = path.join(MCP_DIR, 'mcp-results.log'); |
| 61 | const ERR_LOG = path.join(MCP_DIR, 'error.log'); |
| 62 | const CLAUDE_CFG = path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); |
| 63 | const SELF = path.resolve(__filename); |
| 64 | |
| 65 | /* load sites config (upgrade legacy string → object) */ |
| 66 | let sites = readJSON(SITE_CFG); |
| 67 | for (const [d, v] of Object.entries(sites)) |
| 68 | if (typeof v === 'string') sites[d] = { url: v, token: '' }; |
| 69 | const saveSites = () => writeJSON(SITE_CFG, sites); |
| 70 | |
| 71 | /* micro JSON-lines logger */ |
| 72 | function logError(kind, err, extra = {}) { |
| 73 | const entry = { ts: new Date().toISOString(), kind, |
| 74 | msg: err?.message || err, stack: err?.stack, ...extra }; |
| 75 | fs.appendFileSync(ERR_LOG, JSON.stringify(entry) + '\n'); |
| 76 | } |
| 77 | process.on('uncaughtException', e => logError('uncaught', e)); |
| 78 | process.on('unhandledRejection', e => logError('unhandled', e)); |
| 79 | |
| 80 | //////////////////////////////////////////////////////////////////////////////// |
| 81 | // Claude Desktop integration (updates claude_desktop_config.json) |
| 82 | //////////////////////////////////////////////////////////////////////////////// |
| 83 | function setClaudeTarget(domain) { |
| 84 | const cfg = readJSON(CLAUDE_CFG); |
| 85 | cfg.mcpServers ??= {}; |
| 86 | cfg.mcpServers['AI Engine'] = { command: SELF, args: ['relay', domain] }; |
| 87 | writeJSON(CLAUDE_CFG, cfg); |
| 88 | } |
| 89 | const activeDomain = () => readJSON(CLAUDE_CFG)?.mcpServers?.['AI Engine']?.args?.[1] || null; |
| 90 | |
| 91 | //////////////////////////////////////////////////////////////////////////////// |
| 92 | // CLI |
| 93 | //////////////////////////////////////////////////////////////////////////////// |
| 94 | const [ , , cmd = 'help', ...args] = process.argv; |
| 95 | |
| 96 | const HELP = ` |
| 97 | add <site-url> <token> Register / update site (and set Claude target) |
| 98 | remove <domain|url> Unregister site |
| 99 | list Show sites |
| 100 | claude [domain|url] Show / change Claude target |
| 101 | select Interactively select a site for Claude |
| 102 | reset Remove all registered sites and reset configuration |
| 103 | start [domain|url] [--raw] Verbose relay (add --raw for JSON responses) |
| 104 | relay <domain|url> Silent relay (for Claude Desktop) |
| 105 | post <domain> <json> <sid> Fire raw JSON-RPC (debug) |
| 106 | help This help |
| 107 | `.trim(); |
| 108 | |
| 109 | switch (cmd) { |
| 110 | case 'add': addSite(...args); break; |
| 111 | case 'remove': removeSite(args[0]); break; |
| 112 | case 'list': listSites(); break; |
| 113 | case 'claude': claudeCmd(args[0]); break; |
| 114 | case 'select': selectSite(); break; |
| 115 | case 'reset': resetAll(); break; |
| 116 | case 'start': |
| 117 | case 'relay': launchRelay(cmd, args[0]); break; |
| 118 | case 'post': firePost(args); break; |
| 119 | default: console.log(HELP); |
| 120 | } |
| 121 | |
| 122 | /* ---------- CLI actions ---------- */ |
| 123 | function addSite(url, token) { |
| 124 | if (!url || !token) die('add <site-url> <token> (token is required)'); |
| 125 | |
| 126 | // Check if URL contains /sse or other API paths |
| 127 | if (url.includes('/wp-json/') || url.includes('/sse')) { |
| 128 | console.log('⚠️ Please use the base URL of your website, not the API endpoint.'); |
| 129 | console.log(' Example: https://example.com instead of https://example.com/wp-json/mcp/v1/sse'); |
| 130 | return; |
| 131 | } |
| 132 | |
| 133 | const norm = url.replace(/\/+$/, ''); |
| 134 | const dom = toDomain(norm); |
| 135 | const existed = !!sites[dom]; |
| 136 | sites[dom] = { url: norm, token }; |
| 137 | saveSites(); setClaudeTarget(dom); |
| 138 | console.log(`✓ ${existed ? 'updated' : 'added'} ${norm}`); |
| 139 | |
| 140 | // Provide guidance about HTTPS vs HTTP |
| 141 | if (norm.startsWith('https://')) { |
| 142 | console.log('\n📌 Using HTTPS - make sure your SSL certificate is valid.'); |
| 143 | console.log(' If you encounter connection issues, try using http:// instead.'); |
| 144 | } else if (norm.startsWith('http://')) { |
| 145 | console.log('\n📌 Using HTTP (unencrypted). Consider using https:// if available.'); |
| 146 | } |
| 147 | } |
| 148 | function removeSite(ref) { |
| 149 | if (!ref) die('remove <domain|url>'); |
| 150 | const dom = toDomain(ref); |
| 151 | if (!sites[dom]) die('unknown site'); |
| 152 | delete sites[dom]; saveSites(); |
| 153 | if (activeDomain() === dom) setClaudeTarget(Object.keys(sites)[0] || 'missing'); |
| 154 | console.log('✓ removed', ref); |
| 155 | } |
| 156 | function listSites() { |
| 157 | if (!Object.keys(sites).length) return console.log('(no sites)'); |
| 158 | |
| 159 | const active = activeDomain(); |
| 160 | if (!active) { |
| 161 | console.log('Claude is not configured to use any of your sites.'); |
| 162 | } |
| 163 | |
| 164 | for (const [domain, site] of Object.entries(sites)) { |
| 165 | const marker = active === domain ? '→' : '•'; |
| 166 | console.log(marker, site.url); |
| 167 | } |
| 168 | } |
| 169 | function claudeCmd(ref) { |
| 170 | if (!ref) return console.log(activeDomain() |
| 171 | ? `Claude: ${sites[activeDomain()].url}` : '(no site)'); |
| 172 | const full = /^https?:/.test(ref) ? ref : `https://${ref}`; |
| 173 | const dom = toDomain(full); |
| 174 | if (!sites[dom]) { |
| 175 | die('Site not registered. Use "add <site-url> <token>" first.'); |
| 176 | } |
| 177 | setClaudeTarget(dom); |
| 178 | console.log('✓ Claude →', sites[dom].url); |
| 179 | } |
| 180 | function selectSite() { |
| 181 | const siteList = Object.entries(sites); |
| 182 | if (!siteList.length) return console.log('No sites registered. Use "add" to register a site first.'); |
| 183 | |
| 184 | if (siteList.length === 1) { |
| 185 | const [domain, site] = siteList[0]; |
| 186 | setClaudeTarget(domain); |
| 187 | return console.log('✓ Claude →', site.url); |
| 188 | } |
| 189 | |
| 190 | console.log('Select a site for Claude:'); |
| 191 | siteList.forEach(([domain, site], i) => { |
| 192 | const current = activeDomain() === domain ? ' (current)' : ''; |
| 193 | console.log(` ${i + 1}. ${site.url}${current}`); |
| 194 | }); |
| 195 | |
| 196 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); |
| 197 | rl.question('\nEnter selection (1-' + siteList.length + '): ', (answer) => { |
| 198 | const choice = parseInt(answer) - 1; |
| 199 | if (choice >= 0 && choice < siteList.length) { |
| 200 | const [domain, site] = siteList[choice]; |
| 201 | setClaudeTarget(domain); |
| 202 | console.log('✓ Claude →', site.url); |
| 203 | } else { |
| 204 | console.log('Invalid selection'); |
| 205 | } |
| 206 | rl.close(); |
| 207 | }); |
| 208 | } |
| 209 | function resetAll() { |
| 210 | // Clear all sites |
| 211 | sites = {}; |
| 212 | saveSites(); |
| 213 | |
| 214 | // Remove AI Engine from Claude config |
| 215 | const cfg = readJSON(CLAUDE_CFG); |
| 216 | if (cfg.mcpServers && cfg.mcpServers['AI Engine']) { |
| 217 | delete cfg.mcpServers['AI Engine']; |
| 218 | writeJSON(CLAUDE_CFG, cfg); |
| 219 | } |
| 220 | |
| 221 | // Clear log files |
| 222 | try { |
| 223 | if (fs.existsSync(LOG_HDR)) fs.unlinkSync(LOG_HDR); |
| 224 | if (fs.existsSync(LOG_BODY)) fs.unlinkSync(LOG_BODY); |
| 225 | if (fs.existsSync(ERR_LOG)) fs.unlinkSync(ERR_LOG); |
| 226 | } catch (e) { |
| 227 | // Ignore errors when deleting log files |
| 228 | } |
| 229 | |
| 230 | console.log('✓ All sites removed'); |
| 231 | console.log('✓ Claude configuration cleared'); |
| 232 | console.log('✓ Log files deleted'); |
| 233 | console.log('\nMCP configuration has been reset.'); |
| 234 | } |
| 235 | |
| 236 | //////////////////////////////////////////////////////////////////////////////// |
| 237 | // manual POST (debug) |
| 238 | //////////////////////////////////////////////////////////////////////////////// |
| 239 | async function firePost([dom, json, sid]) { |
| 240 | if (!dom || !json || !sid) die('post <domain> <json> <sid>'); |
| 241 | const site = sites[toDomain(dom)]; |
| 242 | if (!site) die('unknown site'); |
| 243 | |
| 244 | const fetchFn = global.fetch || (await import('node-fetch')).default; |
| 245 | const url = `${site.url.replace(/\/+$/, '')}/wp-json/mcp/v1/messages?session_id=${sid}`; |
| 246 | const headers = { 'content-type': 'application/json' }; |
| 247 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 248 | |
| 249 | const res = await fetchFn(url, { method: 'POST', headers, body: json }); |
| 250 | console.log('HTTP', res.status); |
| 251 | console.log(await res.text()); |
| 252 | } |
| 253 | |
| 254 | //////////////////////////////////////////////////////////////////////////////// |
| 255 | // launch relay |
| 256 | //////////////////////////////////////////////////////////////////////////////// |
| 257 | function launchRelay(mode, ref) { |
| 258 | const dom = pickSite(ref); |
| 259 | const isVerbose = mode === 'start'; |
| 260 | const showRaw = process.argv.includes('--raw'); |
| 261 | runRelay(sites[dom], isVerbose, showRaw) |
| 262 | .catch(e => { logError('fatal', e); process.exit(1); }); |
| 263 | } |
| 264 | function pickSite(ref) { |
| 265 | if (ref) return toDomain(ref); |
| 266 | |
| 267 | const active = activeDomain(); |
| 268 | if (active && sites[active]) return active; |
| 269 | |
| 270 | const keys = Object.keys(sites); |
| 271 | if (!keys.length) die('no sites registered'); |
| 272 | if (keys.length === 1) return keys[0]; |
| 273 | die('multiple sites: ' + keys.join(', ') + ' (use "select" to choose)'); |
| 274 | } |
| 275 | |
| 276 | //////////////////////////////////////////////////////////////////////////////// |
| 277 | // relay core |
| 278 | //////////////////////////////////////////////////////////////////////////////// |
| 279 | async function runRelay(site, verbose, showRaw = false) { |
| 280 | if (!site.token) { |
| 281 | die('No token configured for this site. Use "add <site-url> <token>" to update.'); |
| 282 | } |
| 283 | const fetchFn = global.fetch || (await import('node-fetch')).default; |
| 284 | |
| 285 | /* ---- tiny disk logs ---- */ |
| 286 | fs.writeFileSync(LOG_HDR, ''); fs.writeFileSync(LOG_BODY, ''); |
| 287 | const hdr = fs.createWriteStream(LOG_HDR, { flags: 'a' }); |
| 288 | const bod = fs.createWriteStream(LOG_BODY, { flags: 'a' }); |
| 289 | const logH = (dir, id, msg='') => hdr.write(`${new Date().toISOString()} ${dir} id=${id ?? '-'} ${msg}\n`); |
| 290 | const logB = (dir, id, msg, obj) => { logH(dir, id, msg); bod.write(JSON.stringify(obj, null, 2) + '\n\n'); }; |
| 291 | |
| 292 | /* ---- runtime state ---- */ |
| 293 | let messagesURL = null; // set after “endpoint” event |
| 294 | const backlog = []; // queued before endpoint known |
| 295 | const pending = new Set(); // ids waiting reply |
| 296 | const id2method = new Map(); // for nicer logs |
| 297 | let authFail = 0; // 0 = OK, 401 / 403 when auth failed |
| 298 | let closing = false; |
| 299 | let sseAbort = null; |
| 300 | |
| 301 | /* ---- stdin from Claude ---- */ |
| 302 | const rl = readline.createInterface({ input: process.stdin }); |
| 303 | rl.on('line', onStdin).on('close', gracefulExit); |
| 304 | process.stdin.on('end', gracefulExit); |
| 305 | |
| 306 | function onStdin(line) { |
| 307 | let msg; try { msg = JSON.parse(line); } catch { return; } |
| 308 | for (const rpc of (Array.isArray(msg) ? msg : [msg])) |
| 309 | handleRpc(rpc, line); |
| 310 | } |
| 311 | |
| 312 | function handleRpc(rpc, rawLine) { |
| 313 | const { id, method, params } = rpc; |
| 314 | |
| 315 | /* Claude handshake */ |
| 316 | if (method === 'initialize') { |
| 317 | const res = { protocolVersion: params?.protocolVersion || '2024-11-05', |
| 318 | capabilities: {}, serverInfo: { name: 'AI Relay', version: '1.5' } }; |
| 319 | console.log(JSON.stringify({ jsonrpc: '2.0', id, result: res })); |
| 320 | logB('server', id, method, res); |
| 321 | return; |
| 322 | } |
| 323 | |
| 324 | /* auth already failed → instant error */ |
| 325 | if (authFail && id !== undefined) return authError(id, authFail); |
| 326 | |
| 327 | id2method.set(id, method); |
| 328 | messagesURL ? forward(rawLine, id) // endpoint known → send now |
| 329 | : backlog.push({ rawLine, id }); |
| 330 | } |
| 331 | |
| 332 | /* ---- helpers to emit JSON-RPC errors ---- */ |
| 333 | function sendError(id, code, message) { |
| 334 | if (id === null || id === undefined) return; // never reply to notifications |
| 335 | const err = { code, message }; |
| 336 | console.log(JSON.stringify({ jsonrpc: '2.0', id, error: err })); |
| 337 | logB('server', id, '', err); |
| 338 | } |
| 339 | const authError = (id, s) => sendError(id, s === 401 ? -32001 : -32003, |
| 340 | s === 401 ? 'Authentication required (401)' |
| 341 | : 'Invalid or insufficient token (403)'); |
| 342 | const transportError = (id, m) => sendError(id, -32000, m); |
| 343 | |
| 344 | /* ---- POST /messages ---- */ |
| 345 | async function forward(rawLine, id) { |
| 346 | const headers = { 'content-type': 'application/json' }; |
| 347 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 348 | |
| 349 | logB('client', id, id2method.get(id), {}); |
| 350 | try { |
| 351 | pending.add(id); |
| 352 | const res = await fetchFn(messagesURL, { method: 'POST', headers, body: rawLine }); |
| 353 | |
| 354 | if (res.status === 401 || res.status === 403) return authError(id, res.status); |
| 355 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 356 | } catch (e) { |
| 357 | logError('post', e, { url: messagesURL }); |
| 358 | transportError(id, '/messages unreachable'); |
| 359 | } finally { |
| 360 | pending.delete(id); |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | /* ---- connect to SSE ---- */ |
| 365 | const endpoint = sseURL(site.url); |
| 366 | if (verbose) { |
| 367 | showWelcome(); |
| 368 | console.error(c('white', '▶ Connecting to MCP server')); |
| 369 | console.error(c('blue', endpoint)); |
| 370 | console.error(''); |
| 371 | } else { |
| 372 | process.stderr.write('AI Engine relay started\n'); |
| 373 | } |
| 374 | |
| 375 | while (!closing) { |
| 376 | messagesURL = null; |
| 377 | try { |
| 378 | sseAbort = new AbortController(); |
| 379 | const headers = { |
| 380 | accept: 'text/event-stream', |
| 381 | 'cache-control': 'no-cache', |
| 382 | connection: 'keep-alive', |
| 383 | 'user-agent': 'Mozilla/5.0' |
| 384 | }; |
| 385 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 386 | |
| 387 | const res = await fetchFn(endpoint, { headers, signal: sseAbort.signal }); |
| 388 | |
| 389 | /* --- auth failure --- */ |
| 390 | if (res.status === 401 || res.status === 403) { |
| 391 | authFail = res.status; |
| 392 | if (verbose) console.error('✗ Unauthorized', res.status); |
| 393 | logError('sse-auth', 'unauthorized', { status: res.status }); |
| 394 | backlog.forEach(b => authError(b.id, authFail)); |
| 395 | backlog.length = 0; |
| 396 | pending.forEach(id => authError(id, authFail)); |
| 397 | pending.clear(); |
| 398 | await delay(1000); |
| 399 | continue; // stay alive → later RPCs short-circuit |
| 400 | } |
| 401 | |
| 402 | /* --- wrong content-type --- */ |
| 403 | const ctype = res.headers.get('content-type') || ''; |
| 404 | if (!ctype.startsWith('text/event-stream')) { |
| 405 | if (verbose) console.error('✗ unexpected content-type', ctype || 'none'); |
| 406 | logError('sse-ctype', ctype, {}); |
| 407 | backlog.forEach(b => transportError(b.id, 'SSE route inactive')); |
| 408 | backlog.length = 0; |
| 409 | pending.forEach(id => transportError(id, 'SSE route inactive')); |
| 410 | pending.clear(); |
| 411 | return; |
| 412 | } |
| 413 | |
| 414 | verbose && console.error(c('white', '✓ SSE connection established')); |
| 415 | |
| 416 | const dec = new TextDecoder(); |
| 417 | let buf = ''; |
| 418 | for await (const chunk of res.body) { |
| 419 | buf += dec.decode(chunk, { stream: true }); |
| 420 | let i; while ((i = buf.indexOf('\n\n')) !== -1) { |
| 421 | handleSseFrame(buf.slice(0, i)); |
| 422 | buf = buf.slice(i + 2); |
| 423 | } |
| 424 | } |
| 425 | } catch (e) { |
| 426 | if (!closing) { |
| 427 | verbose && console.error('SSE', e.message); |
| 428 | logError('sse', e, { endpoint }); |
| 429 | backlog.forEach(b => transportError(b.id, 'SSE unreachable')); |
| 430 | backlog.length = 0; |
| 431 | pending.forEach(id => transportError(id, 'Server disconnected')); |
| 432 | pending.clear(); |
| 433 | } |
| 434 | } |
| 435 | if (!closing) await delay(2000); // retry |
| 436 | } |
| 437 | |
| 438 | /* ---- SSE frame handler ---- */ |
| 439 | function handleSseFrame(frame) { |
| 440 | const evt = frame.match(/^event:(.*)/m)?.[1].trim() || 'message'; |
| 441 | const data = frame.match(/(?:^data:|\ndata:)([\s\S]*)/m)?. [1]?.replace(/\ndata:/g, '').trim() || ''; |
| 442 | |
| 443 | if (evt === 'endpoint') { |
| 444 | messagesURL = data; |
| 445 | if (verbose) { |
| 446 | console.error(c('white', '✓ MCP server connected')); |
| 447 | console.error(c('green', data)); |
| 448 | console.error(''); |
| 449 | |
| 450 | // Extract session_id from URL |
| 451 | const sessionMatch = data.match(/session_id=([^&]+)/); |
| 452 | if (sessionMatch) { |
| 453 | const sessionId = sessionMatch[1]; |
| 454 | const domain = toDomain(site.url); |
| 455 | const toolsCmd = `${SELF} post ${domain} '{"jsonrpc":"2.0","method":"tools/list","id":1}' ${sessionId}`; |
| 456 | const pingCmd = `${SELF} post ${domain} '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"mcp_ping","arguments":{}},"id":2}' ${sessionId}`; |
| 457 | |
| 458 | console.error(c('white', 'Test the connection in another terminal:')); |
| 459 | console.error(c('white', 'Simple ping test:')); |
| 460 | console.error(c('lightblue', pingCmd)); |
| 461 | console.error(''); |
| 462 | console.error(c('white', 'List all available tools:')); |
| 463 | console.error(c('lightblue', toolsCmd)); |
| 464 | console.error(''); |
| 465 | console.error(c('white', 'For raw JSON responses, add'), c('lightblue', '--raw'), c('white', 'to any command')); |
| 466 | console.error(c('blue', 'Results will appear in this terminal.')); |
| 467 | console.error(''); |
| 468 | } |
| 469 | } |
| 470 | backlog.splice(0).forEach(b => forward(b.rawLine, b.id)); |
| 471 | return; |
| 472 | } |
| 473 | |
| 474 | if (evt === 'message' && !data) return; // heartbeat |
| 475 | |
| 476 | // Show received data message in verbose mode |
| 477 | if (verbose) { |
| 478 | try { |
| 479 | const obj = JSON.parse(data); |
| 480 | if ('id' in obj) { |
| 481 | console.error(c('white', `▼ Response for ID ${obj.id}:`)); |
| 482 | |
| 483 | // Show raw JSON if requested |
| 484 | if (showRaw) { |
| 485 | console.error(c('green', JSON.stringify(obj, null, 2))); |
| 486 | } |
| 487 | // Format MCP tool results nicely |
| 488 | else if (obj.result) { |
| 489 | console.error(c('green', '✓ Success')); |
| 490 | |
| 491 | // Special formatting for mcp_ping |
| 492 | if (obj.result.data && obj.result.data.time && obj.result.data.name) { |
| 493 | console.error(c('white', `Time: ${obj.result.data.time}`)); |
| 494 | console.error(c('white', `Site: ${obj.result.data.name}`)); |
| 495 | if (obj.result.data.tools_count !== undefined) { |
| 496 | console.error(c('white', `Tools: ${obj.result.data.tools_count} available`)); |
| 497 | } |
| 498 | } |
| 499 | // Special formatting for tools/list |
| 500 | else if (obj.result.tools && Array.isArray(obj.result.tools)) { |
| 501 | console.error(c('white', `Found ${obj.result.tools.length} tools:\n`)); |
| 502 | |
| 503 | obj.result.tools.forEach((tool, index) => { |
| 504 | // Tool name and description |
| 505 | console.error(c('bright', `${index + 1}. ${tool.name || 'unnamed'}`)); |
| 506 | if (tool.description) { |
| 507 | console.error(c('white', ` ${tool.description}`)); |
| 508 | } |
| 509 | |
| 510 | // Input parameters |
| 511 | if (tool.inputSchema && tool.inputSchema.properties) { |
| 512 | const props = tool.inputSchema.properties; |
| 513 | const required = tool.inputSchema.required || []; |
| 514 | const propKeys = Object.keys(props); |
| 515 | |
| 516 | if (propKeys.length > 0) { |
| 517 | console.error(c('blue', ' Parameters:')); |
| 518 | propKeys.forEach(key => { |
| 519 | const prop = props[key]; |
| 520 | const isRequired = required.includes(key); |
| 521 | const reqIcon = isRequired ? '*' : '-'; |
| 522 | const typeInfo = prop.type ? ` (${prop.type})` : ''; |
| 523 | const desc = prop.description ? ` - ${prop.description}` : ''; |
| 524 | console.error(c('white', ` ${reqIcon} ${key}${typeInfo}${desc}`)); |
| 525 | }); |
| 526 | } else { |
| 527 | console.error(c('blue', ' No parameters required')); |
| 528 | } |
| 529 | } else { |
| 530 | console.error(c('blue', ' No parameters required')); |
| 531 | } |
| 532 | console.error(''); // Empty line between tools |
| 533 | }); |
| 534 | } |
| 535 | // Fallback for other structured data |
| 536 | else if (obj.result.data) { |
| 537 | console.error(c('white', 'Data:')); |
| 538 | console.error(c('green', JSON.stringify(obj.result.data, null, 2))); |
| 539 | } |
| 540 | // Generic result display |
| 541 | else { |
| 542 | console.error(c('green', JSON.stringify(obj.result, null, 2))); |
| 543 | } |
| 544 | } |
| 545 | // Handle errors |
| 546 | else if (obj.error) { |
| 547 | console.error(c('white', '✗ Error:')); |
| 548 | console.error(c('green', `${obj.error.code}: ${obj.error.message}`)); |
| 549 | } |
| 550 | // Fallback to raw JSON for other responses |
| 551 | else { |
| 552 | console.error(c('green', JSON.stringify(obj, null, 2))); |
| 553 | } |
| 554 | |
| 555 | console.error(''); |
| 556 | // Don't forward to console.log in verbose mode for responses with IDs |
| 557 | return; |
| 558 | } |
| 559 | } catch (e) { |
| 560 | console.error(c('white', 'Received Data:')); |
| 561 | console.error(c('green', data)); |
| 562 | console.error(''); |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | console.log(data); // forward as-is |
| 567 | |
| 568 | try { |
| 569 | const obj = JSON.parse(data); |
| 570 | if ('id' in obj) pending.delete(obj.id); |
| 571 | logB('server', obj.id, '', obj.result ? { result: obj.result } |
| 572 | : { error: obj.error }); |
| 573 | } catch (e) { |
| 574 | logError('sse-json', e, { raw: data }); |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | /* ---- graceful exit ---- */ |
| 579 | async function gracefulExit() { |
| 580 | if (closing) return; closing = true; |
| 581 | |
| 582 | if (messagesURL) { |
| 583 | try { |
| 584 | const headers = { 'content-type': 'application/json' }; |
| 585 | if (site.token) headers.authorization = `Bearer ${site.token}`; |
| 586 | await fetchFn(messagesURL, { |
| 587 | method: 'POST', |
| 588 | headers, |
| 589 | body: JSON.stringify({ jsonrpc: '2.0', method: 'mwai/kill' }) |
| 590 | }); |
| 591 | } catch {/* ignore */} |
| 592 | } |
| 593 | sseAbort?.abort(); |
| 594 | process.exit(0); |
| 595 | } |
| 596 | } |
| 597 |