@fangorn-network/westmarch 2026.11.3 → 2026.11.4-dev

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/src/agent/mcp.js CHANGED
@@ -1,199 +1,55 @@
1
1
  #!/usr/bin/env node
2
- // Every Fangorn app's tools, behind one stdio MCP server.
2
+ // Fangorn for agents: every publisher's records, found through the explorer.
3
3
  //
4
- // fangorn-mcp [--from-block <n>]
4
+ // fangorn-mcp
5
5
  // claude mcp add fangorn -- fangorn-mcp
6
6
  //
7
- // One MCP server for every app, so an agent registers once. It is a directory
8
- // and a proxy:
7
+ // The explorer (FANGORN_EXPLORER, default https://explorer.fangorn.network) is the Fangorn
8
+ // app that indexes every publisher. This server downloads the explorer's view and searches
9
+ // it here, so no query ever leaves the machine; it never reads the chain to find anything:
9
10
  //
10
- // list-apps apps read off the chain (AppAgentChanged → verified cards)
11
- // open-app verify one app's card and add its tools to this server as
12
- // `<app>__<tool>` (tools/list_changed)
13
- // call-app-tool the same call by name, for clients that ignore list_changed
11
+ // search records across every publisher, or one (`publisher`)
12
+ // publishers who publishes what: each publisher's repos, with row counts
13
+ // get one record in full
14
+ // buy a record a publisher sells, quoted at the explorer, paid over x402
15
+ // wallet the person's address and balances
16
+ // publish records into the person's own repo, on their yes
14
17
  //
15
- // An app's tools are its own MCP server's (the card's `mcp`, the app's Worker at
16
- // /mcp), relayed. The Worker alone reads the app's lens from R2, so nothing is
17
- // downloaded here and access is the app's to gate.
18
- // A private app's paid tools answer with x402's PaymentRequired. With FANGORN_MCP_WALLET_KEY set (or
19
- // FANGORN_MCP_BUDGET, to pay from the fangorn CLI's key in ~/.fangorn/config.json), this server pays: a pass when the card sells
20
- // one at or under FANGORN_MCP_MAX_PASS (USDC; unset, never), else that one call at or under
21
- // FANGORN_MCP_MAX_PRICE (default 0.10), and never past FANGORN_MCP_BUDGET (USDC a month, default 5).
22
- // Every payment is a receipt in ~/.fangorn/spend.json, shown by `wallet`. Without a wallet the price
23
- // is handed back to the agent, to say to the person.
24
- //
25
- // With either key it also publishes: `publish` commits records to the person's own repo, on their yes.
18
+ // Buying needs FANGORN_MCP_WALLET_KEY and pays at most FANGORN_MCP_MAX_PRICE a record
19
+ // (default 0.10). Publishing signs with that key, else the fangorn CLI's.
26
20
 
27
21
  import net from "node:net";
28
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { readFileSync } from "node:fs";
29
23
  import { homedir } from "node:os";
30
24
  import { createInterface } from "node:readline";
31
- import { APP_EXTENSION, listApps, pluginOf, skillOf, toApp } from "../discover/apps.js";
32
25
 
33
26
  const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
34
- // FANGORN_MCP_TRACE=1: step timings on stderr, for finding where an answer waits.
35
- const T0 = performance.now();
36
- const trace = process.env.FANGORN_MCP_TRACE ? (what) => log(`${Math.round(performance.now() - T0)}ms ${what}`) : () => {};
37
27
 
38
28
  // FANGORN_NETWORK: the SDK's network name ("baseSepolia"); unset, its default.
39
29
  const network = async () => (await import("../publish/view.js")).network();
40
30
 
