openzoo 0.48.4 → 0.48.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/grokui.mjs +125 -0
  2. package/package.json +1 -1
package/lib/grokui.mjs CHANGED
@@ -112,6 +112,16 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
112
112
  text — web search only gives you short
113
113
  snippets; use FETCH when asked to
114
114
  "read" or quote something specific
115
+ MCP: <url> list the tools an MCP server exposes
116
+ MCP: <url> | <tool> | {"arg": "value"} CALL one of them, for real
117
+ An MCP endpoint speaks JSON-RPC over
118
+ POST. FETCH does a GET, so it will
119
+ always come back 405 Method Not
120
+ Allowed — that is NOT a broken URL and
121
+ NOT a reason to curl it or write your
122
+ own client. Use this directive; it
123
+ does the initialize handshake, holds
124
+ the session, and calls the tool.
115
125
  RUN: <shell command> run a REAL shell command in this
116
126
  thread's directory — by default this
117
127
  pauses and waits for the user to
@@ -536,12 +546,127 @@ async function tryDirective(reply, originId) {
536
546
  .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
537
547
  .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
538
548
  }
549
+ // A 405 on an MCP endpoint is not a broken URL, and a model cannot tell
550
+ // the difference — it retries the same GET, then gives up and hand-rolls
551
+ // a client. Say what actually happened and point at the directive that
552
+ // works. MEASURED: three wasted turns curling /api/mcp before the user
553
+ // had to intervene with "stop hitting /api/mcp as a curl".
554
+ if (r.status === 405 && /\/mcp\/?$/.test(url)) {
555
+ return `${url} (405): that is an MCP endpoint, not a web page — it speaks `
556
+ + `JSON-RPC over POST and rejects the GET that FETCH does. Use the MCP `
557
+ + `directive instead:\n MCP: ${url}\nto list its tools, then\n `
558
+ + `MCP: ${url} | <tool> | {"arg": "value"}\nto call one.`;
559
+ }
539
560
  return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
540
561
  } catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
541
562
  }
563
+
564
+ const mcpD = /^MCP:\s*(\S+)\s*(?:\|\s*([^|]+?)\s*(?:\|\s*([\s\S]+))?)?$/m.exec(reply);
565
+ if (mcpD) {
566
+ const [, url, tool, argsRaw] = mcpD;
567
+ let args = {};
568
+ if (argsRaw) {
569
+ const fenced = /```[\w-]*\n([\s\S]*?)```/.exec(argsRaw);
570
+ try { args = JSON.parse((fenced ? fenced[1] : argsRaw).trim()); }
571
+ catch (e) { return `MCP: couldn't parse the arguments as JSON — ${e.message}`; }
572
+ }
573
+ return await mcpDirective(url.trim(), tool?.trim(), args);
574
+ }
542
575
  return null;
543
576
  }
544
577
 
