@naulon/wayfarer-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -37,8 +37,8 @@
37
37
  */
38
38
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
39
39
  import { z } from "zod";
40
- import { appraise, articleUrl, authorizeOrigin, buildPopProof, decodeHeld, DEFAULT_POLICY, discover, fetchJwks, fileHeldStore, gatewayBuyer, getWallet, isLive, memoBuyer, probe, probeFailure, quotedTotalAtomic, railBuyer, rereadWithLicense, run, selectBuyer, spendGate, tollgateBase, verifyAgainst, } from "@naulon/wayfarer";
41
- import { activeNetwork, getConfig, supportsMemo, usdc } from "@naulon/shared";
40
+ import { appraise, articleUrl, authorizeOrigin, buildPopProof, decodeHeld, DEFAULT_POLICY, discover, fetchJwks, fileHeldStore, gatewayBuyer, getWallet, isLive, licenseIdentityFor, memoBuyer, payHostOf, probe, probeFailure, quotedTotalAtomic, railBuyer, rereadWithLicense, resolvedDiscoverySourceUrl, run, selectBuyer, spendGate, tollgateBase, verifyAgainst, } from "@naulon/wayfarer";
41
+ import { activeNetwork, explorerTxUrl, FLEET_ORIGIN, getConfig, isFleetDefaultDiscovery, supportsMemo, usdc } from "@naulon/shared";
42
42
  import { cloudSignerFromEnv } from "./cloud-signer.js";
43
43
  export const SERVER_NAME = "naulon-wayfarer-mcp";
44
44
  /** Keep in step with this package's package.json `version` — it is what the MCP
@@ -61,6 +61,23 @@ function trueTotalUsdc(quoted) {
61
61
  function round6(usdcAmount) {
62
62
  return Math.round(usdcAmount * 1_000_000) / 1_000_000;
63
63
  }
64
+ /** The settlement-network descriptor echoed on every spend envelope and on status —
65
+ * the answer to "paid in WHAT, on WHICH chain, is it real money?" that used to be
66
+ * absent from every tool result. `testnet:true` means no fiat value. */
67
+ const networkOutputSchema = {
68
+ network: z.string().describe("CAIP-2 network id, e.g. eip155:5042002 (Arc Testnet) — the chain the toll settles on."),
69
+ chainId: z.number().describe("EVM chain id."),
70
+ chainName: z.string().describe("Human network name, e.g. arcTestnet / base."),
71
+ testnet: z.boolean().describe("TRUE = testnet play-money with NO fiat value (faucet-funded); FALSE = real-money mainnet. Report this honestly — never call a testnet toll 'real money'."),
72
+ token: z
73
+ .object({
74
+ symbol: z.string().describe("Settlement token symbol (USDC)."),
75
+ address: z.string().describe("ERC-20 token contract address on this chain."),
76
+ decimals: z.number().describe("Token decimals (6 for USDC)."),
77
+ })
78
+ .describe("The token every toll is paid in."),
79
+ explorer: z.string().optional().describe("Block-explorer origin for this chain when known — settlementRef is viewable at <explorer>/tx/<ref>. Omitted when no verified explorer exists for the chain."),
80
+ };
64
81
  /** Lowercased host INCLUDING port — endpoint identity, used for the gate origin pin
65
82
  * (a different port is a different service, so it must not satisfy the pin). */