41
- let client;
42
- async function fangorn() {
43
- if (!client) {
44
- const [{ Fangorn }, { generatePrivateKey }] = await Promise.all([
45
- import("@fangorn-network/sdk"), import("viem/accounts")]);
46
- client = Fangorn.create({ privateKey: generatePrivateKey(), config: await network() }); // reads only
47
- // list-apps verifies every card and open-app verifies one again: once a minute is enough.
48
- const verify = client.discoverCard.bind(client), seen = new Map();
49
- client.discoverCard = (url) => {
50
- const hit = seen.get(url);
51
- if (hit && Date.now() - hit.at < 60_000) return hit.p;
52
- const p = verify(url).catch((e) => { seen.delete(url); throw e; });
53
- seen.set(url, { at: Date.now(), p });
54
- return p;
55
- };
56
- }
57
- return client;
58
- }
59
-
60
- /** A card URL → its app's MCP endpoint (the card's `mcp`), once the card verifies on chain. */
61
- async function resolveCard(cardUrl) {
62
- const card = await (await fetch(cardUrl)).json();
63
- if (!card.capabilities?.extensions?.some((e) => e.uri === APP_EXTENSION)) throw new Error(`${cardUrl} is not a Fangorn app card`);
64
- const { mcp } = toApp(await (await fangorn()).discoverCard(cardUrl), cardUrl);
65
- return { card, mcp };
66
- }
67
-
68
- /** An app's own MCP server over streamable HTTP → `{ tools, call }`.
69
- * The app's Worker is stateless (one JSON body per POST); a session id or an SSE
70
- * reply from any other server is followed too. */
71
- export async function remoteTools(url, fetcher = fetch) {
72
- let id = 0, session;
73
- const rpc = async (method, params) => {
74
- const r = await fetcher(url, { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
75
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...(session && { "mcp-session-id": session }) } });
76
- session ??= r.headers.get("mcp-session-id") ?? undefined;
77
- let body = await r.text();
78
- if (!r.ok) throw new Error(`${url}: HTTP ${r.status} ${body.slice(0, 200)}`);
79
- if (r.headers.get("content-type")?.includes("text/event-stream")) body = body.split("\n").filter((l) => l.startsWith("data:")).pop()?.slice(5) ?? "";
80
- const m = JSON.parse(body);
81
- if (m.error) throw new Error(`${url}: ${m.error.message}`);
82
- return m.result;
83
- };
84
- await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fangorn-mcp", version: "0.1.0" } });
85
- const { tools } = await rpc("tools/list", {});
86
- const call = (name, args) => rpc("tools/call", { name, arguments: args ?? {} });
87
- return { tools, call };
88
- }
89
-
90
- // ── what this wallet spent ──────────────────────────────────────────────────
91
- /** This month's payments: `spent()` in USDC base units, `record({url, tool, amount, tx})`. */
92
- export function memoryLedger(load = () => null, save = () => {}) {
93
- const month = () => new Date().toISOString().slice(0, 7);
94
- let book = load();
95
- const now = () => (book?.month === month() ? book : (book = { month: month(), receipts: [] }));
96
- return {
97
- spent: () => now().receipts.reduce((n, r) => n + BigInt(r.amount), 0n),
98
- receipts: () => now().receipts,
99
- record: (r) => { now().receipts.push({ at: new Date().toISOString(), ...r, amount: String(r.amount) }); save(book); },
100
- };
101
- }
102
-
103
- /** The ledger on disk. ponytail: two servers paying at once can drop a receipt; a lock if that bites. */
104
- export function fileLedger(path) {
105
- return memoryLedger(() => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } },
106
- (b) => { try { writeFileSync(path, JSON.stringify(b, null, 1)); } catch (e) { log(`receipt not saved: ${e.message}`); } });
107
- }
108
-
109
- // ── the network ─────────────────────────────────────────────────────────────
110
- // Catalogs, manifests and cards change on a rebake: held for a minute, so one
111
- // search (or resolving then verifying a card) does not fetch each twice.
112
- const CACHE = `${homedir()}/.cache/fangorn-mcp`; // the embedding model (find-apps) and the chain logs read
113
- function cachingFetch(real = globalThis.fetch) {
114
- const memo = new Map();
115
- return async (input, init) => {
116
- const url = String(input?.url ?? input);
117
- if ((init?.method ?? input?.method ?? "GET") !== "GET" || !/^https?:/.test(url)) return real(input, init);
118
- const path = new URL(url).pathname;
119
- // A card, catalog or manifest is small: 8s without one means the host is
120
- // down, and a dead card in the directory must not stall a listing for a
121
- // TCP timeout.
122
- init = { ...init, signal: init?.signal ?? AbortSignal.timeout(8000) };
123
- if (/\/cdn\/catalog$|\/manifest$|\/agent-card\.json$/.test(path)) {
124
- const hit = memo.get(url);
125
- if (hit && Date.now() - hit.at < 60_000) return new Response(hit.body, { headers: hit.headers });
126
- const r = await real(input, init);
127
- if (!r.ok) return r;
128
- const body = new Uint8Array(await r.arrayBuffer());
129
- memo.set(url, { at: Date.now(), body, headers: r.headers });
130
- return new Response(body, { headers: r.headers });
131
- }
132
- return real(input, init);
133
- };
134
- }
135
-
136
- /**
137
- * The chain logs a scan has read, on disk, so a new session reads only the
138
- * blocks since (apps.js cachedLogs). Logs carry bigints; JSON does not.
139
- * ponytail: two servers writing at once can tear the file. A torn file reads
140
- * as empty and the next scan starts over; write-then-rename if that ever bites.
141
- */
142
- function fileStore(path) {
143
- const big = (_, v) => (typeof v === "bigint" ? { $n: String(v) } : v);
144
- const unbig = (_, v) => (v?.$n !== undefined ? BigInt(v.$n) : v);
145
- let data;
146
- try { data = JSON.parse(readFileSync(path, "utf8"), unbig); } catch { data = {}; }
147
- return {
148
- get: (k) => data[k],
149
- set: (k, v) => {
150
- data[k] = v;
151
- try { mkdirSync(CACHE, { recursive: true }); writeFileSync(path, JSON.stringify(data, big)); }
152
- catch (e) { log(`log cache not saved: ${e.message}`); }
153
- },
154
- };
155
- }
156
-
157
- /** JSON-RPC over newline-delimited stdio. Returns `notify` for server → client messages. */
158
- export function serve(handlers, { input = process.stdin, output = process.stdout } = {}) {
159
- const send = (m) => output.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n");
160
- createInterface({ input }).on("line", async (line) => {
161
- let msg; try { msg = JSON.parse(line); } catch { return; }
162
- if (msg.id === undefined) return; // notifications
163
- const h = handlers[msg.method];
164
- if (!h) return send({ id: msg.id, error: { code: -32601, message: `unknown method ${msg.method}` } });
165
- try { send({ id: msg.id, result: await h(msg.params ?? {}) }); }
166
- catch (e) { send({ id: msg.id, error: { code: -32000, message: e?.message ?? String(e) } }); }
167
- });
168
- return (method, params = {}) => send({ method, params });
169
- }
170
-
31
+ // ── the server ──────────────────────────────────────────────────────────────
171
32
  const text = (o) => ({ content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 1) }] });
172
- // MCP tool names: [a-zA-Z0-9_-]{1,64}. `__` separates app from tool.
173
- const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "app";
33
+ const ADDRESS = { type: "string", description: "A publisher's address (0x…)" };
174
34
 
