openzoo 0.11.2 → 0.12.1

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 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 async function startMcp() {
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
+ }
@@ -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
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import http from 'node:http';
2
3
  import crypto from 'node:crypto';
3
4
  import { Readable } from 'node:stream';
@@ -271,6 +272,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
271
272
  let tunnelGate = null;
272
273
 
273
274
  const server = http.createServer(async (req, res) => {
275
+ // MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
276
+ // both surfaces: point base_url at /v1 for transparent context spilling,
277
+ // or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
278
+ // get both was friction nobody should pay.
279
+ if ((req.url || '').split('?')[0] === '/mcp') {
280
+ try {
281
+ const { handleMcpRequest } = await import('./mcphttp.js');
282
+ await handleMcpRequest(req, res);
283
+ } catch (err) {
284
+ jsonErr(res, 500, `openzoo mcp error: ${err.message}`);
285
+ }
286
+ return;
287
+ }
288
+
274
289
  const normalized = normalizePath(req.url);
275
290
  if (normalized !== req.url) {
276
291
  log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
@@ -511,7 +526,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
511
526
  });
512
527
 
513
528
  if (!silent) {
514
- console.log(`openzoo proxy -> ${config.apiBase}`);
529
+ // VERSION IN THE BANNER, deliberately. `npx openzoo` can serve a STALE
530
+ // cached copy — npx reuses a cache entry that matches the bare spec, so a
531
+ // user running the newest published version still gets old behaviour and
532
+ // no clue why (observed: a missing tunnel and a missing token row, both
533
+ // "fixed" releases ago). Printing the version makes that one glance.
534
+ const { version } = JSON.parse(
535
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
536
+ );
537
+ console.log(`openzoo v${version} -> ${config.apiBase}`);
515
538
  console.log(`listening on http://localhost:${config.port}/v1`);
516
539
  console.log('');
517
540
  if (client.walletCreated) console.log(`new burner wallet created at ${client.walletPath} (chmod 600)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
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",