@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/README.md +1 -1
- package/docs/app-to-agent.md +12 -80
- package/package.json +5 -8
- package/site/worker.js +4 -1
- package/src/agent/mcp.js +134 -386
- package/src/agent/tools.js +2 -1
- package/src/agent/view-tools.js +4 -2
- package/src/agent/x402.js +16 -18
- package/src/publish/devnet.js +4 -2
- package/src/publish/init.js +1 -1
- package/src/publish/ship.js +6 -5
- package/src/publish/view.js +7 -6
- package/template/README.md +7 -9
- package/plugin/scripts/fx.mjs +0 -266
- package/src/discover/explore.js +0 -227
package/src/agent/mcp.js
CHANGED
|
@@ -1,199 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Fangorn for agents: every publisher's records, found through the explorer.
|
|
3
3
|
//
|
|
4
|
-
// fangorn-mcp
|
|
4
|
+
// fangorn-mcp
|
|
5
5
|
// claude mcp add fangorn -- fangorn-mcp
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// and
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
{ name: "
|
|
177
|
-
description: "
|
|
178
|
-
inputSchema: { type: "object",
|
|
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: "
|
|
185
|
-
description: "
|
|
186
|
-
inputSchema: { type: "object", properties: {
|
|
187
|
-
{ name: "
|
|
188
|
-
description: "
|
|
189
|
-
inputSchema: { type: "object",
|
|
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
|
|
192
|
-
inputSchema: { type: "object", required: ["
|
|
193
|
-
|
|
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
|
|
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
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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:
|
|
300
|
-
serverInfo: { name: "fangorn-mcp", version: "0.
|
|
301
|
-
instructions: "Fangorn
|
|
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])
|
|
311
|
-
|
|
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
|
|
327
|
-
// The
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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, "
|
|
358
|
-
|
|
359
|
-
assert(res(2).result.tools.
|
|
360
|
-
assert(body(3).
|
|
361
|
-
assert(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
assert(
|
|
365
|
-
|
|
366
|
-
call(
|
|
367
|
-
|
|
368
|
-
assert(body(
|
|
369
|
-
assert(
|
|
370
|
-
|
|
371
|
-
assert(
|
|
372
|
-
|
|
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
|
|
400
|
-
|
|
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
|
|
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
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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 (!
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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}`);
|