175
- const META = [
176
- { name: "list-apps",
177
- description: "List the Fangorn apps whose agent cards verify on chain (an app is a publisher's repo with a card): name, description, card URL, and its id (publisher:repo). Filter with `query`. Call open-app on one to use its tools.",
178
- inputSchema: { type: "object", properties: { query: { type: "string", description: "Case-insensitive match on name or description" } } } },
179
- { name: "find-apps",
180
- description: "Find Fangorn apps for a question, ranked by what their data is about (e.g. \"where can I learn about weather?\", \"music\"). Each app carries its site, agent card, ERC-8004 agent with an 8004scan link, ERC-8004 reputation (feedback count and average), record count and bound block. Only apps whose cards verify on chain. `relevant: true` marks the ones worth opening; the rest are sorted, not relevant. With no query, lists every app. Then open-app.",
181
- inputSchema: { type: "object", properties: {
35
+ const TOOLS = [
36
+ { name: "search",
37
+ description: "Search every publisher's records on Fangorn by meaning. Each hit names its `publisher`, and carries `sale` ({publisher, repo, id, price} in USDC base units) when it is sold. Pass `publisher` to search one publisher's records.",
38
+ inputSchema: { type: "object", required: ["query"], properties: {
182
39
  query: { type: "string", description: "What the person is looking for, in their words" },
183
- limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } } } },
184
- { name: "open-app",
185
- description: "Verify an app's card against the chain and add its tools to this server as `<app>__<tool>`. The tools are the app's own MCP server's (typically describe, search, get, similar, count, browse). `app` is a name from list-apps, an id (publisher:repo), or a card URL.",
186
- inputSchema: { type: "object", properties: { app: { type: "string" } }, required: ["app"] } },
187
- { name: "call-app-tool",
188
- description: "Call one tool of an opened app. Same as calling `<app>__<tool>` directly.",
189
- inputSchema: { type: "object", properties: { app: { type: "string" }, tool: { type: "string" }, arguments: { type: "object" } }, required: ["app", "tool"] } },
40
+ publisher: ADDRESS, limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } } } },
41
+ { name: "publishers",
42
+ description: "Who publishes on Fangorn: each publisher's repos, with what they hold and how many records. Filter with `query` (matches a repo's name or description, or the address). The busiest first.",
43
+ inputSchema: { type: "object", properties: { query: { type: "string" } } } },
44
+ { name: "get",
45
+ description: "One record in full, by the id a search hit gave.",
46
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } } },
190
47
  { name: "buy",
191
- description: "Buy one record a publisher sells, from an open app whose search hits carry `sale` ({publisher, repo, id, price}), and return its sealed fields opened. Pays the price in USDC from the person's wallet (FANGORN_MCP_WALLET_KEY), never above FANGORN_MCP_MAX_PRICE a record or the monthly FANGORN_MCP_BUDGET. Tell the person the price first.",
192
- inputSchema: { type: "object", required: ["app", "publisher", "repo", "id"], properties: {
193
- app: { type: "string", description: "The open app the hit came from" },
194
- publisher: { type: "string" }, repo: { type: "string" }, id: { type: "string", description: "From the hit's `sale`" } } } },
48
+ description: "Buy one record a publisher sells (a search hit with `sale`) and return its sealed fields opened. Pays the price in USDC from the person's wallet (FANGORN_MCP_WALLET_KEY), never above FANGORN_MCP_MAX_PRICE. It checks the sealed data can be read before paying. Tell the person the price first.",
49
+ inputSchema: { type: "object", required: ["publisher", "repo", "id"], properties: {
50
+ publisher: ADDRESS, repo: { type: "string" }, id: { type: "string", description: "From the hit's `sale`" } } } },
195
51
  { name: "wallet",
196
- description: "The person's Fangorn wallet: its address, USDC and ETH balances, this month's budget, what was spent and the receipt for each payment. Call it when they ask what they have earned or spent.",
52
+ description: "The person's Fangorn wallet: its address and its USDC and ETH balances.",
197
53
  inputSchema: { type: "object", properties: {} } },