66
83
  function hostOf(u) {
@@ -146,10 +163,24 @@ export function buildServer(opts = {}) {
146
163
  // (so it reflects deployment config), but `spentUsdc` accumulates across this
147
164
  // server instance's lifetime — one stdio/HTTP session = one budget envelope. The
148
165
  // model sees what remains and can plan within it; it can never raise the ceiling.
149
- let spentUsdc = 0;
150
- // Per-session pays per publisher host — the granular path's half of `perDomainCap` (decide()
151
- // enforces it for naulon_research against its own run-scoped counts).
166
+ // Seeded from the caller's durable prior spend (cloud) so the running total survives a
167
+ // silent reconnect; 0 for the stdio funnel (one process is the whole session lifetime).
168
+ let spentUsdc = round6(opts.initialSpentUsdc ?? 0);
169
+ // Per-session pays per publisher host — the ONE `perDomainCap` counter shared by BOTH spending
170
+ // paths. naulon_pay_and_read increments it directly (below); naulon_research seeds decide()'s own
171
+ // per-host tally from it (`decideContext.priorDomainCounts`) before each run and increments it
172
+ // from that run's `pay` decisions afterward (B3) — so a cap hit through either tool carries to
173
+ // the other, for the life of this session. Never a second, run-scoped counter.
152
174
  const paidByHost = new Map();
175
+ // WP-2 T2: the distinct hostnames THIS session's most recent naulon_discover call
176
+ // actually returned. Fleet-default auto-trust for the GRANULAR pay path (naulon_quote /
177
+ // naulon_pay_and_read) is keyed off this — unlike naulon_research, which discovers
178
+ // INSIDE run() and derives its own allowDomains fresh each call (RunOptions.
179
+ // autoTrustDiscoveredDomains), these two tools receive a slug/url the model already
180
+ // has from an EARLIER naulon_discover call, so "this session's last discovery" is the
181
+ // only discovery signal available to them. Reset (not merged) on every naulon_discover
182
+ // call — a stale prior topic's hosts must not linger and widen the allowlist.
183
+ let lastDiscoveredHosts = [];
153
184
  // ── Spend lock ──────────────────────────────────────────────────────────────
154
185
  // The budget check and its debit are separated by network I/O (probe → sign → paid GET), and an
155
186
  // MCP client may issue tool calls CONCURRENTLY (a normal parallel tool_use block). Without
@@ -167,6 +198,9 @@ export function buildServer(opts = {}) {
167
198
  // Where a needs_topup / grant_expired refusal points the agent to fund/renew (default: the
168
199
  // portal wallet path). Server-config, resolved once — never a tool arg.
169
200
  const buyerWalletUrl = opts.buyerWalletUrl ?? "/buyer/wallet";
201
+ // WP-3a — resolved once, mirroring every other opt above. Absent/empty on every stdio/self-host
202
+ // caller (the unchanged default); the 4 guarded handlers below check this BEFORE anything else.
203
+ const hostedInertSteer = opts.hostedInertSteer;
170
204
  // Hosted-wallet opt-in (BUY-2): when the cloud env is configured, tolls are signed by naulon's
171
205
  // grant-checked /sign-memo BFF (the custody-free session key), so this process holds NO private
172
206
  // key. Unset ⇒ the OSS default (BYO BUYER_PRIVATE_KEY via selectBuyer). Server-config, not a tool
@@ -200,7 +234,26 @@ export function buildServer(opts = {}) {
200
234
  // This session's gate: the injected fleet tenant (BUY-4.2) wins over env TOLLGATE_URL.
201
235
  // A function so the env default stays fresh per call when no override is supplied.
202
236
  // Resolved server-side, never from a tool arg — the model can't aim a payment off it.
203
- const gateBase = () => opts.tollgateUrl ?? tollgateBase();
237
+ //
238
+ // WP-2: there is NO universal fleet pay-gate (gate.naulon.app/articles/* → 502 — each
239
+ // publisher tolls at its own origin), so fleet-default zero-config deliberately leaves
240
+ // TOLLGATE_URL unset. `tollgateBase()` throws in that case (by design — a self-host with
241
+ // no gate configured at all is a real config error). So: an explicit TOLLGATE_URL (hosted
242
+ // override or env) always wins; absent that, fall back to FLEET_ORIGIN ONLY when discovery
243
+ // is the untouched fleet default (never for a self-hosted CATALOG_URL) — this is a
244
+ // placeholder identity for logging/JWKS/slug-fallback purposes only, since every fleet
245
+ // candidate carries its own real `url` (see isFleetDefaultDiscovery). Anything else
246
+ // (self-host with no gate at all) still throws, unchanged.
247
+ const gateBase = () => {
248
+ if (opts.tollgateUrl)
249
+ return opts.tollgateUrl;
250
+ const cfg = getConfig();
251
+ if (cfg.TOLLGATE_URL)
252
+ return cfg.TOLLGATE_URL;
253
+ if (isFleetDefaultDiscovery(cfg))
254
+ return FLEET_ORIGIN;
255
+ return tollgateBase();
256
+ };
204
257
  const slugUrl = (slug) => articleUrl(gateBase(), slug);
205
258
  // A non-gated quote must say WHY it isn't gated: a genuine free (2xx) read is very
206
259
  // different from a 404 (wrong path — usually the /essays/<slug> fallback missing a
@@ -238,13 +291,54 @@ export function buildServer(opts = {}) {
238
291
  killSwitch: cfg.WAYFARER_KILL_SWITCH,
239
292
  };
240
293
  };
294
+ // WP-2 T2 (corrected) — fleet-default auto-trust for the GRANULAR pay path (naulon_quote /
295
+ // naulon_pay_and_read). Unlike naulon_research (which discovers INSIDE run() and derives its
296
+ // own fresh allowDomains via RunOptions.autoTrustDiscoveredDomains), these tools act on a
297
+ // slug/url the model already holds from an EARLIER naulon_discover call in this session — so
298
+ // the fleet-default allowance here is keyed off `lastDiscoveredHosts` (that call's own
299
+ // hostnames), plus the gate's own host (L-OSS-3 — a slug-only target's resolved pay-URL lands
300
+ // on the gate host, and once allowDomains is stated the plain identity pin no longer covers
301
+ // it). Gated identically to isFleetDefaultDiscovery + no explicit WAYFARER_ALLOW_DOMAINS; an
302
+ // injected `opts.policy` or an explicit allowlist always wins (never overridden). Deliberately
303
+ // a no-op until `lastDiscoveredHosts` is non-empty (an ACTUAL naulon_discover call happened
304
+ // this session) — every fresh session (fleet-default is now the zero-config DEFAULT, so this
305
+ // is the common case) keeps the exact prior behavior (no allowDomains, plain gate-identity
306
+ // pin) until discovery actually occurs; stating an allowlist for no reason would silently swap
307
+ // the off-gate refusal from an identity-pin reason to an allowlist reason for every caller.
308
+ const policyForGranularPay = () => {
309
+ const base = policyFromConfig();
310
+ if (base.allowDomains)
311
+ return base;
312
+ if (!isFleetDefaultDiscovery(getConfig()) || lastDiscoveredHosts.length === 0)
313
+ return base;
314
+ const fleetAllow = [...new Set([hostnameOf(gateBase()) ?? undefined, ...lastDiscoveredHosts].filter((h) => !!h))];
315
+ return fleetAllow.length ? { ...base, allowDomains: fleetAllow } : base;
316
+ };
241
317
  const remainingUsdc = () => round6(Math.max(0, ceilingUsdc() - spentUsdc));
242
- /** The session-budget fields every spend-aware tool echoes so the host LLM always
243
- * sees the live envelope alongside the tool's own result. */
318
+ /** The settlement network this session tolls on — the fact that was MISSING, so an
319
+ * agent could not tell whether a paid read cost real money or testnet play-money
320
+ * (it would report a testnet toll as "real USDC"). `testnet:true` = no fiat value.
321
+ * Constant for the session (`SETTLEMENT_NETWORK`); echoed alongside every spend
322
+ * envelope and on naulon_status. */
323
+ const networkInfo = () => {
324
+ const net = activeNetwork();
325
+ return {
326
+ network: net.network,
327
+ chainId: net.chainId,
328
+ chainName: net.chainName,
329
+ testnet: net.testnet,
330
+ token: { symbol: "USDC", address: net.usdc, decimals: 6 },
331
+ ...(net.explorer ? { explorer: net.explorer } : {}),
332
+ };
333
+ };
334
+ /** The session-budget + network fields every spend-aware tool echoes so the host LLM
335
+ * always sees the live envelope — and what chain/token it is spending — alongside the
336
+ * tool's own result. */
244
337
  const envelope = () => ({
245
338
  ceilingUsdc: round6(ceilingUsdc()),
246
339
  spentSessionUsdc: round6(spentUsdc),
247
340
  remainingUsdc: remainingUsdc(),
341
+ settlement: networkInfo(),
248
342
  });
249
343
  // BUY-4.4: hand each buyer decision to the injected audit sink (the cloud writes it to
250
344
  // its org audit plane). Best-effort — a misbehaving sink must never break a paid read,
@@ -267,7 +361,12 @@ export function buildServer(opts = {}) {
267
361
  "catalog endpoint — set RSS_URL, PUBLISHER_URL, or CATALOG_URL, or use the hosted endpoint). " +
268
362
  "Returns FREE public teasers only — slug, title, and summary — with no content and no payment. " +
269
363
  "Call this first to see what is available before appraising, quoting, or paying. If no source " +
270
- "is configured it refuses with setup guidance rather than inventing sources.",
364
+ "is configured it refuses with setup guidance rather than inventing sources. " +
365
+ "A candidate may carry WHY it matched: `matchedInBody` means your terms are inside the paid " +
366
+ "text (strong — the teaser just does not show it), `matchedSemantic` means only that it is " +
367
+ "near your query in meaning and your terms are absent (weak — judge the teaser, and do not " +
368
+ "pay one that is off-topic). Discovery returns the best available matches, never a promise " +
369
+ "that any of them answers your question.",
271
370
  inputSchema: {
272
371
  topic: z.string().min(1).describe("The research topic to find candidate sources for."),
273
372
  },
@@ -283,21 +382,113 @@ export function buildServer(opts = {}) {
283
382
  .describe("Canonical URL this source is served from (from the RSS <link> / catalog / directory). " +
284
383
  "Pass it back to naulon_quote / naulon_pay_and_read so the toll targets the real link " +
285
384
  "(e.g. /articles/<slug>) instead of a reconstructed /essays/<slug> path."),
385
+ // Why the source matched. A directory that searches article bodies sets exactly one of
386
+ // these; an RSS source sets neither (it does not search). They must be DECLARED here or
387
+ // the SDK strips them from structuredContent — which is what happened until 2026-08-03:
388
+ // the fleet directory had been sending matchedInBody since it gained body search, and no
389
+ // stdio buyer ever saw it. The hosted agent had the evidence; a self-host buyer did not.
390
+ matchedInBody: z
391
+ .boolean()
392
+ .optional()
393
+ .describe("STRONG: your query's terms appear inside this source's full text, which the free " +
394
+ "teaser may not show. A flag only — the body itself stays behind the toll."),
395
+ matchedSemantic: z
396
+ .boolean()
397
+ .optional()
398
+ .describe("WEAK: this source is close to your query in meaning, but your terms do NOT appear " +
399
+ "in it. Judge it on the title and teaser; a near-miss looks exactly like this."),
286
400
  }))
287
401
  .describe("Free teasers; the agent has paid for nothing at this stage."),
402
+ refused: z
403
+ .boolean()
404
+ .optional()
405
+ .describe("True if the server refused to run discovery at all (e.g. a hosted ask-only mount) — see note. Never set alongside real candidates."),
406
+ note: z.string().optional().describe("Present when refused — explains why, and where to go instead."),
288
407
  },
289
408
  annotations: { readOnlyHint: true, openWorldHint: true },
290
409
  }, async ({ topic }) => {
410
+ // WP-3a — a hosted ask-only mount (WP-3c) is inert-but-honest for this tool: refuse BEFORE
411
+ // ever calling discover() (no config read, no network) rather than throwing a raw config
412
+ // error or fabricating candidates.
413
+ if (hostedInertSteer)
414
+ return structured({ candidates: [], refused: true, note: hostedInertSteer });
291
415
  const candidates = await discover(topic);
416
+ // WP-2 T2: remember THIS call's distinct hostnames (reset, not merged — a stale prior
417
+ // topic's hosts must not linger) for the granular pay path's fleet-default allowance
418
+ // (see policyForGranularPay). Never widens anything for a non-fleet-default deployment.
419
+ lastDiscoveredHosts = [
420
+ ...new Set(candidates.map((c) => (c.url ? hostnameOf(c.url) : null)).filter((h) => h !== null)),
421
+ ];
292
422
  return structured({ candidates });
293
423
  });
