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.
- package/lib/grokui.mjs +125 -0
- 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(/ /g, ' ').replace(/&/g, '&')
|
|
537
547
|
.replace(/</g, '<').replace(/>/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.
|
|
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",
|