198
54
  { name: "publish",
199
55
  description: "Publish records to the person's own repo on Fangorn, from their wallet, where any agent can find them. Public and permanent: first show the person exactly what will be published and call this only on their yes to these records. Propose it when something you made for them (research, a list, a dataset) would be worth to others.",
@@ -207,85 +63,35 @@ const META = [
207
63
 
208
64
  /**
209
65
  * The server. `deps` is the outside world, so the self-check can fake it:
210
- * findApps() → { apps: [{ name, desc, card, id }], rejected }
211
- * exploreApps(query) → { apps: ranked explore.js entries, ranked_by }
212
- * resolveCard(url) → { card, mcp }
213
- * remoteTools(url) → { tools, call(name, args) } (the app's own MCP server)
214
- * buy(quoteUrl) → { fields, price, tx } (src/agent/x402.js, from the person's wallet)
215
- * notify(method) set after serve()
66
+ * explorer() → { call(name, args) } (view-tools over the explorer's view, run here)
67
+ * quote(p, r, id) → the explorer's x402 URL for one record
68
+ * buy(quoteUrl) → { fields, price, tx } (src/agent/x402.js, from the person's wallet)
69
+ * wallet(), publish({ repo, records })
216
70
  */
217
71
  export function handlers(deps) {
218
- const open = new Map(); // slug → { slug, name, card, id, tools, call }
219
-
220
- const findOpen = (app) => open.get(slug(app)) ?? [...open.values()].find((o) => o.card === app || o.id === app);
221
-
222
- async function openApp(app) {
223
- const hit = findOpen(app);
224
- if (hit) return hit;
225
- let cardUrl = app, id;
226
- if (!/^https?:\/\//.test(app)) {
227
- trace(`open ${app}: lookup`);
228
- const a = (await deps.findApps()).apps.find((x) => x.id === app || slug(x.name) === slug(app));
229
- if (!a) throw new Error(`no verified app named ${JSON.stringify(app)}; see list-apps`);
230
- ({ card: cardUrl, id } = a);
231
- }
232
- trace(`open ${app}: resolve ${cardUrl}`);
233
- const { card, mcp } = await deps.resolveCard(cardUrl);
234
- if (!mcp) throw new Error(`${card.name ?? cardUrl} has no MCP server (its card has no \`mcp\`)`);
235
- let s = slug(card.name ?? app);
236
- while (open.has(s)) s += "-";
237
- const ext = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION);
238
- const o = { slug: s, name: card.name, card: cardUrl, id, skill: skillOf(ext), plugin: pluginOf(ext), paid: ext?.params?.paid?.url ?? null,
239
- ...await deps.remoteTools(mcp) };
240
- trace(`open ${app}: ready`);
241
- open.set(o.slug, o);
242
- deps.notify?.("notifications/tools/list_changed");
243
- return o;
244
- }
245
-
246
- async function call(app, tool, args) {
247
- const o = findOpen(app);
248
- if (!o) throw new Error(`${app} is not open; call open-app first`);
249
- if (!o.tools.some((t) => t.name === tool)) throw new Error(`${o.slug} has no tool ${tool}`);
250
- return o.call(tool, args);
251
- }
252
-
72
+ const ask = async (name, args) => {
73
+ const r = await (await deps.explorer()).call(name, args);
74
+ if (r.isError) throw new Error(`explorer: ${r.content?.[0]?.text ?? "error"}`);
75
+ return r;
76
+ };
253
77
  const run = {
254
- "list-apps": async ({ query }) => {
255
- const { apps, rejected } = await deps.findApps();
78
+ search: ({ query, publisher, limit = 10 }) =>
79
+ ask("search", { query, limit: Math.min(50, Math.max(1, limit)), ...(publisher ? { where: { owner: publisher } } : {}) }),
80
+ publishers: async ({ query }) => {
81
+ const { views = [] } = JSON.parse((await ask("describe", {})).content[0].text);
82
+ const by = new Map();
83
+ for (const r of views.flatMap((v) => v.repos ?? [])) {
84
+ const p = by.get(r.publisher.toLowerCase()) ?? { publisher: r.publisher, rows: 0, repos: [] };
85
+ p.rows += r.rows;
86
+ p.repos.push({ repo: r.repo, rows: r.rows, description: r.description });
87
+ by.set(r.publisher.toLowerCase(), p);
88
+ }
256
89
  const q = String(query ?? "").toLowerCase();
257
- return text({
258
- apps: apps.filter((a) => !q || `${a.name} ${a.desc}`.toLowerCase().includes(q)).map((a) => ({
259
- app: slug(a.name), name: a.name, description: a.desc, card: a.card, id: a.id,
260
- open: open.has(slug(a.name)),
261
- })),
262
- unverified: rejected.length,
263
- });
264
- },
265
- "find-apps": async ({ query, limit = 10 }) => {
266
- const { apps, ranked_by } = await deps.exploreApps(query);
267
- return text({ query: query ?? null, ranked_by, apps: apps.slice(0, Math.min(50, Math.max(1, limit)))
268
- .map((a) => ({ ...a, open: open.has(a.app) })) });
269
- },
270
- "open-app": async ({ app }) => {
271
- const o = await openApp(app);
272
- return text({ app: o.slug, name: o.name, card: o.card,
273
- call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools,
274
- // An app's own skill: standing instructions for the reader's agent, so never loaded
275
- // silently. The fangorn-index skill offers it to the user and pins the hash on a yes.
276
- ...(o.skill ? { skill: { ...o.skill, adopt: "The app ships an agent skill. Offer it to the user (what it would make you do, from its description); adopt it only on their yes, checking sha256, and ask again if the hash changes." } } : {}),
277
- // Its Claude Code plugin: hooks run code on every matching event, so the same rule, louder.
278
- ...(o.plugin ? { plugin: { ...o.plugin, install: `claude plugin install ${o.plugin.name} --marketplace ${o.plugin.marketplace}`,
279
- adopt: "The app ships a Claude Code plugin, whose hooks run code on this machine without being called. Offer it to the user once, saying what its hooks trigger on; install only on their yes. It takes effect next session." } } : {}) });
280
- },
281
- "call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
282
- buy: async ({ app, publisher, repo, id }) => {
283
- const o = findOpen(app);
284
- if (!o) throw new Error(`${app} is not open; call open-app first`);
285
- if (!o.paid) throw new Error(`${o.name} sells nothing (its card has no paid quote)`);
286
- const quote = o.paid.replace("{publisher}", encodeURIComponent(publisher)).replace("{repo}", encodeURIComponent(repo)).replace("{id}", encodeURIComponent(id));
287
- return text({ publisher, repo, id, ...await deps.buy(quote) });
90
+ const hit = (p) => !q || p.publisher.toLowerCase().includes(q) || p.repos.some((r) => `${r.repo} ${r.description}`.toLowerCase().includes(q));
91
+ return text({ publishers: [...by.values()].filter(hit).sort((a, b) => b.rows - a.rows) });
288
92
  },
93
+ get: ({ id }) => ask("get", { id }),
94
+ buy: async ({ publisher, repo, id }) => text({ publisher, repo, id, ...await deps.buy(deps.quote(publisher, repo, id)) }),
289
95
  wallet: async () => text(await deps.wallet()),
290
96
  publish: async ({ repo, records }) => {
291
97
  if (!records?.length) throw new Error("no records to publish");
@@ -296,26 +102,35 @@ export function handlers(deps) {
296
102
  return {
297
103
  initialize: async (p) => ({
298
104
  protocolVersion: p.protocolVersion ?? "2025-06-18",
299
- capabilities: { tools: { listChanged: true } },
300
- serverInfo: { name: "fangorn-mcp", version: "0.1.0" },
301
- instructions: "Fangorn apps are anchored on chain and each serves its own MCP. list-apps to find one, open-app to load it, then call its tools as <app>__<tool>. Start with <app>__describe when it exists.",
105
+ capabilities: { tools: { listChanged: false } },
106
+ serverInfo: { name: "fangorn-mcp", version: "0.2.0" },
107
+ instructions: "Fangorn is publishers' records, anchored on chain and indexed by the Fangorn explorer. search finds records across every publisher; publishers says who publishes what; get reads one; buy pays for a sold one (say the price first).",
302
108
  }),
303
109
  ping: async () => ({}),
304
- "tools/list": async () => ({ tools: [
305
- ...META,
306
- ...[...open.values()].flatMap((o) => o.tools.map((t) => ({ ...t, name: `${o.slug}__${t.name}`, description: `[${o.name}] ${t.description ?? ""}` }))),
307
- ] }),
110
+ "tools/list": async () => ({ tools: TOOLS }),
308
111
  "tools/call": async ({ name, arguments: args = {} }) => {
309
112
  try {
310
- if (run[name]) return await run[name](args);
311
- const i = name.indexOf("__");
312
- if (i > 0) return await call(name.slice(0, i), name.slice(i + 2), args);
313
- throw new Error(`no tool ${name}`);
113
+ if (!run[name]) throw new Error(`no tool ${name}`);
114
+ return await run[name](args);
314
115
  } catch (e) { return { isError: true, content: [{ type: "text", text: e?.message ?? String(e) }] }; }
315
116
  },
316
117
  };
317
118
  }
318
119
 
120
+ /** JSON-RPC over newline-delimited stdio. Returns `notify` for server → client messages. */
121
+ export function serve(handlers, { input = process.stdin, output = process.stdout } = {}) {
122
+ const send = (m) => output.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n");
123
+ createInterface({ input }).on("line", async (line) => {
124
+ let msg; try { msg = JSON.parse(line); } catch { return; }
125
+ if (msg.id === undefined) return; // notifications
126
+ const h = handlers[msg.method];
127
+ if (!h) return send({ id: msg.id, error: { code: -32601, message: `unknown method ${msg.method}` } });
128
+ try { send({ id: msg.id, result: await h(msg.params ?? {}) }); }
129
+ catch (e) { send({ id: msg.id, error: { code: -32000, message: e?.message ?? String(e) } }); }
130
+ });
131
+ return (method, params = {}) => send({ method, params });
132
+ }
133
+
319
134
  // ── main / self-check ───────────────────────────────────────────────────────
320
135
  // A command, not a module: installed as a bin, argv[1] is the symlink, so there is no is-main check.
321
136
  if (process.argv[2] === "--selfcheck") {
@@ -323,144 +138,89 @@ if (process.argv[2] === "--selfcheck") {
323
138
  const input = new PassThrough(), output = new PassThrough();
324
139
  const replies = [];
325
140
  createInterface({ input: output }).on("line", (l) => replies.push(JSON.parse(l)));
326
- const posts = [];
327
- // The app's Worker: initialize, tools/list, tools/call over JSON-RPC POSTs.
328
- const remote = await remoteTools("https://k.test/mcp", async (url, init) => {
329
- const m = JSON.parse(init.body);
330
- posts.push(m.method);
331
- const result = m.method === "tools/list" ? { tools: [{ name: "search" }] }
332
- : m.method === "tools/call" ? text({ via: "remote", ...m.params }) : {};
333
- return new Response(JSON.stringify({ jsonrpc: "2.0", id: m.id, result }), { headers: { "content-type": "application/json" } });
334
- });
335
- let opened = 0;
336
- const deps = {
337
- findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", id: "0xk:kings-foil" },
338
- { name: "Bare", desc: "", card: "https://b.test/card", id: "0xb:bare" }], rejected: [{}] }),
339
- exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
340
- resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil", capabilities: { extensions: [{ uri: APP_EXTENSION,
341
- params: { skill: { url: "https://k.test/skill/kings-foil/SKILL.md", sha256: "b".repeat(64) }, plugin: { marketplace: "k/kf", name: "kings-foil" },
342
- paid: { url: "https://k.test/paid/{publisher}/{repo}/{id}" } } }] } }, mcp: "https://k.test/mcp" }),
343
- remoteTools: async () => (opened++, remote),
344
- wallet: async () => ({ address: "0xme", spent: "0" }),
141
+ const P = "0xAbC0000000000000000000000000000000000001", Q = "0x0000000000000000000000000000000000000002";
142
+ // The explorer's view tools, as view-tools.js answers them.
143
+ const views = [{ view: "view", rows: 6, repos: [
144
+ { publisher: P, repo: "weather", rows: 4, description: "hourly readings" },
145
+ { publisher: Q, repo: "notes", rows: 1, description: "" },
146
+ { publisher: P, repo: "tides", rows: 1, description: "" }] }];
147
+ const explorer = { call: async (name, args) => (name === "describe" ? text({ views }) : text({ via: name, ...args })) };
148
+ const published = [];
149
+ serve(handlers({
150
+ explorer: async () => explorer,
151
+ quote: (p, r, id) => `https://explorer.test/paid/${p}/${r}/${encodeURIComponent(id)}`,
345
152
  buy: async (quoted) => ({ fields: { quoted }, price: "10000" }),
153
+ wallet: async () => ({ address: "0xme" }),
346
154
  publish: async (a) => (published.push(a), { repo: a.repo, records: a.records.length }),
347
- };
348
- const published = [];
349
- deps.notify = serve(handlers(deps), { input, output });
155
+ }), { input, output });
350
156
  const rpc = (id, method, params) => input.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
