PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.4
3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp.js
ai-engine / labs Last commit date
mcp-core.php 5 months ago mcp-rest.php 1 year ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 5 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