@fangorn-network/westmarch 2026.10.26-dev → 2026.10.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "2026.10.26-dev",
3
+ "version": "2026.10.27",
4
4
  "description": "all-in-one builder for fangorn apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@fangorn-network/markov": "^0.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@fangorn-network/sdk": "^2026.10.21-dev",
48
+ "@fangorn-network/sdk": "^2026.10.26-dev",
49
49
  "@huggingface/transformers": "^4.2.0",
50
50
  "@noble/curves": "^1.9.1",
51
51
  "viem": "^2.55.1"
@@ -48,6 +48,10 @@ const HEX32 = /^0x[0-9a-fA-F]{64}$/;
48
48
  * tools, as the free tier's Worker does at /mcp.
49
49
  * config the deployment; defaults to the SDK's, so the addresses in the
50
50
  * card cannot drift from the ones `discoverApp` checks.
51
+ * skill `{url, sha256}`: the app's own agent skill (a SKILL.md), for agents
52
+ * that adopt it, so its behaviour travels with the app and not with a
53
+ * marketplace. The hash pins what the reader saw: an agent adopts it
54
+ * only on its user's yes, and asks again when the hash changes.
51
55
  */
52
56
  export function agentCard({ name, description, url, version = "0.0.0", tools = [], tags = [], skills = [], fangorn, ...rest }) {
53
57
  if (!name || !url) throw new Error("agentCard: name and url are required");
@@ -75,7 +79,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
75
79
 
76
80
  /** The params `discoverApp` reads, validated the same way it validates them.
77
81
  * A malformed card should fail the build, not the first reader. */
78
- export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, config = BaseSepoliaConfig }) {
82
+ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, skill, config = BaseSepoliaConfig }) {
79
83
  if (!app) throw new Error("fangorn.app is required");
80
84
  const id = HEX32.test(app) ? app : hashAppId(app);
81
85
  const from = String(fromBlock ?? "");
@@ -96,6 +100,8 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
96
100
  }
97
101
  // `access`: a private app. Its view and tools need a pass (`pass_url`, `pass` in base units, for
98
102
  // `days`) or one x402 payment of `call` per tool call; describe and the catalog are free.
103
+ if (skill && !(/^https?:\/\//.test(skill.url ?? "") && /^[0-9a-f]{64}$/.test(skill.sha256 ?? "")))
104
+ throw new Error(`fangorn.skill needs an http(s) url and the sha256 of the file, in hex: ${JSON.stringify(skill)}`);
99
105
  if (access && !(/^https?:\/\//.test(access.pass_url ?? "") && /^\d+$/.test(String(access.pass)) && /^\d+$/.test(String(access.call))))
100
106
  throw new Error(`fangorn.access needs pass_url, and pass and call in base units: ${JSON.stringify(access)}`);
101
107
  return {
@@ -110,6 +116,7 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
110
116
  ...(paid ? { paid: { url: paid.url, price: String(paid.price), asset: paid.asset, network: paid.network,
111
117
  symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
112
118
  ...(access ? { access } : {}),
119
+ ...(skill ? { skill: { url: skill.url, sha256: skill.sha256 } } : {}),
113
120
  },
114
121
  };
115
122
  }
@@ -148,6 +155,10 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
148
155
  throws(() => fangornExtension({ app: "a" }), /fromBlock/, "missing fromBlock");
149
156
  throws(() => fangornExtension({ app: "a", fromBlock: 0, views: ["javascript:alert(1)"] }), /views/, "non-http view");
150
157
  throws(() => agentCard({ name: "X" }), /url/, "missing url");
158
+ const sha = "a".repeat(64);
159
+ eq(fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/skill/a/SKILL.md", sha256: sha } }).params.skill, { url: "https://x.test/skill/a/SKILL.md", sha256: sha }, "a skill rides in the extension");
160
+ throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/s.md" } }), /skill/, "a skill without its hash");
161
+ throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "file:///etc/passwd", sha256: sha } }), /skill/, "a non-http skill");
151
162
 
152
163
  console.log("agent-card.js self-check ok — tools captured without executing, extension matches the SDK's, bad params fail the build");
153
164
  }
package/src/agent/mcp.js CHANGED
@@ -24,7 +24,7 @@ import net from "node:net";
24
24
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { createInterface } from "node:readline";
27
- import { APP_EXTENSION, listApps, toApp } from "../discover/apps.js";
27
+ import { APP_EXTENSION, listApps, skillOf, toApp } from "../discover/apps.js";
28
28
 
29
29
  const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
30
30
  // FANGORN_MCP_TRACE=1: step timings on stderr, for finding where an answer waits.
@@ -214,8 +214,9 @@ export function handlers(deps) {
214
214
  if (!mcp) throw new Error(`${card.name ?? cardUrl} has no MCP server (its card has no \`mcp\`)`);
215
215
  let s = slug(card.name ?? app);
216
216
  while (open.has(s)) s += "-";
217
- const access = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION)?.params?.access ?? null;
218
- const o = { slug: s, name: card.name, card: cardUrl, appId, ...await deps.remoteTools(mcp, { access }) };
217
+ const ext = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION);
218
+ const access = ext?.params?.access ?? null;
219
+ const o = { slug: s, name: card.name, card: cardUrl, appId, skill: skillOf(ext), ...await deps.remoteTools(mcp, { access }) };
219
220
  trace(`open ${app}: ready`);