351
- const wait = () => new Promise((r) => setTimeout(r, 20));
352
157
  const call = (id, name, args) => rpc(id, "tools/call", { name, arguments: args });
158
+ const wait = () => new Promise((r) => setTimeout(r, 20));
353
159
  const res = (id) => replies.find((r) => r.id === id);
354
160
  const body = (id) => JSON.parse(res(id).result.content[0].text);
355
161
  const assert = (c, m) => { if (!c) throw new Error(m); };
356
162
 
357
- rpc(1, "initialize", {}); rpc(2, "tools/list"); call(3, "list-apps", { query: "TRIAL" }); call(4, "kings-foil__search"); await wait();
358
- assert(res(1).result.capabilities.tools.listChanged, "advertises list_changed");
359
- assert(res(2).result.tools.length === 7, "only the meta tools before anything is open");
360
- assert(body(3).apps.length === 1 && body(3).apps[0].app === "kings-foil" && body(3).unverified === 1, "list-apps filters and slugs");
361
- assert(res(4).result.isError && /not open/.test(res(4).result.content[0].text), "an unopened app's tool is an error");
362
-
363
- call(30, "find-apps", { query: "trials", limit: 1 }); await wait();
364
- assert(body(30).ranked_by === "meaning" && body(30).apps.length === 1 && body(30).apps[0].open === false, "find-apps ranks, caps, and says what is open");
365
-
366
- call(5, "open-app", { app: "Kings Foil" }); await wait();
367
- call(6, "open-app", { app: "https://k.test/card" }); await wait();
368
- assert(body(5).tools[0].name === "search" && opened === 1, "open once, by name or by card URL");
369
- assert(body(5).skill?.url === "https://k.test/skill/kings-foil/SKILL.md" && /user/.test(body(5).skill.adopt), "open-app offers the app's skill, for the user to adopt");
370
- assert(body(5).plugin?.marketplace === "k/kf" && /user/.test(body(5).plugin.adopt), "open-app offers the app's plugin, for the user to install");
371
- assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
372
- rpc(7, "tools/list"); call(8, "kings-foil__search", { query: "x" }); call(9, "call-app-tool", { app: "kings-foil", tool: "search" });
373
- call(10, "call-app-tool", { app: "kings-foil", tool: "boom" }); call(11, "open-app", { app: "nope" }); call(13, "open-app", { app: "Bare" }); rpc(12, "nope"); await wait();
374
- assert(res(7).result.tools.some((t) => t.name === "kings-foil__search"), "opened tools are listed under the app");
375
- assert(body(8).via === "remote" && body(8).arguments.query === "x" && body(9).name === "search", "both call paths reach the app's server");
376
- assert(posts.join() === "initialize,tools/list,tools/call,tools/call", `initialize, list, then calls: ${posts}`);
377
- assert(res(10).result.isError && res(11).result.isError, "failures are tool errors");
378
- assert(res(13).result.isError && /no MCP server/.test(res(13).result.content[0].text), "an app without `mcp` cannot open");
379
- assert(res(12).error.code === -32601, "unknown method");
380
- call(14, "publish", { repo: "notes", records: [{ id: 1, payload: { title: "t" } }] }); call(15, "publish", { repo: "notes", records: [] }); call(16, "wallet"); await wait();
381
- assert(body(14).records === 1 && published[0].records[0].id === "1" && published[0].records[0].type === "record.v1", "publish commits, ids as strings, a default type");
382
- assert(res(15).result.isError && body(16).address === "0xme", "publishing nothing is an error; wallet reports");
383
- call(17, "buy", { app: "kings-foil", publisher: "0xp", repo: "notes", id: "a/b" }); call(18, "buy", { app: "nope", publisher: "0xp", repo: "r", id: "x" }); await wait();
384
- assert(body(17).fields.quoted === "https://k.test/paid/0xp/notes/a%2Fb", "buy quotes the record at the app's paid URL, the id escaped");
385
- assert(res(18).result.isError, "buying from an app that is not open is an error");
386
- // The budget: what a month's receipts add up to.
387
- const ledger = memoryLedger();
388
- ledger.record({ url: "q", amount: 10000n });
389
- if (ledger.spent() !== 10000n || ledger.receipts().length !== 1) throw new Error(`receipts: ${JSON.stringify(ledger.receipts())}`);
390
- console.log("mcp.js self-check ok — one server, apps opened on demand, calls relayed to each app's own MCP server; buy quotes a record at the opened app's paid URL; publish and wallet");
163
+ rpc(1, "initialize", {}); rpc(2, "tools/list"); call(3, "search", { query: "rain", publisher: P }); call(4, "search", { query: "rain", limit: 500 });
164
+ call(5, "publishers", {}); call(6, "publishers", { query: "HOURLY" }); call(7, "get", { id: "a" }); call(8, "nope"); rpc(9, "nope"); await wait();
165
+ assert(res(2).result.tools.map((t) => t.name).join() === "search,publishers,get,buy,wallet,publish", "the six tools, no apps");
166
+ assert(body(3).via === "search" && body(3).where.owner === P && body(4).where === undefined && body(4).limit === 50, "search goes to the explorer, narrowed to a publisher on request, capped");
167
+ assert(body(5).publishers.map((p) => [p.publisher, p.rows, p.repos.length]).join("|") === `${P},5,2|${Q},1,1`, "publishers groups repos by publisher, busiest first");
168
+ assert(body(6).publishers.length === 1 && body(6).publishers[0].publisher === P, "publishers filters on what a repo holds");
169
+ assert(body(7).via === "get" && body(7).id === "a", "get goes to the explorer");
170
+ assert(res(8).result.isError && res(9).error.code === -32601, "unknown tool and method");
171
+ call(10, "buy", { publisher: P, repo: "weather", id: "a/b" }); call(11, "publish", { repo: "notes", records: [{ id: 1, payload: { title: "t" } }] });
172
+ call(12, "publish", { repo: "notes", records: [] }); call(13, "wallet"); await wait();
173
+ assert(body(10).fields.quoted === `https://explorer.test/paid/${P}/weather/a%2Fb` && body(10).price === "10000", "buy quotes the record at the explorer, the id escaped");
174
+ assert(body(11).records === 1 && published[0].records[0].id === "1" && published[0].records[0].type === "record.v1", "publish commits, ids as strings, a default type");
175
+ assert(res(12).result.isError && body(13).address === "0xme", "publishing nothing is an error; wallet reports");
176
+ const failing = handlers({ explorer: async () => ({ call: async () => ({ isError: true, content: [{ type: "text", text: "down" }] }) }) });
177
+ assert(/explorer: down/.test((await failing["tools/call"]({ name: "search", arguments: { query: "x" } })).content[0].text), "an explorer error says so");
178
+ console.log("mcp.js self-check ok — search, get and publishers ask the explorer and never the chain; publishers groups repos by publisher; buy quotes at the explorer; publish and wallet");
391
179
  process.exit(0);
