openzoo 0.11.2 → 0.12.0
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/bindpath.js +2 -1
- package/lib/hrr.js +2 -0
- package/lib/mcp.js +7 -1
- package/lib/mcphttp.js +51 -0
- package/lib/namespace.js +37 -0
- package/lib/pay.js +4 -0
- package/lib/proxy.js +14 -0
- package/package.json +1 -1
package/lib/bindpath.js
CHANGED
|
@@ -23,6 +23,7 @@ import fs from 'node:fs';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { config } from './config.js';
|
|
25
25
|
import { corpusHash, rememberContext, lookupContext } from './contexts.js';
|
|
26
|
+
import { withNamespace } from './namespace.js';
|
|
26
27
|
|
|
27
28
|
/** Bodies over this go in their own part. Under the ~8MB transport ceiling
|
|
28
29
|
* with room for JSON escaping, which can inflate text substantially. */
|
|
@@ -115,7 +116,7 @@ export function splitIntoParts(text, maxBytes = MAX_PART_BYTES) {
|
|
|
115
116
|
async function postBind(payload) {
|
|
116
117
|
const r = await fetch(`${config.apiBase}/v1/hrr/bind`, {
|
|
117
118
|
method: 'POST',
|
|
118
|
-
headers: { 'content-type': 'application/json' },
|
|
119
|
+
headers: withNamespace({ 'content-type': 'application/json' }),
|
|
119
120
|
body: JSON.stringify(payload),
|
|
120
121
|
});
|
|
121
122
|
if (r.status !== 200) throw new Error(`bind failed: HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
package/lib/hrr.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { config } from './config.js';
|
|
15
15
|
import { corpusHash, lookupContext, rememberContext, forgetContext } from './contexts.js';
|
|
16
16
|
import { postWithUploadSignal } from './spinner.js';
|
|
17
|
+
import { withNamespace } from './namespace.js';
|
|
17
18
|
|
|
18
19
|
/** Corpora under this many chars ride inline — binding has a round-trip cost
|
|
19
20
|
* and the reuse win only matters when the body is actually big. */
|
|
@@ -37,6 +38,7 @@ export async function bindCorpus(corpus, { onStage, force = false } = {}) {
|
|
|
37
38
|
}
|
|
38
39
|
onStage?.('binding', { bytes });
|
|
39
40
|
const r = await postWithUploadSignal(`${config.apiBase}/v1/hrr/bind`, JSON.stringify({ corpus }), {
|
|
41
|
+
headers: withNamespace(),
|
|
40
42
|
onUploaded: () => onStage?.('bound-uploading-done', { bytes }),
|
|
41
43
|
});
|
|
42
44
|
if (r.status !== 200) {
|
package/lib/mcp.js
CHANGED
|
@@ -16,7 +16,7 @@ function text(obj) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** `npx openzoo mcp` — stdio MCP server sharing the proxy's wallet + payment core. */
|
|
19
|
-
export
|
|
19
|
+
export function buildMcpServer() {
|
|
20
20
|
const client = new PayClient();
|
|
21
21
|
// MCP's own channel for "what am I connected to" — clients surface this to
|
|
22
22
|
// the model before any tool is called, which is exactly when it needs to
|
|
@@ -186,6 +186,12 @@ export async function startMcp() {
|
|
|
186
186
|
})),
|
|
187
187
|
}));
|
|
188
188
|
|
|
189
|
+
return { server, client };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `npx openzoo mcp` — stdio transport, for clients that spawn a process. */
|
|
193
|
+
export async function startMcp() {
|
|
194
|
+
const { server, client } = buildMcpServer();
|
|
189
195
|
const transport = new StdioServerTransport();
|
|
190
196
|
await server.connect(transport);
|
|
191
197
|
console.error(`openzoo mcp on stdio — wallet ${client.address} — zoo ${config.apiBase}`);
|
package/lib/mcphttp.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over Streamable HTTP, mounted on the proxy's own port at /mcp.
|
|
3
|
+
*
|
|
4
|
+
* WHY: `npx openzoo` should give a harness BOTH surfaces without a second
|
|
5
|
+
* command — point base_url at /v1 for transparent context spilling, or add
|
|
6
|
+
* /mcp for the tools (zoo_bind, zoo_ask, zoo_wallet...). Clients that spawn a
|
|
7
|
+
* process still get stdio via `npx openzoo mcp`.
|
|
8
|
+
*
|
|
9
|
+
* Stateless on purpose: no session ids to track, no server-side state to
|
|
10
|
+
* expire, and any client that reconnects just works. The MCP server object
|
|
11
|
+
* itself is built once and reused across requests.
|
|
12
|
+
*/
|
|
13
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
+
import { buildMcpServer } from './mcp.js';
|
|
15
|
+
|
|
16
|
+
let ready = null;
|
|
17
|
+
|
|
18
|
+
async function ensure() {
|
|
19
|
+
if (!ready) {
|
|
20
|
+
ready = (async () => {
|
|
21
|
+
const { server, client } = buildMcpServer();
|
|
22
|
+
const transport = new StreamableHTTPServerTransport({
|
|
23
|
+
sessionIdGenerator: undefined, // stateless
|
|
24
|
+
enableJsonResponse: true,
|
|
25
|
+
});
|
|
26
|
+
await server.connect(transport);
|
|
27
|
+
return { transport, client };
|
|
28
|
+
})();
|
|
29
|
+
}
|
|
30
|
+
return ready;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read and JSON-parse a request body. MCP posts are small. */
|
|
34
|
+
function readJson(req) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
req.on('data', (c) => chunks.push(c));
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
40
|
+
if (!raw) return resolve(undefined);
|
|
41
|
+
try { resolve(JSON.parse(raw)); } catch { resolve(undefined); }
|
|
42
|
+
});
|
|
43
|
+
req.on('error', () => resolve(undefined));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleMcpRequest(req, res) {
|
|
48
|
+
const { transport } = await ensure();
|
|
49
|
+
const body = req.method === 'POST' ? await readJson(req) : undefined;
|
|
50
|
+
await transport.handleRequest(req, res, body);
|
|
51
|
+
}
|
package/lib/namespace.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { loadOrCreateWallet } from './wallet.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The namespace that isolates THIS wallet's bound corpora from everyone
|
|
6
|
+
* else's on the shared sidecar.
|
|
7
|
+
*
|
|
8
|
+
* Contexts used to live in one global tenant, so a context was protected only
|
|
9
|
+
* by its id being unguessable — and the bind endpoint is free and open, so
|
|
10
|
+
* anyone could write into that same space. The gateway now hashes this header
|
|
11
|
+
* into the sidecar tenant id, which means a leaked context id is useless to a
|
|
12
|
+
* different wallet.
|
|
13
|
+
*
|
|
14
|
+
* Derived from the PUBLIC key, never the secret: it must be stable across
|
|
15
|
+
* restarts and reveal nothing. It is hashed again server-side, so the value on
|
|
16
|
+
* the wire is not the address either.
|
|
17
|
+
*/
|
|
18
|
+
let cached = null;
|
|
19
|
+
|
|
20
|
+
export function namespaceHeaderValue() {
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
try {
|
|
23
|
+
const w = loadOrCreateWallet();
|
|
24
|
+
cached = crypto.createHash('sha256')
|
|
25
|
+
.update(`openzoo-ns:${w.keypair.publicKey.toBase58()}`)
|
|
26
|
+
.digest('hex');
|
|
27
|
+
} catch {
|
|
28
|
+
cached = ''; // no wallet (read-only use): fall back to the shared tenant
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Merge the namespace header into any headers object. */
|
|
34
|
+
export function withNamespace(headers = {}) {
|
|
35
|
+
const ns = namespaceHeaderValue();
|
|
36
|
+
return ns ? { ...headers, 'x-openzoo-namespace': ns } : headers;
|
|
37
|
+
}
|
package/lib/pay.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { buildEvmPayment, evmTokenBalance } from './evm.js';
|
|
10
10
|
import { acquireWrappedIfNeeded } from './evmwrap.js';
|
|
11
11
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
12
|
+
import { withNamespace } from './namespace.js';
|
|
12
13
|
import {
|
|
13
14
|
resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
|
|
14
15
|
} from './wrap.js';
|
|
@@ -175,6 +176,9 @@ export class PayClient {
|
|
|
175
176
|
*/
|
|
176
177
|
async fetch(url, init = {}, { onStage } = {}) {
|
|
177
178
|
onStage?.('request');
|
|
179
|
+
// Contexts are tenanted by this namespace server-side — a request without
|
|
180
|
+
// it cannot see corpora this wallet bound.
|
|
181
|
+
init = { ...init, headers: withNamespace(init.headers || {}) };
|
|
178
182
|
const first = await fetch(url, init);
|
|
179
183
|
if (first.status !== 402) return { response: first, paid: false };
|
|
180
184
|
|
package/lib/proxy.js
CHANGED
|
@@ -271,6 +271,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
271
271
|
let tunnelGate = null;
|
|
272
272
|
|
|
273
273
|
const server = http.createServer(async (req, res) => {
|
|
274
|
+
// MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
|
|
275
|
+
// both surfaces: point base_url at /v1 for transparent context spilling,
|
|
276
|
+
// or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
|
|
277
|
+
// get both was friction nobody should pay.
|
|
278
|
+
if ((req.url || '').split('?')[0] === '/mcp') {
|
|
279
|
+
try {
|
|
280
|
+
const { handleMcpRequest } = await import('./mcphttp.js');
|
|
281
|
+
await handleMcpRequest(req, res);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
jsonErr(res, 500, `openzoo mcp error: ${err.message}`);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
274
288
|
const normalized = normalizePath(req.url);
|
|
275
289
|
if (normalized !== req.url) {
|
|
276
290
|
log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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",
|