220
221
  open.set(o.slug, o);
221
222
  deps.notify?.("notifications/tools/list_changed");
@@ -249,7 +250,10 @@ export function handlers(deps) {
249
250
  "open-app": async ({ app }) => {
250
251
  const o = await openApp(app);
251
252
  return text({ app: o.slug, name: o.name, card: o.card,
252
- call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools });
253
+ call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools,
254
+ // An app's own skill: standing instructions for the reader's agent, so never loaded
255
+ // silently. The fangorn-index skill offers it to the user and pins the hash on a yes.
256
+ ...(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." } } : {}) });
253
257
  },
254
258
  "call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
255
259
  };
@@ -298,7 +302,8 @@ if (process.argv[2] === "--selfcheck") {
298
302
  findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", appId: "0xk" },
299
303
  { name: "Bare", desc: "", card: "https://b.test/card", appId: "0xb" }], rejected: [{}] }),
300
304
  exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
301
- resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil" }, mcp: "https://k.test/mcp" }),
305
+ resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil", capabilities: { extensions: [{ uri: APP_EXTENSION,
306
+ params: { skill: { url: "https://k.test/skill/kings-foil/SKILL.md", sha256: "b".repeat(64) } } }] } }, mcp: "https://k.test/mcp" }),
302
307
  remoteTools: async () => (opened++, remote),
303
308
  };
304
309
  deps.notify = serve(handlers(deps), { input, output });