392
180
  }
393
181
 
394
- const argv = process.argv.slice(2);
395
- globalThis.fetch = cachingFetch();
396
182
  // Node gives each address 250ms before trying the next; on a slow link, or a
397
183
  // host with no working IPv6, every attempt times out and fetch fails outright.
398
184
  net.setDefaultAutoSelectFamilyAttemptTimeout?.(2000);
399
- const vt = await import("./view-tools.js");
400
- const fb = argv.indexOf("--from-block");
401
- // The SDK's deployment by default: pass --from-block for another one.
402
- const fromBlock = fb >= 0 ? BigInt(argv[fb + 1]) : (await network()).fromBlock;
403
- let found, foundAt = 0, explored, exploredAt = 0, pub, warming;
404
- const chainClient = async () => {
405
- if (!pub) {
406
- const [{ createPublicClient, http }, net] = await Promise.all([import("viem"), network()]);
407
- pub = createPublicClient({ chain: net.chain, transport: http(net.rpcUrl) });
408
- }
409
- return pub;
410
- };
411
- const store = fileStore(`${CACHE}/logs-${(await network()).chain.id}.json`);
185
+ const EXPLORER = (process.env.FANGORN_EXPLORER || "https://explorer.fangorn.network").replace(/\/+$/, "");
186
+ let explorer;
412
187
  // The person's key: this server's own, else the one the fangorn CLI signs with.