424
+ // ── naulon_status (free — the "run first" tool) ─────────────────────────────
425
+ server.registerTool("naulon_status", {
426
+ title: "Check wallet, discovery, and gate status",
427
+ description: "Run this FIRST, before anything else. Reports the buyer wallet address, where discovery " +
428
+ "is configured to look, and — plain-language — what to do next to get a paid read working " +
429
+ "(e.g. fund the wallet, or point discovery/the gate at your own publisher). Free, read-only, " +
430
+ "no network calls beyond resolving local config.",
431
+ inputSchema: {},
432
+ outputSchema: {
433
+ wallet: z.string().describe("The buyer wallet address (0x…40 hex) that pays every toll."),
434
+ tollgate: z
435
+ .string()
436
+ .optional()
437
+ .describe("The single configured pay-gate (TOLLGATE_URL), when self-hosting one. Absent for the fleet " +
438
+ "default — there is NO universal fleet pay-gate; each discovered publisher tolls at its own origin."),
439
+ discovery: z.string().describe("Where naulon_discover looks for candidates (RSS_URL > PUBLISHER_URL/rss.xml > CATALOG_URL)."),
440
+ ready: z.boolean().describe("True once a wallet address is resolvable — does NOT mean it is funded."),
441
+ nextStep: z.string().describe("Plain-language guidance for what to do next, given the current config."),
442
+ settlement: z
443
+ .object(networkOutputSchema)
444
+ .describe("The settlement chain + token this wallet tolls on. Read settlement.testnet FIRST: true ⇒ every spend is testnet play-money with no fiat value; false ⇒ real money."),
445
+ },
446
+ annotations: { readOnlyHint: true, openWorldHint: false },
447
+ }, async () => {
448
+ const cfg = getConfig();
449
+ // The address that ACTUALLY signs tolls — the injected session EOA on the hosted path, the env
450
+ // wallet on BYO-key. NOT `getWallet()`: on a custody-free deploy (no BUYER_PRIVATE_KEY, which is
451
+ // the correct hosted posture) that resolves to the throwaway MOCK dev key, whose private key is
452
+ // public. This tool is documented "run this FIRST" and its nextStep says "Fund this wallet (0x…)",
453
+ // so reporting the wrong address does not merely confuse — it directs a buyer to fund an address
454
+ // that never pays their tolls and that anyone can sweep. Prod 2026-08-03 reported
455
+ // 0xF0c9…a6F5 (the mock key) while every toll was signed by the session EOA.
456
+ const wallet = payerAddress();
457
+ const fleetDefault = isFleetDefaultDiscovery(cfg);
458
+ // Amendment (wp2-brief): never imply a single universal pay-gate exists for the fleet
459
+ // default — discovery is fleet-WIDE, but payment is authorized per-publisher (the trusted
460
+ // directory's own discovered domains, spendGate-capped — WP-2 T2), not one gate.
461
+ // The funding phrase is network-aware: a testnet gate is faucet-funded play-money, a
462
+ // mainnet gate is real USDC — never tell an operator to "fund with testnet USDC" on mainnet.
463
+ const net = activeNetwork();
464
+ const fundHint = net.testnet
465
+ ? `with testnet USDC (play-money, no fiat value) — e.g. faucet.circle.com/Arc-Testnet — or connect a token`
466
+ : `with real USDC on ${net.chainName} (mainnet — real money) — or connect a funded token`;
467
+ const nextStep = fleetDefault
468
+ ? `Fund this wallet (${wallet}) ${fundHint}. ` +
469
+ `Discovery is fleet-wide (the naulon directory); payment is authorized per-publisher for whatever it ` +
470
+ `discovers (each publisher's own toll, capped by your spend policy) — there is no single pay-gate to configure.`
471
+ : `Confirm your configured gate (TOLLGATE_URL${cfg.TOLLGATE_URL ? ` = ${cfg.TOLLGATE_URL}` : " is not set yet"}) ` +
472
+ `is reachable and that this wallet (${wallet}) is funded ${fundHint.replace(/ — .*$/, "")} to pay it.`;
473
+ return structured({
474
+ wallet,
475
+ ...(cfg.TOLLGATE_URL ? { tollgate: cfg.TOLLGATE_URL } : {}),
476
+ discovery: resolvedDiscoverySourceUrl(),
477
+ ready: Boolean(wallet),
478
+ nextStep,
479
+ settlement: networkInfo(),
480
+ });
481
+ });
294
482
  // ── naulon_appraise (free) ──────────────────────────────────────────────────
295
483
  server.registerTool("naulon_appraise", {
296
484
  title: "Appraise candidates for a topic",
297
485
  description: "Score how relevant each candidate is to the topic, from its free teaser alone — a 0..1 " +
298
486
  "relevance plus a one-line rationale. Pass the candidates you got from naulon_discover (or a " +
299
- "curated subset). This is FREE and judges the teaser text only; it does not fetch or pay for " +
300
- "any content. Use it to decide what is worth quoting and paying for.",
487
+ "curated subset), and pass them back WHOLE — `matchedInBody` / `matchedSemantic` tell the " +
488
+ "scorer WHY the search returned each one, and dropping them makes a body-matched source with " +
489
+ "a terse teaser look identical to a near-miss with a keyword-ish one. FREE: it reads the " +
490
+ "teaser and those flags, never paid content, and it fetches and pays for nothing. " +
491
+ "Use it to decide what is worth quoting and paying for.",
301
492
  inputSchema: {
302
493
  topic: z.string().min(1).describe("The research topic to score relevance against."),
303
494
  candidates: z
@@ -305,9 +496,21 @@ export function buildServer(opts = {}) {
305
496
  slug: z.string(),
306
497
  title: z.string(),
307
498
  summary: z.string().describe("The free teaser to judge — title + summary, no paid content."),
499
+ // Declared for the same reason naulon_discover declares them on the way out: an
500
+ // undeclared key is stripped by schema validation. Without these two lines the
501
+ // evidence survives discovery and dies one tool later, and the scorer is back to
502
+ // judging a summary it was never safe to judge alone.
503
+ matchedInBody: z
504
+ .boolean()
505
+ .optional()
506
+ .describe("From naulon_discover: the topic's words are inside this source's full text."),
507
+ matchedSemantic: z
508
+ .boolean()
509
+ .optional()
510
+ .describe("From naulon_discover: near the topic in meaning only — its words are absent."),
308
511
  }))
309
512
  .min(1)
310
- .describe("Candidates to appraise (typically from naulon_discover)."),
513
+ .describe("Candidates to appraise — pass naulon_discover's rows through unmodified."),
311
514
  },