@@ -321,6 +326,7 @@ if (process.argv[2] === "--selfcheck") {
321
326
  call(5, "open-app", { app: "Kings Foil" }); await wait();
322
327
  call(6, "open-app", { app: "https://k.test/card" }); await wait();
323
328
  assert(body(5).tools[0].name === "search" && opened === 1, "open once, by name or by card URL");
329
+ 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");
324
330
  assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
325
331
  rpc(7, "tools/list"); call(8, "kings-foil__search", { query: "x" }); call(9, "call-app-tool", { app: "kings-foil", tool: "search" });
326
332
  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();
@@ -33,6 +33,12 @@ function safeView(url) {
33
33
  * src/agent/agent-card.js's self-check fails if the two drift. */
34
34
  export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
35
35
 
36
+ /** The app's agent skill, `{url, sha256}`, when its card names one well-formed; else null. */
37
+ export const skillOf = (ext) => {
38
+ const k = ext?.params?.skill;
39
+ return k && safeView(k.url) && /^[0-9a-f]{64}$/.test(k.sha256 ?? "") ? { url: k.url, sha256: k.sha256 } : null;
40
+ };
41
+
36
42
  /** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
37
43
  export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
38
44
  const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
@@ -40,7 +46,7 @@ export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
40
46
  const p = ext?.params?.paid;
41
47
  const paid = p && /^https?:\/\//.test(p.url ?? "") && p.url.includes("{id}") && /^\d+$/.test(String(p.price)) ? p : null;
42
48
  return {
43
- appId, card: cardUrl, fromBlock, repos, views, paid,
49
+ appId, card: cardUrl, fromBlock, repos, views, paid, skill: skillOf(ext),
44
50
  mcp: safeView(ext?.params?.mcp),
45
51
  name: card.name ?? appId,
46
52
  desc: card.description ?? "",
@@ -83,6 +83,8 @@ const log = (s) => console.error(`[ship] ${s}`);
83
83
  // // (pages/intro.html, a fragment, replaces the description on the front page)
84
84
  // "nav": [{ "href": "coverage.html", "label": "Coverage" }], // optional: header links to them
85
85
  // "examples": ["…"], // optional: questions to start from, on the front page (and a private app's gate)
86
+ // "skill": "skill/SKILL.md", // optional: the app's agent skill, served at skill/<app>/SKILL.md and named
87
+ // // in the card with its sha256; fangorn-index offers it to whoever opens the app
86
88
  // "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
87
89
  // // `pages` replaces the stock index.html with a page of its own
88
90
  // "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
@@ -549,6 +551,15 @@ export async function buildSite(cfg, base) {
549
551
  if (cfg.site.pages) cpSync(resolve(base, cfg.site.pages), site, { recursive: true });
550
552
  writeFileSync(join(site, "nav.json"), JSON.stringify(cfg.site.nav ?? []));
551
553
  writeFileSync(join(site, "examples.json"), JSON.stringify(cfg.site.examples ?? []));
554
+ // site.skill: the app's agent skill (a SKILL.md), served at skill/<app>/SKILL.md and named in the
555
+ // card with its hash, so fangorn-index can offer it to whoever opens the app.
556
+ let skill = null;
557
+ if (cfg.site.skill) {
558
+ const bytes = readFileSync(resolve(base, cfg.site.skill));
559
+ mkdirSync(join(site, "skill", cfg.app), { recursive: true });
560
+ writeFileSync(join(site, "skill", cfg.app, "SKILL.md"), bytes);
561
+ skill = { path: `skill/${cfg.app}/SKILL.md`, sha256: createHash("sha256").update(bytes).digest("hex") };
562
+ }
552
563
  // The card lists the tools the page at `url` registers: the stock page's, or the app's own.
553
564
  const card = await import("../agent/agent-card.js");
554
565
  const { registerAgent } = await import(cfg.site.agent ? pathToFileURL(resolve(base, cfg.site.agent)).href : "../../site/agent.js");
@@ -564,7 +575,8 @@ export async function buildSite(cfg, base) {
564
575
  ...(paid ? [{ id: "keys", name: "Keys", tags: [...(cfg.tags ?? []), "x402"],
565
576
  description: `The keys to sealed records (POST ${u}/paid), per record.` }] : []),
566
577
  ],
567
- fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {}) },
578
+ fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {}),
579
+ ...(skill ? { skill: { url: `${u}/${skill.path}`, sha256: skill.sha256 } } : {}) },
568
580
  });
569
581
  mkdirSync(join(site, ".well-known"), { recursive: true });
570
582
  writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
@@ -6,7 +6,7 @@
6
6
  "deploy": "wrangler deploy"
7
7
  },
8
8
  "dependencies": {
9
- "@fangorn-network/westmarch": "2026.10.2-2.0-dev",
9
+ "@fangorn-network/westmarch": "2026.10.27",
10
10
  "@huggingface/transformers": "^4.3.1",
11
11
  "wrangler": "^4.83.0"
12
12
  }