413
188
  const FANGORN_DIR = `${homedir()}/.fangorn`;
414
- let KEY = process.env.FANGORN_MCP_WALLET_KEY;
189
+ let KEY = process.env.FANGORN_MCP_WALLET_KEY || undefined; // the plugin passes "" when unset
415
190
  try { KEY ??= JSON.parse(readFileSync(`${FANGORN_DIR}/config.json`, "utf8")).privateKey; } catch { /* no CLI config */ }
416
191
  KEY &&= `0x${KEY.trim().replace(/^0x/i, "")}`; // a key file may lack 0x
417
- const ledger = fileLedger(`${FANGORN_DIR}/spend.json`);
418
192
  const needKey = () => { if (!KEY) throw new Error("no wallet: run `fangorn init`, or set FANGORN_MCP_WALLET_KEY"); return KEY; };
419
- let signer;
193
+ let signer, pub;
420
194
  const signing = async () => (signer ??= (await import("@fangorn-network/sdk")).Fangorn.create({
421
195
  privateKey: needKey(), config: await network(), storage: { signedUrl: {} } }));
196
+ const chainClient = async () => {
197
+ if (!pub) {
198
+ const [{ createPublicClient, http }, n] = await Promise.all([import("viem"), network()]);
199
+ pub = createPublicClient({ chain: n.chain, transport: http(n.rpcUrl) });
200
+ }
201
+ return pub;
202
+ };
422
203
  // Circle's USDC on Base Sepolia. ponytail: one network's; FANGORN_USDC for another.
