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