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 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
@@ -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.11.2",
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",