423
- const USDC = process.env.FANGORN_USDC ?? "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
424
- // The embedder is for find-apps' ranking alone. Loading it blocks the thread for
425
- // seconds, so not at startup, where it would hold up list-apps.
426
- const warmEmbedder = () => (warming ??= vt.warm({ cacheDir: `${CACHE}/models` }).then((ok) => ok ? trace("embedder ready")
427
- : log(`embedder unavailable, searching by words: ${vt.warmError}`)));
204
+ const USDC = process.env.FANGORN_USDC || "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
428
205
  const deps = {
429
- // ponytail: cached for a minute. One log scan, a @card read per changed repo, a fetch per card.
430
- findApps: async () => {
431
- if (!found || Date.now() - foundAt > 60_000) {
432
- const { apps, rejected } = await listApps(await fangorn(), { fromBlock, store });
433
- found = { apps, rejected };
434
- foundAt = Date.now();
435
- }
436
- return found;
437
- },
438
- // ponytail: cached for a minute, like findApps. Two getLogs, a card and catalog per app.
439
- exploreApps: async (query) => {
440
- if (query?.trim()) warmEmbedder(); // loads behind the directory read; a first download ranks by words
441
- if (!explored || Date.now() - exploredAt > 60_000) {
442
- const [ex, client, net, f] = await Promise.all([import("../discover/explore.js"), chainClient(), network(), fangorn()]);
443
- explored = { ex, dir: await ex.readDirectory(f, client, { chain: ex.chainOf(net), store }) };
444
- exploredAt = Date.now();
206
+ // The explorer's view, searched here. Re-read every 5 minutes, so a long session sees new publishers.
207
+ // ponytail: the first search downloads the whole view; route to the best domains (shard.js setDomains)
208
+ // once the network is big enough for that to hurt.
209
+ explorer: async () => {
210
+ if (!explorer || Date.now() - explorer.at > 300_000) {
211
+ const [vt, { resetShard }] = await Promise.all([import("./view-tools.js"), import("../core/shard.js")]);
212
+ resetShard();
213
+ explorer = { at: Date.now(), ...vt.viewTools({ name: "the Fangorn explorer", desc: "every publisher", views: [`${EXPLORER}/view`] }) };
445
214
  }
446
- const { ex, dir } = explored;
447
- const { EMBED_MODEL } = await import("../core/embed.js");
448
- const qv = query?.trim() ? await vt.queryVector(query) : null;
449
- const apps = ex.rankApps(dir.apps, query, { qv, model: EMBED_MODEL }).map(ex.brief);
450
- return { apps, ranked_by: apps[0]?.ranked_by ?? null };
215
+ return explorer;
451
216
  },
452
- resolveCard,
453
- remoteTools: (url) => remoteTools(url, fetch),
454
- // Paying is opted into: this server's own key, or a budget set. The CLI's key alone only publishes.
217
+ quote: (p, r, id) => `${EXPLORER}/paid/${encodeURIComponent(p)}/${encodeURIComponent(r)}/${encodeURIComponent(id)}`,
218
+ // Paying is opted into with this server's own key; the CLI's key alone only publishes.
455
219
  buy: async (quote) => {
456
- if (!(process.env.FANGORN_MCP_WALLET_KEY || process.env.FANGORN_MCP_BUDGET)) throw new Error("buying is off: set FANGORN_MCP_WALLET_KEY (or FANGORN_MCP_BUDGET to pay from the CLI's wallet)");
457
- const usdc = (n) => BigInt(Math.round(Number(n) * 1e6));
458
- const left = usdc(process.env.FANGORN_MCP_BUDGET ?? "5") - ledger.spent();
459
- const cap = usdc(process.env.FANGORN_MCP_MAX_PRICE ?? "0.10");
220
+ if (!process.env.FANGORN_MCP_WALLET_KEY) throw new Error("buying is off: set FANGORN_MCP_WALLET_KEY");
460
221
  const { buyRecord } = await import("./x402.js");
461
- const r = await buyRecord(quote, { privateKey: needKey(), fangorn: await signing(), maxPrice: cap < left ? cap : left });
462
- if (r.tx) ledger.record({ url: quote, tool: "buy", amount: BigInt(r.price), tx: r.tx });
463
- return r;
222
+ return buyRecord(quote, { privateKey: needKey(), fangorn: await signing(),
223
+ maxPrice: BigInt(Math.round(Number(process.env.FANGORN_MCP_MAX_PRICE || "0.10") * 1e6)) });
464
224
  },
465
225
  wallet: async () => {
466
226
  const [{ privateKeyToAccount }, { parseAbi, formatUnits, formatEther }, c] = await Promise.all([import("viem/accounts"), import("viem"), chainClient()]);
@@ -468,9 +228,7 @@ const deps = {
468
228
  const [usdc, eth] = await Promise.all([
469
229
  c.readContract({ address: USDC, abi: parseAbi(["function balanceOf(address) view returns (uint256)"]), functionName: "balanceOf", args: [address] }),
470
230
  c.getBalance({ address })]);
471
- return { address, usdc: formatUnits(usdc, 6), eth: formatEther(eth),
472
- budget: `${process.env.FANGORN_MCP_BUDGET ?? "5"} USDC a month`, spent: formatUnits(ledger.spent(), 6),
473
- receipts: ledger.receipts().slice(-10).map((r) => ({ ...r, amount: formatUnits(BigInt(r.amount), 6) })) };
231
+ return { address, usdc: formatUnits(usdc, 6), eth: formatEther(eth) };
474
232
  },
475
233
  publish: async ({ repo, records }) => {
476
234
  const f = await signing();
@@ -479,15 +237,5 @@ const deps = {
479
237
  return { publisher: f.getAddress(), repo, records: records.length, commit: commitCid, tx: txHash };
480
238
  },
481
239
  };
482
- const h = handlers(deps);
483
- // The client starts this server with the session, long before an agent opens an
484
- // app: load the SDK then, behind the agent's first thought. After the initialize
485
- // reply, not before: it blocks the thread while it loads, and the client is
486
- // waiting on that reply.
487
- const reply = h.initialize;
488
- h.initialize = async (p) => {
489
- setImmediate(() => fangorn().then(() => trace("sdk ready"), () => {}));
490
- return reply(p);
491
- };
492
- deps.notify = serve(h);
493
- log(`ready: apps from block ${fromBlock}`);
240
+ serve(handlers(deps));
241
+ log(`ready: explorer ${EXPLORER}`);