312
515
  outputSchema: {
313
516
  appraised: z.array(z.object({
@@ -319,8 +522,9 @@ export function buildServer(opts = {}) {
319
522
  },
320
523
  annotations: { readOnlyHint: true, openWorldHint: false },
321
524
  }, async ({ topic, candidates }) => {
322
- // Relevance is judged from the teaser text only — price plays no part, so we
323
- // appraise with a zero price and drop it from the output.
525
+ // Price plays no part in relevance, so appraise at zero and drop it from the output. The
526
+ // spread carries the match-evidence flags through to the scorer — that is the point of
527
+ // declaring them above.
324
528
  const priced = candidates.map((c) => ({ ...c, price: usdc(0) }));
325
529
  const scored = await appraise(topic, priced);
326
530
  const appraised = scored.map((a) => ({
@@ -376,14 +580,22 @@ export function buildServer(opts = {}) {
376
580
  ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session (cannot be raised from a tool)."),
377
581
  spentSessionUsdc: z.number().describe("Total already spent in this MCP session."),
378
582
  remainingUsdc: z.number().describe("Budget left for this session — plan spend within this."),
583
+ settlement: z.object(networkOutputSchema).describe("The chain + token this session settles on. Check settlement.testnet before reporting a spend as real money."),
379
584
  note: z.string().optional(),
380
585
  },
381
586
  annotations: { readOnlyHint: true, openWorldHint: true },
382
587
  }, async ({ slug, url }) => {
588
+ // WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
589
+ // resolving gateBase() or probing, using the SAME refused/note shape the origin refusal
590
+ // below already returns.
591
+ if (hostedInertSteer)
592
+ return structured({ refused: true, note: hostedInertSteer, ...envelope() });
383
593
  const target = url ?? slugUrl(slug);
384
594
  // Origin + policy even on the free probe: an unauthorized url is an SSRF surface and would
385
- // return an attacker-authored price/payTo the model might then act on.
386
- const refusal = originRefusal(target, gateBase(), policyFromConfig());
595
+ // return an attacker-authored price/payTo the model might then act on. policyForGranularPay
596
+ // (WP-2 T2) is the SAME fleet-default allowance naulon_pay_and_read applies below, so a
597
+ // fleet-discovered publisher domain can be quoted, not just paid.
598
+ const refusal = originRefusal(target, gateBase(), policyForGranularPay());
387
599
  if (refusal) {
388
600
  // A refusal is neither payable nor free: signal refused (not gated:false, which the tool
389
601
  // contract defines as "free read — just fetch it" and a buyer would act on).
@@ -437,25 +649,40 @@ export function buildServer(opts = {}) {
437
649
  ok: z.boolean(),
438
650
  content: z.string().optional().describe("The paid-for content."),
439
651
  settlementRef: z.string().optional().describe("On-chain / settlement reference for the payment."),
652
+ explorerTxUrl: z.string().optional().describe("A clickable block-explorer link for settlementRef (<explorer>/tx/<ref>), when the chain has a known explorer. Cite this so a human can verify the on-chain settlement."),
440
653
  paidUsdc: z.number().optional().describe("The author leg paid, in USDC."),
441
654
  costUsdc: z.number().optional().describe("The true total debited from the session budget (author + any fee legs)."),
442
655
  licenseId: z.string().optional().describe("Citation License jti — cite this as proof of a paid read."),
443
656
  licenseVerified: z
444
657
  .boolean()
445
658
  .optional()
446
- .describe("True/false if the license signature was checked against the gate's JWKS; omitted if JWKS unavailable."),
659
+ .describe("True/false if the license was checked against the gate's JWKS and canonical iss/aud identity; " +
660
+ "omitted if the JWKS or the gate's canonical identity is unavailable (never a fabricated match)."),
447
661
  ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session."),
448
662
  spentSessionUsdc: z.number().describe("Total spent in this MCP session (after this call)."),
449
663
  remainingUsdc: z.number().describe("Budget left for this session (after this call)."),
664
+ settlement: z.object(networkOutputSchema).describe("The chain + token this toll settled on. If settlement.testnet is true the amount is play-money with no fiat value — report it as such."),
450
665
  error: z.string().optional(),
451
666
  errorCode: z
452
- .enum(["not_gated", "not_found", "toll_moved", "insufficient_funds", "expired", "rejected", "origin_error", "needs_topup", "grant_expired"])
667
+ .enum([
668
+ "not_gated",
669
+ "not_found",
670
+ "toll_moved",
671
+ "insufficient_funds",
672
+ "expired",
673
+ "rejected",
674
+ "origin_error",
675
+ "needs_topup",
676
+ "grant_expired",
677
+ "settlement_ambiguous",
678
+ "payee_refused",
679
+ ])
453
680
  .optional()
454
- .describe("Typed failure reason when ok:false — lets you decide whether to retry. not_found = the probed URL 404'd (pass the canonical url; it is not a free read). needs_topup = the funding session is exhausted/unset — fund it at topUpUrl. grant_expired = the funding window lapsed (funds intact) — renew at topUpUrl."),
681
+ .describe("Typed failure reason when ok:false — lets you decide whether to retry. not_found = the probed URL 404'd (pass the canonical url; it is not a free read). needs_topup = the funding session is exhausted/unset — fund it at topUpUrl. grant_expired = the funding window lapsed (funds intact) — renew at topUpUrl. settlement_ambiguous = the payment-signature was sent and may have settled, but reading the response failed — do NOT blind-retry (a fresh pay could double-charge); verify via settlementRef or a held license first. payee_refused = the 402 named a payTo this server does not authorize for the origin — a spend-safety stop, nothing was paid; do NOT retry (the same 402 re-refuses)."),
455
682
  retryable: z
456
683
  .boolean()
457
684
  .optional()
458
- .describe("True if re-quoting/retrying may succeed (toll moved, expired, rejected); false for a hard stop (insufficient funds, needs_topup, grant_expired — the wallet needs funding or renewal, not a retry)."),
685
+ .describe("True if re-quoting/retrying may succeed (toll moved, expired, rejected); false for a hard stop (insufficient funds, needs_topup, grant_expired — the wallet needs funding or renewal, not a retry; settlement_ambiguous — a retry could double-charge)."),
459
686
  topUpUrl: z
460
687
  .string()
461
688
  .optional()
@@ -466,179 +693,200 @@ export function buildServer(opts = {}) {
466
693
  .describe("The toll (true total, USDC) this call could not cover — present on a needs_topup refusal so the operator knows how much the session is short."),
467
694
  },
468
695
  annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
469
- }, async ({ slug, url }) => withSpendLock(async () => {
470
- // Quote first and gate on the SESSION BUDGET before any spend. The price is the
471
- // buyer's true total across legs; refusing here is the budget ceiling (the
472
- // on-chain insufficient-funds + toll-moved-at-pay tolerance are BUY-1.4).
473
- // The canonical url (when the model passes it) is the pay target, verbatim — one
474
- // buyer pays any publisher's URL shape (/articles/, custom domain) without a
475
- // reconstructed /essays/ template. Absent, fall back to the template.
476
- const target = url ?? slugUrl(slug);
477
- // Endpoint identity first — refused targets are never even fetched, so an off-gate url
478
- // costs nothing and reaches no attacker origin. Operator policy is applied below, once the
479
- // toll is known (the approval threshold is price-dependent).
480
- const policy = policyFromConfig();
481
- const refusal = originRefusal(target, gateBase(), policy);
482
- if (refusal) {
483
- emitAudit({ slug, action: "skip", reason: refusal, agentId: policy.agentId });
484
- return structured({ ok: false, error: refusal, errorCode: "rejected", retryable: false, ...envelope() });
485
- }
486
- const outcome = await probe(target, KIND, payerAddress());
487
- if (outcome.status !== "gated") {
488
- const failure = probeFailure(outcome, target);
489
- emitAudit({
490
- slug,
491
- action: "skip",
492
- reason: `not payable: ${failure.errorCode ?? "not_gated"} — ${failure.error ?? ""}`.trim(),
493
- agentId: policyFromConfig().agentId,
494
- });
495
- return structured({
496
- ok: false,
497
- error: failure.error ?? "not gated — no payment is required.",
498
- ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
499
- ...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
500
- ...envelope(),
501
- });
502
- }
503
- const quoted = outcome.quoted;
504
- const cost = round6(trueTotalUsdc(quoted));
505
- // Operator policy — the ONE shared evaluator `decide()` uses, so this granular path and
506
- // naulon_research enforce byte-identical rules (kill-switch, deny/allow, per-domain cap,
507
- // approval threshold). `paidCount`/`remainingUsdc` are omitted: the session envelope below
508
- // owns budget accounting with its own message, and maxPaid is a per-run planning cap.
509
- const payHost = hostnameOf(target);
510
- const verdict = spendGate({
511
- host: payHost ?? undefined,
512
- priceUsdc: cost,
513
- policy,
514
- paidForHost: payHost ? (paidByHost.get(payHost) ?? 0) : 0,
515
- });
516
- if (!verdict.ok) {
517
- const reason = verdict.action === "approve"
518
- ? `${verdict.reason} — NOT auto-paid. Nothing was spent.`
519
- : `${verdict.reason} — nothing was spent.`;
520
- emitAudit({ slug, action: verdict.action, reason, priceUsdc: quoted.priceUsdc, agentId: policy.agentId });
521
- return structured({ ok: false, error: reason, errorCode: "rejected", retryable: false, ...envelope() });
696
+ }, async ({ slug, url }) => {
697
+ // WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
698
+ // joining the spend lock or resolving gateBase(), using the SAME ok:false/errorCode shape
699
+ // the origin refusal below already returns. Nothing is spent.
700
+ if (hostedInertSteer) {
701
+ return structured({ ok: false, error: hostedInertSteer, errorCode: "rejected", retryable: false, ...envelope() });
522
702
  }
523
- if (cost > remainingUsdc()) {
524
- emitAudit({
525
- slug,
526
- action: "skip",
527
- reason: `over budget: toll $${cost} exceeds $${remainingUsdc()} remaining (ceiling $${round6(ceilingUsdc())}) — nothing spent`,
528
- priceUsdc: quoted.priceUsdc,
529
- agentId: policyFromConfig().agentId,
703
+ return withSpendLock(async () => {
704
+ // Quote first and gate on the SESSION BUDGET before any spend. The price is the
705
+ // buyer's true total across legs; refusing here is the budget ceiling (the
706
+ // on-chain insufficient-funds + toll-moved-at-pay tolerance are BUY-1.4).
707
+ // The canonical url (when the model passes it) is the pay target, verbatim — one
708
+ // buyer pays any publisher's URL shape (/articles/, custom domain) without a
709
+ // reconstructed /essays/ template. Absent, fall back to the template.
710
+ const target = url ?? slugUrl(slug);
711
+ // Endpoint identity first — refused targets are never even fetched, so an off-gate url
712
+ // costs nothing and reaches no attacker origin. Operator policy is applied below, once the
713
+ // toll is known (the approval threshold is price-dependent). policyForGranularPay (WP-2 T2)
714
+ // folds in the fleet-default allowance (this session's last naulon_discover hosts) so a
715
+ // directory-discovered publisher domain is payable, not off-gate-skipped.
716
+ const policy = policyForGranularPay();
717
+ const refusal = originRefusal(target, gateBase(), policy);
718
+ if (refusal) {
719
+ emitAudit({ slug, action: "skip", reason: refusal, agentId: policy.agentId });
720
+ return structured({ ok: false, error: refusal, errorCode: "rejected", retryable: false, ...envelope() });
721
+ }
722
+ const outcome = await probe(target, KIND, payerAddress());
723
+ if (outcome.status !== "gated") {
724
+ const failure = probeFailure(outcome, target);
725
+ emitAudit({
726
+ slug,
727
+ action: "skip",
728
+ reason: `not payable: ${failure.errorCode ?? "not_gated"} — ${failure.error ?? ""}`.trim(),
729
+ agentId: policyFromConfig().agentId,
730
+ });
731
+ return structured({
732
+ ok: false,
733
+ error: failure.error ?? "not gated — no payment is required.",
734
+ ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
735
+ ...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
736
+ ...envelope(),
737
+ });
738
+ }
739
+ const quoted = outcome.quoted;
740
+ const cost = round6(trueTotalUsdc(quoted));
741
+ // Operator policy — the ONE shared evaluator `decide()` uses, so this granular path and
742
+ // naulon_research enforce byte-identical rules IN THE SAME ORDER (kill-switch, deny/allow,
743
+ // per-domain cap, budget, approval threshold — decide.ts's order is load-bearing). `remainingUsdc`
744
+ // MUST be passed: decide()'s spendGate checks budget BEFORE the approval threshold, so omitting
745
+ // it here let a toll that was BOTH over budget and over the approval threshold fall through to
746
+ // the approval branch and misreport as "needs human approval" — implying approving it would let
747
+ // it proceed, which it would not (the toll is over budget regardless). `paidCount` (the maxPaid
748
+ // gate) stays omitted on purpose — maxPaid is a PER-RUN planning cap for naulon_research's own
749
+ // loop, not a session-wide cap on this granular tool; passing it would newly enforce a 5-pay
750
+ // ceiling across the whole MCP session, which nothing here asked for.
751
+ const payHost = hostnameOf(target);
752
+ const verdict = spendGate({
753
+ host: payHost ?? undefined,
754
+ priceUsdc: cost,
755
+ policy,
756
+ paidForHost: payHost ? (paidByHost.get(payHost) ?? 0) : 0,
757
+ remainingUsdc: remainingUsdc(),
530
758
  });
531
- return structured({
532
- ok: false,
533
- error: `Toll is $${cost} but only $${remainingUsdc()} remains in the session budget ` +
534
- `($${round6(ceilingUsdc())} ceiling, $${round6(spentUsdc)} already spent). The ceiling is ` +
535
- `server-configured and cannot be raised from a tool. Nothing was spent.`,
536
- ...envelope(),
759
+ if (!verdict.ok) {
760
+ const reason = verdict.action === "approve"
761
+ ? `${verdict.reason} — NOT auto-paid. Nothing was spent.`
762
+ : `${verdict.reason} — nothing was spent.`;
763
+ emitAudit({ slug, action: verdict.action, reason, priceUsdc: quoted.priceUsdc, agentId: policy.agentId });
764
+ return structured({ ok: false, error: reason, errorCode: "rejected", retryable: false, ...envelope() });
765
+ }
766
+ // NOTE: a redundant `cost > remainingUsdc()` re-check used to live here, AFTER spendGate. It is
767
+ // now unreachable by construction — spendGate is given the same `cost` and the same
768
+ // `remainingUsdc()` above, so if this line is reached the budget gate has already passed. Two
769
+ // implementations of one rule is exactly how the "misreports as approval" bug above happened;
770
+ // it is not re-introduced.
771
+ // Hosted path: sign each leg via the cloud session key. With BOTH rail signers (RAS-B mixed
772
+ // fleet) railBuyer picks the rail from the TENANT's advertised 402 — a gateway 402 signs the
773
+ // Circle envelope even under a memo-default fleet, and vice-versa. With a single injected signer
774
+ // (one-network host / stdio) keep the activeNetwork() branch: a memo-LESS network (Base + every
775
+ // Gateway chain) settles via gatewayBuyer, else memoBuyer. Neither reads BUYER_PRIVATE_KEY.
776
+ // Default: the BYO-key buyer selectBuyer() picks (which branches the same way for the env path).
777
+ const buyer = opts.railSigners
778
+ ? railBuyer(opts.railSigners)
779
+ : cloudSigner
780
+ ? supportsMemo(activeNetwork())
781
+ ? memoBuyer(cloudSigner)
782
+ : gatewayBuyer(cloudSigner)
783
+ : await selectBuyer();
784
+ await buyer.init();
785
+ // Re-quote at pay time and abort if the toll moved past the quote we gated the
786
+ // budget on (BUY-1.4 toll-moved guard). The buyer pays NOTHING if it has moved.
787
+ const result = await buyer.fetch(target, KIND, {
788
+ maxTotalAtomic: guardCeilingAtomic(quoted),
789
+ // Bind the fetched url so the cloud host can resolve the tenant + its owner-declared payees; the
790
+ // guard passes each leg's payTo through and refuses (payee_refused) any the host does not authorize.
791
+ ...(opts.authorizePayee ? { authorizePayee: (payTo) => opts.authorizePayee({ url: target, payTo }) } : {}),
537
792
  });
538
- }
539
- // Hosted path: sign each leg via the cloud session key. With BOTH rail signers (RAS-B mixed
540
- // fleet) railBuyer picks the rail from the TENANT's advertised 402 — a gateway 402 signs the
541
- // Circle envelope even under a memo-default fleet, and vice-versa. With a single injected signer
542
- // (one-network host / stdio) keep the activeNetwork() branch: a memo-LESS network (Base + every
543
- // Gateway chain) settles via gatewayBuyer, else memoBuyer. Neither reads BUYER_PRIVATE_KEY.
544
- // Default: the BYO-key buyer selectBuyer() picks (which branches the same way for the env path).
545
- const buyer = opts.railSigners
546
- ? railBuyer(opts.railSigners)
547
- : cloudSigner
548
- ? supportsMemo(activeNetwork())
549
- ? memoBuyer(cloudSigner)
550
- : gatewayBuyer(cloudSigner)
551
- : await selectBuyer();
552
- await buyer.init();
553
- // Re-quote at pay time and abort if the toll moved past the quote we gated the
554
- // budget on (BUY-1.4 toll-moved guard). The buyer pays NOTHING if it has moved.
555
- const result = await buyer.fetch(target, KIND, { maxTotalAtomic: guardCeilingAtomic(quoted) });
556
- if (!result.ok) {
557
- // A failed pay is an accountable non-spend: the agent decided to pay, the rail refused.
558
- // Audit it as a skip carrying the typed failure so the org can see the attempt + cause.
793
+ if (!result.ok) {
794
+ // A failed pay is an accountable non-spend: the agent decided to pay, the rail refused.
795
+ // Audit it as a skip carrying the typed failure so the org can see the attempt + cause.
796
+ emitAudit({
797
+ slug,
798
+ action: "skip",
799
+ reason: `payment failed: ${result.errorCode ?? result.error ?? "unknown"} — nothing spent`,
800
+ priceUsdc: quoted.priceUsdc,
801
+ agentId: policyFromConfig().agentId,
802
+ });
803
+ // A hosted session-signer refusal (needs_topup / grant_expired) is actionable, not a dead
804
+ // end: surface WHERE to fund/renew and HOW MUCH the toll was, so the agent points its
805
+ // operator at the fix instead of re-calling a pay that can only fail again.
806
+ const actionable = result.errorCode === "needs_topup" || result.errorCode === "grant_expired";
807
+ return structured({
808
+ ok: false,
809
+ error: result.error ?? "payment failed",
810
+ ...(result.errorCode ? { errorCode: result.errorCode } : {}),
811
+ ...(result.retryable === undefined ? {} : { retryable: result.retryable }),
812
+ ...(actionable ? { topUpUrl: buyerWalletUrl, requiredUsdc: cost } : {}),
813
+ ...envelope(),
814
+ });
815
+ }
816
+ // Debit the true total the buyer ACTUALLY authorized (result.costUsdc, computed by the
817
+ // buyer from the quote it signed at pay time), falling back to our pre-pay `cost`. Using
818
+ // costUsdc closes the gap where a pay-time re-quote within tolerance paid more than the
819
+ // pre-pay quote we gated on — the ledger would otherwise understate real spend. result.paidUsdc
820
+ // is only the author leg, so it would under-count a fee'd toll against the budget.
821
+ spentUsdc = round6(spentUsdc + (result.costUsdc ?? cost));
822
+ if (payHost)
823
+ paidByHost.set(payHost, (paidByHost.get(payHost) ?? 0) + 1);
824
+ let licenseId;
825
+ let licenseVerified;
826
+ if (result.license) {
827
+ const decoded = decodeHeld(result.license);
828
+ if (decoded) {
829
+ licenseId = decoded.jti;
830
+ // BEST-EFFORT, exactly like emitAudit: the money has ALREADY moved by here. A hosted
831
+ // store (DB/KV) that throws on a transient failure must never turn a successful paid
832
+ // read into an error — that would lose the content + receipt the buyer just paid for
833
+ // and push the agent to pay again. Persisting the license is a caching nicety; the
834
+ // paid read is the product.
835
+ try {
836
+ const held = await heldStore.load();
837
+ // Capture the url actually paid so a later read_held re-fetches THIS link
838
+ // verbatim, not a reconstructed /essays/<slug> template that 404s off-shape.
839
+ held.set(decoded.slug, { ...decoded, jws: result.license, url: target });
840
+ await heldStore.save(held);
841
+ }
842
+ catch {
843
+ /* swallow — a held-license persist failure must never fail an already-paid read */
844
+ }
845
+ }
846
+ const jwks = await fetchJwks(gateBase());
847
+ // The canonical identity of the gate this read just settled into — derived from
848
+ // `target` (the paid url), never from the token's own claims (A4). Cosmetic/log
849
+ // field only: it does not gate the payment or the license persist above, both of
850
+ // which already happened by this point regardless of licenseVerified's value.
851
+ const identity = licenseIdentityFor(target);
852
+ if (jwks && identity)
853
+ licenseVerified = verifyAgainst(result.license, jwks, { issuer: identity, audience: identity });
854
+ }
559
855
  emitAudit({
560
856
  slug,
561
- action: "skip",
562
- reason: `payment failed: ${result.errorCode ?? result.error ?? "unknown"} — nothing spent`,
857
+ action: "pay",
858
+ reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
563
859
  priceUsdc: quoted.priceUsdc,
860
+ paidUsdc: result.paidUsdc,
861
+ costUsdc: cost,
862
+ ...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
863
+ ...(licenseId ? { licenseId } : {}),
564
864
  agentId: policyFromConfig().agentId,
565
865
  });
566
- // A hosted session-signer refusal (needs_topup / grant_expired) is actionable, not a dead
567
- // end: surface WHERE to fund/renew and HOW MUCH the toll was, so the agent points its
568
- // operator at the fix instead of re-calling a pay that can only fail again.
569
- const actionable = result.errorCode === "needs_topup" || result.errorCode === "grant_expired";
866
+ const explorerUrl = explorerTxUrl(activeNetwork(), result.settlementRef);
570
867
  return structured({
571
- ok: false,
572
- error: result.error ?? "payment failed",
573
- ...(result.errorCode ? { errorCode: result.errorCode } : {}),
574
- ...(result.retryable === undefined ? {} : { retryable: result.retryable }),
575
- ...(actionable ? { topUpUrl: buyerWalletUrl, requiredUsdc: cost } : {}),
868
+ ok: true,
869
+ content: result.content,
870
+ settlementRef: result.settlementRef,
871
+ ...(explorerUrl ? { explorerTxUrl: explorerUrl } : {}),
872
+ paidUsdc: result.paidUsdc,
873
+ // Report the total ACTUALLY authorized (what the budget was debited), not the pre-pay quote.
874
+ costUsdc: result.costUsdc ?? cost,
875
+ ...(licenseId ? { licenseId } : {}),
876
+ ...(licenseVerified === undefined ? {} : { licenseVerified }),
576
877
  ...envelope(),
577
878
  });
578
- }
579
- // Debit the true total the buyer ACTUALLY authorized (result.costUsdc, computed by the
580
- // buyer from the quote it signed at pay time), falling back to our pre-pay `cost`. Using
581
- // costUsdc closes the gap where a pay-time re-quote within tolerance paid more than the
582
- // pre-pay quote we gated on — the ledger would otherwise understate real spend. result.paidUsdc
583
- // is only the author leg, so it would under-count a fee'd toll against the budget.
584
- spentUsdc = round6(spentUsdc + (result.costUsdc ?? cost));
585
- if (payHost)
586
- paidByHost.set(payHost, (paidByHost.get(payHost) ?? 0) + 1);
587
- let licenseId;
588
- let licenseVerified;
589
- if (result.license) {
590
- const decoded = decodeHeld(result.license);
591
- if (decoded) {
592
- licenseId = decoded.jti;
593
- // BEST-EFFORT, exactly like emitAudit: the money has ALREADY moved by here. A hosted
594
- // store (DB/KV) that throws on a transient failure must never turn a successful paid
595
- // read into an error — that would lose the content + receipt the buyer just paid for
596
- // and push the agent to pay again. Persisting the license is a caching nicety; the
597
- // paid read is the product.
598
- try {
599
- const held = await heldStore.load();
600
- // Capture the url actually paid so a later read_held re-fetches THIS link
601
- // verbatim, not a reconstructed /essays/<slug> template that 404s off-shape.
602
- held.set(decoded.slug, { ...decoded, jws: result.license, url: target });
603
- await heldStore.save(held);
604
- }
605
- catch {
606
- /* swallow — a held-license persist failure must never fail an already-paid read */
607
- }
608
- }
609
- const jwks = await fetchJwks(gateBase());
610
- if (jwks)
611
- licenseVerified = verifyAgainst(result.license, jwks);
612
- }
613
- emitAudit({
614
- slug,
615
- action: "pay",
616
- reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
617
- priceUsdc: quoted.priceUsdc,
618
- paidUsdc: result.paidUsdc,
619
- costUsdc: cost,
620
- ...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
621
- ...(licenseId ? { licenseId } : {}),
622
- agentId: policyFromConfig().agentId,
623
879
  });
624
- return structured({
625
- ok: true,
626
- content: result.content,
627
- settlementRef: result.settlementRef,
628
- paidUsdc: result.paidUsdc,
629
- // Report the total ACTUALLY authorized (what the budget was debited), not the pre-pay quote.
630
- costUsdc: result.costUsdc ?? cost,
631
- ...(licenseId ? { licenseId } : {}),
632
- ...(licenseVerified === undefined ? {} : { licenseVerified }),
633
- ...envelope(),
634
- });
635
- }));
880
+ });
636
881
  // ── naulon_read_held (free) ──────────────────────────────────────────────────
637
882
  server.registerTool("naulon_read_held", {
638
883
  title: "Re-read a source you already licensed (free)",
639
884
  description: "Re-read a source you previously paid for, FREE, using the held Citation License — no second " +
640
885
  "payment. If the license is holder-of-key bound, a fresh wallet proof-of-possession is signed " +
641
- "automatically. Returns ok:false (telling you to pay) if no live license is held for the slug.",
886
+ "automatically. Returns ok:false (telling you to pay) if no live license is held for the slug. " +
887
+ "A citation must always carry a LIVE license (jti): when the held one has expired this returns " +
888
+ "ok:false — re-read here to re-verify, or pay again. Any locally-cached copy of earlier content " +
889
+ "is your own continuity only; it carries no live license and must never be cited as a paid read.",
642
890
  inputSchema: {
643
891
  slug: z.string().min(1).describe("Source slug you previously paid for with naulon_pay_and_read."),
644
892
  },
@@ -706,6 +954,7 @@ export function buildServer(opts = {}) {
706
954
  spent: z.number().describe("Total actually spent on this run, in USDC."),
707
955
  spentSessionUsdc: z.number().describe("Total spent across the whole MCP session (after this run)."),
708
956
  remainingUsdc: z.number().describe("Budget left for this session (after this run)."),
957
+ settlement: z.object(networkOutputSchema).describe("The chain + token these tolls settled on. settlement.testnet true ⇒ play-money with no fiat value."),
709
958
  answer: z.string().describe("The grounded answer, citing the paid sources."),
710
959
  decisions: z.array(z.object({
711
960
  slug: z.string(),
@@ -726,81 +975,141 @@ export function buildServer(opts = {}) {
726
975
  log: z.array(z.string()).describe("The auditable, human-readable decision log for the run."),
727
976
  },
728
977
  annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
729
- }, async ({ topic, budgetUsdc }) => withSpendLock(async () => {
730
- const log = [];
731
- // Clamp the requested budget to what the session has left: the model can spend
732
- // less than the ceiling, never more. Passing the clamp into run() overrides its
733
- // config ceiling for this run only.
734
- const effective = round6(Math.min(budgetUsdc ?? ceilingUsdc(), remainingUsdc()));
735
- const result = await run(topic, (line) => log.push(line), {
736
- budgetUsdc: effective,
737
- policy: policyFromConfig(),
738
- ...(opts.tollgateUrl ? { tollgateUrl: opts.tollgateUrl } : {}),
739
- // Hosted path: pay from the buyer's custody-free session wallet, not the env key
740
- // (mirrors naulon_pay_and_read). Both rail signers win — run() then rail-picks PER-402
741
- // (mixed fleet), same as the pay_and_read buyer above; a single cloud signer keeps the
742
- // fleet-global routing. Absent ⇒ run() falls back to selectBuyer().
743
- ...(opts.railSigners
744
- ? { railSigners: opts.railSigners }
745
- : cloudSigner
746
- ? { signer: cloudSigner }
747
- : {}),
748
- // Same per-session isolation + PoP identity for the composite loop's held re-reads.
749
- ...(opts.heldStore ? { heldStore: opts.heldStore } : {}),
750
- ...(opts.popWallet ? { popWallet: opts.popWallet } : {}),
751
- });
752
- spentUsdc = round6(spentUsdc + result.spent);
753
- // BUY-4.4: audit each decision the run made. run() owns the decide()/pay loop
754
- // internally, so we replay its decisions here post-run — enriching a `pay` with the
755
- // settlement detail from the matching cited source. agentId is a policy tag (audit
756
- // attribution), read once for the whole run.
757
- const sourceBySlug = new Map(result.sources.map((s) => [s.slug, s]));
758
- const runAgentId = policyFromConfig().agentId;
759
- for (const d of result.decisions) {
760
- const src = d.action === "pay" ? sourceBySlug.get(d.slug) : undefined;
761
- emitAudit({
762
- slug: d.slug,
763
- action: d.action,
764
- reason: d.reason,
765
- relevance: d.relevance,
766
- priceUsdc: d.price,
767
- ...(src
768
- ? {
769
- paidUsdc: src.paidUsdc,
770
- ...(src.settlementRef ? { settlementRef: src.settlementRef } : {}),
771
- ...(src.licenseId ? { licenseId: src.licenseId } : {}),
772
- }
773
- : {}),
774
- ...(runAgentId ? { agentId: runAgentId } : {}),
978
+ }, async ({ topic, budgetUsdc }) => {
979
+ // WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
980
+ // joining the spend lock, calling discover(), or resolving gateBase() inside run(). This
981
+ // tool's outputSchema has no dedicated ok/error field, so the refusal is carried in the
982
+ // SAME already-required fields every real run reports (answer, log) — zero spend, zero
983
+ // decisions, nothing invented.
984
+ if (hostedInertSteer) {
985
+ return structured({
986
+ topic,
987
+ budget: 0,
988
+ spent: 0,
989
+ ...envelope(),
990
+ answer: hostedInertSteer,
991
+ decisions: [],
992
+ sources: [],
993
+ log: [hostedInertSteer],
775
994
  });
776
995
  }
777
- const wasClamped = budgetUsdc !== undefined && effective < budgetUsdc;
778
- return structured({
779
- topic: result.topic,
780
- budget: round6(result.budget),
781
- ...(wasClamped ? { requestedBudgetUsdc: budgetUsdc } : {}),
782
- spent: round6(result.spent),
783
- ...envelope(),
784
- answer: result.answer,
785
- decisions: result.decisions.map((d) => ({
786
- slug: d.slug,
787
- title: d.title,
788
- action: d.action,
789
- reason: d.reason,
790
- relevance: d.relevance,
791
- price: d.price,
792
- })),
793
- sources: result.sources.map((s) => ({
794
- slug: s.slug,
795
- title: s.title,
796
- content: s.content,
797
- paidUsdc: s.paidUsdc,
798
- ...(s.settlementRef ? { settlementRef: s.settlementRef } : {}),
799
- ...(s.licenseId ? { licenseId: s.licenseId } : {}),
800
- })),
801
- log,
996
+ return withSpendLock(async () => {
997
+ const log = [];
998
+ // Clamp the requested budget to what the session has left: the model can spend
999
+ // less than the ceiling, never more. Passing the clamp into run() overrides its
1000
+ // config ceiling for this run only.
1001
+ const effective = round6(Math.min(budgetUsdc ?? ceilingUsdc(), remainingUsdc()));
1002
+ const cfg = getConfig();
1003
+ const result = await run(topic, (line) => log.push(line), {
1004
+ budgetUsdc: effective,
1005
+ policy: policyFromConfig(),
1006
+ // Same per-payment payee authority as naulon_pay_and_read: run() binds each candidate's url and
1007
+ // refuses (skips) a leg paying a payTo the host does not authorize. Absent ⇒ no payee check.
1008
+ ...(opts.authorizePayee ? { authorizePayee: opts.authorizePayee } : {}),
1009
+ // B3: seed decide()'s per-host tally from the SAME session `paidByHost` the granular
1010
+ // naulon_pay_and_read path maintains (declared once above), so a perDomainCap already hit
1011
+ // via pay_and_read carries into this run instead of decide() starting every call at 0.
1012
+ // One counter for the whole session — never a second, run-scoped one.
1013
+ decideContext: { priorDomainCounts: Object.fromEntries(paidByHost) },
1014
+ // gateBase() (not opts.tollgateUrl directly): resolves the injected per-session override,
1015
+ // else env TOLLGATE_URL, else — ONLY for fleet-default discovery — FLEET_ORIGIN, so run()
1016
+ // never crashes on an eagerly-evaluated tollgateBase() call when the fleet's zero-config
1017
+ // path deliberately leaves TOLLGATE_URL unset (there is no universal fleet pay-gate).
1018
+ tollgateUrl: gateBase(),
1019
+ // WP-2 T2 (corrected — see oss-fix-architecture.md §G6): discovery happens INSIDE run(),
1020
+ // so this server can only pass the INTENT, not candidates, to policyFromConfig. run()
1021
+ // itself derives the effective allowDomains from ITS OWN discovery when this is true. Gated
1022
+ // exactly like policyForGranularPay: fleet-default discovery AND no operator-stated
1023
+ // WAYFARER_ALLOW_DOMAINS — a self-hosted CATALOG_URL is never auto-trusted.
1024
+ autoTrustDiscoveredDomains: isFleetDefaultDiscovery(cfg) && !cfg.WAYFARER_ALLOW_DOMAINS,
1025
+ // Hosted path: pay from the buyer's custody-free session wallet, not the env key
1026
+ // (mirrors naulon_pay_and_read). Both rail signers win — run() then rail-picks PER-402
1027
+ // (mixed fleet), same as the pay_and_read buyer above; a single cloud signer keeps the
1028
+ // fleet-global routing. Absent ⇒ run() falls back to selectBuyer().
1029
+ ...(opts.railSigners
1030
+ ? { railSigners: opts.railSigners }
1031
+ : cloudSigner
1032
+ ? { signer: cloudSigner }
1033
+ : {}),
1034
+ // Same per-session isolation + PoP identity for the composite loop's held re-reads.
1035
+ ...(opts.heldStore ? { heldStore: opts.heldStore } : {}),
1036
+ ...(opts.popWallet ? { popWallet: opts.popWallet } : {}),
1037
+ });
1038
+ spentUsdc = round6(spentUsdc + result.spent);
1039
+ // `result.decisions` is decide()'s PLAN, filled in BEFORE the obtain loop runs the actual
1040
+ // pays — a "pay" decision whose buyer.fetch() then fails (rejected, insufficient funds,
1041
+ // toll-moved, settlement_ambiguous, …) never gets its `action` revised, so counting it here
1042
+ // would burn the session per-domain cap for a read that never settled and $0 spent (B3
1043
+ // follow-up). `result.sources` is the outcome, not the plan: agent.ts only pushes a Source
1044
+ // for a "pay" decision AFTER `buyer.fetch()` resolves `ok: true` (see agent.ts's obtain
1045
+ // loop) — a failed pay `continue`s without ever reaching that push. So a slug present here
1046
+ // under a "pay" decision is, by construction, a CONFIRMED settlement, never a free "cache"
1047
+ // re-read (those are pushed under `d.action === "cache"`, filtered out below) and never a
1048
+ // failed attempt. Built BEFORE the increment loop so both loops below share the one Map.
1049
+ const sourceBySlug = new Map(result.sources.map((s) => [s.slug, s]));
1050
+ // B3: fold this run's actual SETTLED pays back into the session counter, resolving each
1051
+ // host via the SAME `payHostOf` decide() itself counts by (never `Decision.url` blindly,
1052
+ // never a second resolver) — so a later naulon_pay_and_read or naulon_research call in this
1053
+ // session sees them too. Gated on `sourceBySlug.has(d.slug)` (confirmed settlement), not
1054
+ // merely `d.action === "pay"` (the plan) — a pay that never settled must never burn the cap.
1055
+ for (const d of result.decisions) {
1056
+ if (d.action !== "pay" || !sourceBySlug.has(d.slug))
1057
+ continue;
1058
+ const host = payHostOf(d.url, gateBase(), d.slug);
1059
+ if (host)
1060
+ paidByHost.set(host, (paidByHost.get(host) ?? 0) + 1);
1061
+ }
1062
+ // BUY-4.4: audit each decision the run made. run() owns the decide()/pay loop
1063
+ // internally, so we replay its decisions here post-run — enriching a `pay` with the
1064
+ // settlement detail from the matching cited source. agentId is a policy tag (audit
1065
+ // attribution), read once for the whole run.
1066
+ const runAgentId = policyFromConfig().agentId;
1067
+ for (const d of result.decisions) {
1068
+ const src = d.action === "pay" ? sourceBySlug.get(d.slug) : undefined;
1069
+ emitAudit({
1070
+ slug: d.slug,
1071
+ action: d.action,
1072
+ reason: d.reason,
1073
+ relevance: d.relevance,
1074
+ priceUsdc: d.price,
1075
+ ...(src
1076
+ ? {
1077
+ paidUsdc: src.paidUsdc,
1078
+ ...(src.settlementRef ? { settlementRef: src.settlementRef } : {}),
1079
+ ...(src.licenseId ? { licenseId: src.licenseId } : {}),
1080
+ }
1081
+ : {}),
1082
+ ...(runAgentId ? { agentId: runAgentId } : {}),
1083
+ });
1084
+ }
1085
+ const wasClamped = budgetUsdc !== undefined && effective < budgetUsdc;
1086
+ return structured({
1087
+ topic: result.topic,
1088
+ budget: round6(result.budget),
1089
+ ...(wasClamped ? { requestedBudgetUsdc: budgetUsdc } : {}),
1090
+ spent: round6(result.spent),
1091
+ ...envelope(),
1092
+ answer: result.answer,
1093
+ decisions: result.decisions.map((d) => ({
1094
+ slug: d.slug,
1095
+ title: d.title,
1096
+ action: d.action,
1097
+ reason: d.reason,
1098
+ relevance: d.relevance,
1099
+ price: d.price,
1100
+ })),
1101
+ sources: result.sources.map((s) => ({
1102
+ slug: s.slug,
1103
+ title: s.title,
1104
+ content: s.content,
1105
+ paidUsdc: s.paidUsdc,
1106
+ ...(s.settlementRef ? { settlementRef: s.settlementRef } : {}),
1107
+ ...(s.licenseId ? { licenseId: s.licenseId } : {}),
1108
+ })),
1109
+ log,
1110
+ });
802
1111
  });
803
- }));
1112
+ });
804
1113
  // ── Prompts (cross-client slash commands) ───────────────────────────────────
805
1114
  // MCP prompts are the client-agnostic UX layer: any prompts-capable host (Claude
806
1115
  // Code / Desktop as `/mcp__<server>__<name>`, Cursor, VS Code, Cline, …) surfaces
@@ -809,6 +1118,18 @@ export function buildServer(opts = {}) {
809
1118
  // loop); the cloud layers its own `naulon_ask` prompt where that tool lives. Each
810
1119
  // returns a single user message that steers the host model through the tools; the
811
1120
  // model still sees every price and spends only when a paying tool is called.
1121
+ //
1122
+ // WP-2 T4 — self-healing: before this, a discovery dead-end (no source configured, or an
1123
+ // empty/failing result) left the model either stuck or — worse — printing a raw env-var name
1124
+ // ("set RSS_URL") at a non-technical user who has no idea what that means. Every prompt now
1125
+ // appends the SAME recovery instruction: on empty/failed discovery, call naulon_status and
1126
+ // relay its plain-language `nextStep` (fund the wallet it shows, or connect a token), then
1127
+ // offer to retry. One shared instruction, not three drifting copies.
1128
+ const SELF_HEAL_PROMPT_TAIL = `\n\nIf naulon_discover comes back empty, or any tool call fails, call naulon_status and relay ` +
1129
+ `its "nextStep" guidance in your own plain language (e.g. fund the wallet address it shows, or ` +
1130
+ `connect a token) — never print a raw environment-variable name to the user. Then offer to retry. ` +
1131
+ `If you fall back to your own general knowledge instead of a naulon-tolled source, say so ` +
1132
+ `explicitly and label it clearly as NOT naulon-cited.`;
812
1133
  server.registerPrompt("research", {
813
1134
  title: "Research a topic (naulon)",
814
1135
  description: "Discover naulon-tolled sources for a topic, see prices before paying, then return a grounded, cited answer within budget.",
@@ -823,7 +1144,8 @@ export function buildServer(opts = {}) {
823
1144
  `1. Call naulon_discover("${topic}") — free — to list candidate essays.\n` +
824
1145
  `2. Use naulon_appraise and naulon_quote to judge relevance and see exact prices. Nothing is spent until a paying tool runs.\n` +
825
1146
  `3. Pay only the most relevant sources with naulon_pay_and_read, or call naulon_research to run the whole discover→quote→pay→ground loop within the session budget.\n\n` +
826
- `Return a grounded answer with numbered citations and report exactly what was spent. Distinguish naulon-cited evidence from your own general knowledge.`,
1147
+ `Return a grounded answer with numbered citations and report exactly what was spent. Distinguish naulon-cited evidence from your own general knowledge.` +
1148
+ SELF_HEAL_PROMPT_TAIL,
827
1149
  },
828
1150
  },
829
1151
  ],
@@ -839,7 +1161,8 @@ export function buildServer(opts = {}) {
839
1161
  content: {
840
1162
  type: "text",
841
1163
  text: `Call naulon_discover("${topic}") and present the candidate essays as a ranked list — title, one-line summary, teaser price, and citation price. ` +
842
- `This is FREE: do not pay for anything. If a grounded answer is wanted next, use the "research" prompt or naulon_research.`,
1164
+ `This is FREE: do not pay for anything. If a grounded answer is wanted next, use the "research" prompt or naulon_research.` +
1165
+ SELF_HEAL_PROMPT_TAIL,
843
1166
  },
844
1167
  },
845
1168
  ],
@@ -857,7 +1180,8 @@ export function buildServer(opts = {}) {
857
1180
  text: `Fact-check the claim: "${claim}".\n\n` +
858
1181
  `Use naulon_discover to find relevant tolled sources, then naulon_appraise / naulon_quote (free) to see relevance and price. ` +
859
1182
  `Only if grounding needs it, pay the most relevant sources with naulon_pay_and_read (or run naulon_research) within budget.\n\n` +
860
- `State whether the claim is SUPPORTED, REFUTED, or UNVERIFIABLE, cite the paid sources by title, and report the spend. Keep naulon-cited evidence separate from your own general knowledge.`,
1183
+ `State whether the claim is SUPPORTED, REFUTED, or UNVERIFIABLE, cite the paid sources by title, and report the spend. Keep naulon-cited evidence separate from your own general knowledge.` +
1184
+ SELF_HEAL_PROMPT_TAIL,
861
1185
  },
862
1186
  },
863
1187
  ],