578
+ // ---------------------------------------------------------------------------
579
+ // MCP client — streamable-http, written against fetch on purpose.
580
+ //
581
+ // grokui runs STANDALONE from /opt/grokui/grokui.mjs, which has no
582
+ // node_modules beside it, so importing @modelcontextprotocol/sdk would break
583
+ // the copy that boxes actually execute. The wire protocol is small: POST
584
+ // JSON-RPC, accept both JSON and SSE, carry the session id the server hands
585
+ // back on initialize.
586
+ //
587
+ // This exists because bots were told to "install an MCP" and had no way to
588
+ // speak to one. FETCH does a GET; every MCP endpoint answers GET with 405, so
589
+ // the model saw a dead URL, retried, then wrote its own Python client.
590
+ let mcpId = 0;
591
+
592
+ async function mcpRpc(url, method, params, session, notify = false) {
593
+ const id = notify ? undefined : ++mcpId;
594
+ const r = await fetch(url, {
595
+ method: 'POST',
596
+ headers: {
597
+ 'content-type': 'application/json',
598
+ // BOTH are required. Servers negotiate between a plain JSON reply and an
599
+ // SSE stream, and offering only one gets a 406 from spec-strict servers.
600
+ accept: 'application/json, text/event-stream',
601
+ 'mcp-protocol-version': '2025-06-18',
602
+ ...(session ? { 'mcp-session-id': session } : {}),
603
+ },
604
+ body: JSON.stringify(notify ? { jsonrpc: '2.0', method, params } : { jsonrpc: '2.0', id, method, params }),
605
+ });
606
+ const sid = r.headers.get('mcp-session-id') || session;
607
+ const body = await r.text();
608
+ if (notify) return { status: r.status, session: sid, json: null };
609
+
610
+ let json = null;
611
+ if ((r.headers.get('content-type') || '').includes('text/event-stream')) {
612
+ // SSE frames: take the last `data:` payload that carries a result/error.
613
+ for (const line of body.split(/\r?\n/)) {
614
+ if (!line.startsWith('data:')) continue;
615
+ try {
616
+ const j = JSON.parse(line.slice(5).trim());
617
+ if (j && (j.result !== undefined || j.error !== undefined)) json = j;
618
+ } catch { /* keep-alive or partial frame */ }
619
+ }
620
+ } else {
621
+ try { json = JSON.parse(body); } catch { /* non-JSON error page */ }
622
+ }
623
+ return { status: r.status, session: sid, json, raw: body };
624
+ }
625
+
626
+ async function mcpDirective(url, tool, args) {
627
+ try {
628
+ const init = await mcpRpc(url, 'initialize', {
629
+ protocolVersion: '2025-06-18',
630
+ capabilities: {},
631
+ clientInfo: { name: 'openzoo-grokui', version: '1' },
632
+ });
633
+ if (init.json?.error) return `MCP ${url}: initialize failed — ${JSON.stringify(init.json.error)}`;
634
+ if (!init.json) return `MCP ${url}: no JSON-RPC reply (HTTP ${init.status})\n${(init.raw || '').slice(0, 600)}`;
635
+ const session = init.session;
636
+ // Required by spec before any other request; skipping it makes some
637
+ // servers reject everything after initialize.
638
+ await mcpRpc(url, 'notifications/initialized', {}, session, true).catch(() => {});
639
+
640
+ const server = init.json.result?.serverInfo;
641
+ const banner = `MCP ${url}${server ? ` — ${server.name} ${server.version || ''}`.trimEnd() : ''}`;
642
+
643
+ if (!tool) {
644
+ const list = await mcpRpc(url, 'tools/list', {}, session);
645
+ if (list.json?.error) return `${banner}\ntools/list failed — ${JSON.stringify(list.json.error)}`;
646
+ const tools = list.json?.result?.tools || [];
647
+ if (!tools.length) return `${banner}\n(no tools)`;
648
+ const lines = tools.map((t) => {
649
+ const req = t.inputSchema?.required || [];
650
+ const props = Object.keys(t.inputSchema?.properties || {});
651
+ const sig = props.map((p) => (req.includes(p) ? p : `${p}?`)).join(', ');
652
+ return ` ${t.name}(${sig})\n ${(t.description || '').split('\n')[0].slice(0, 160)}`;
653
+ });
654
+ return `${banner}\n${tools.length} tools:\n${lines.join('\n')}\n\n`
655
+ + `Call one with: MCP: ${url} | <tool> | {"arg": "value"}`;
656
+ }
657
+
658
+ const call = await mcpRpc(url, 'tools/call', { name: tool, arguments: args }, session);
659
+ if (call.json?.error) return `${banner}\n${tool} failed — ${JSON.stringify(call.json.error)}`;
660
+ const res = call.json?.result;
661
+ const out = (res?.content || [])
662
+ .map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
663
+ .join('\n') || JSON.stringify(res ?? call.raw);
664
+ return `${banner}\n$ ${tool}\n${out.slice(0, 6000)}${out.length > 6000 ? '\n…(truncated)' : ''}`;
665
+ } catch (e) {
666
+ return `MCP ${url}: ${e.message}`;
667
+ }
668
+ }
669
+
545
670
  // onEvent (optional) gets live progress for whoever's actually watching this
546
671
  // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
547
672
  // name,color,delta} per streamed token, {type:'final',name,color,text} once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.4",
3
+ "version": "0.48.5",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",