@naulon/wayfarer-mcp 0.2.0 → 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,13 +37,13 @@
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
45
45
  * handshake reports as `serverInfo.version`. */
46
- export const SERVER_VERSION = "0.2.0";
46
+ export const SERVER_VERSION = "0.2.1";
47
47
  /** Every MCP toll is a citation license — the agent gathers citable sources. */
48
48
  const KIND = "citation";
49
49
  /** The buyer's TRUE outflow for a quote: the sum of every settlement leg (author +
@@ -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,
@@ -263,10 +357,16 @@ export function buildServer(opts = {}) {
263
357
  // ── naulon_discover (free) ──────────────────────────────────────────────────
264
358
  server.registerTool("naulon_discover", {
265
359
  title: "Discover tollable sources",
266
- description: "Find candidate essays for a topic from the configured publisher (live RSS feed, a " +
267
- "catalog endpoint, or the bundled demo). Returns FREE public teasers only — slug, title, " +
268
- "and summary — with no content and no payment. Call this first to see what is available " +
269
- "before appraising, quoting, or paying.",
360
+ description: "Find candidate essays for a topic from the configured publisher (a live RSS feed or a " +
361
+ "catalog endpoint — set RSS_URL, PUBLISHER_URL, or CATALOG_URL, or use the hosted endpoint). " +
362
+ "Returns FREE public teasers only — slug, title, and summary — with no content and no payment. " +
363
+ "Call this first to see what is available before appraising, quoting, or paying. If no source " +
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.",
270
370
  inputSchema: {
271
371
  topic: z.string().min(1).describe("The research topic to find candidate sources for."),
272
372
  },
@@ -282,21 +382,113 @@ export function buildServer(opts = {}) {
282
382
  .describe("Canonical URL this source is served from (from the RSS <link> / catalog / directory). " +
283
383
  "Pass it back to naulon_quote / naulon_pay_and_read so the toll targets the real link " +
284
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."),
285
400
  }))
286
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."),
287
407
  },
288
408
  annotations: { readOnlyHint: true, openWorldHint: true },
289
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 });
290
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
+ ];
291
422
  return structured({ candidates });
292
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
+ });
293
482
  // ── naulon_appraise (free) ──────────────────────────────────────────────────
294
483
  server.registerTool("naulon_appraise", {
295
484
  title: "Appraise candidates for a topic",
296
485
  description: "Score how relevant each candidate is to the topic, from its free teaser alone — a 0..1 " +
297
486
  "relevance plus a one-line rationale. Pass the candidates you got from naulon_discover (or a " +
298
- "curated subset). This is FREE and judges the teaser text only; it does not fetch or pay for " +
299
- "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.",
300
492
  inputSchema: {
301
493
  topic: z.string().min(1).describe("The research topic to score relevance against."),
302
494
  candidates: z
@@ -304,9 +496,21 @@ export function buildServer(opts = {}) {
304
496
  slug: z.string(),
305
497
  title: z.string(),
306
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."),
307
511
  }))
308
512
  .min(1)
309
- .describe("Candidates to appraise (typically from naulon_discover)."),
513
+ .describe("Candidates to appraise — pass naulon_discover's rows through unmodified."),
310
514
  },
311
515
  outputSchema: {
312
516
  appraised: z.array(z.object({
@@ -318,8 +522,9 @@ export function buildServer(opts = {}) {
318
522
  },
319
523
  annotations: { readOnlyHint: true, openWorldHint: false },
320
524
  }, async ({ topic, candidates }) => {
321
- // Relevance is judged from the teaser text only — price plays no part, so we
322
- // 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.
323
528
  const priced = candidates.map((c) => ({ ...c, price: usdc(0) }));
324
529
  const scored = await appraise(topic, priced);
325
530
  const appraised = scored.map((a) => ({
@@ -375,14 +580,22 @@ export function buildServer(opts = {}) {
375
580
  ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session (cannot be raised from a tool)."),
376
581
  spentSessionUsdc: z.number().describe("Total already spent in this MCP session."),
377
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."),
378
584
  note: z.string().optional(),
379
585
  },
380
586
  annotations: { readOnlyHint: true, openWorldHint: true },
381
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() });
382
593
  const target = url ?? slugUrl(slug);
383
594
  // Origin + policy even on the free probe: an unauthorized url is an SSRF surface and would
384
- // return an attacker-authored price/payTo the model might then act on.
385
- 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());
386
599
  if (refusal) {
387
600
  // A refusal is neither payable nor free: signal refused (not gated:false, which the tool
388
601
  // contract defines as "free read — just fetch it" and a buyer would act on).
@@ -436,25 +649,40 @@ export function buildServer(opts = {}) {
436
649
  ok: z.boolean(),
437
650
  content: z.string().optional().describe("The paid-for content."),
438
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."),
439
653
  paidUsdc: z.number().optional().describe("The author leg paid, in USDC."),
440
654
  costUsdc: z.number().optional().describe("The true total debited from the session budget (author + any fee legs)."),
441
655
  licenseId: z.string().optional().describe("Citation License jti — cite this as proof of a paid read."),
442
656
  licenseVerified: z
443
657
  .boolean()
444
658
  .optional()
445
- .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)."),
446
661
  ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session."),
447
662
  spentSessionUsdc: z.number().describe("Total spent in this MCP session (after this call)."),
448
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."),
449
665
  error: z.string().optional(),
450
666
  errorCode: z
451
- .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
+ ])
452
680
  .optional()
453
- .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)."),
454
682
  retryable: z
455
683
  .boolean()
456
684
  .optional()
457
- .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)."),
458
686
  topUpUrl: z
459
687
  .string()
460
688
  .optional()
@@ -465,179 +693,200 @@ export function buildServer(opts = {}) {
465
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."),
466
694
  },
467
695
  annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
468
- }, async ({ slug, url }) => withSpendLock(async () => {
469
- // Quote first and gate on the SESSION BUDGET before any spend. The price is the
470
- // buyer's true total across legs; refusing here is the budget ceiling (the
471
- // on-chain insufficient-funds + toll-moved-at-pay tolerance are BUY-1.4).
472
- // The canonical url (when the model passes it) is the pay target, verbatim — one
473
- // buyer pays any publisher's URL shape (/articles/, custom domain) without a
474
- // reconstructed /essays/ template. Absent, fall back to the template.
475
- const target = url ?? slugUrl(slug);
476
- // Endpoint identity first — refused targets are never even fetched, so an off-gate url
477
- // costs nothing and reaches no attacker origin. Operator policy is applied below, once the
478
- // toll is known (the approval threshold is price-dependent).
479
- const policy = policyFromConfig();
480
- const refusal = originRefusal(target, gateBase(), policy);
481
- if (refusal) {
482
- emitAudit({ slug, action: "skip", reason: refusal, agentId: policy.agentId });
483
- return structured({ ok: false, error: refusal, errorCode: "rejected", retryable: false, ...envelope() });
484
- }
485
- const outcome = await probe(target, KIND, payerAddress());
486
- if (outcome.status !== "gated") {
487
- const failure = probeFailure(outcome, target);
488
- emitAudit({
489
- slug,
490
- action: "skip",
491
- reason: `not payable: ${failure.errorCode ?? "not_gated"} — ${failure.error ?? ""}`.trim(),
492
- agentId: policyFromConfig().agentId,
493
- });
494
- return structured({
495
- ok: false,
496
- error: failure.error ?? "not gated — no payment is required.",
497
- ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
498
- ...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
499
- ...envelope(),
500
- });
501
- }
502
- const quoted = outcome.quoted;
503
- const cost = round6(trueTotalUsdc(quoted));
504
- // Operator policy — the ONE shared evaluator `decide()` uses, so this granular path and
505
- // naulon_research enforce byte-identical rules (kill-switch, deny/allow, per-domain cap,
506
- // approval threshold). `paidCount`/`remainingUsdc` are omitted: the session envelope below
507
- // owns budget accounting with its own message, and maxPaid is a per-run planning cap.
508
- const payHost = hostnameOf(target);
509
- const verdict = spendGate({
510
- host: payHost ?? undefined,
511
- priceUsdc: cost,
512
- policy,
513
- paidForHost: payHost ? (paidByHost.get(payHost) ?? 0) : 0,
514
- });
515
- if (!verdict.ok) {
516
- const reason = verdict.action === "approve"
517
- ? `${verdict.reason} — NOT auto-paid. Nothing was spent.`
518
- : `${verdict.reason} — nothing was spent.`;
519
- emitAudit({ slug, action: verdict.action, reason, priceUsdc: quoted.priceUsdc, agentId: policy.agentId });
520
- 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() });
521
702
  }
522
- if (cost > remainingUsdc()) {
523
- emitAudit({
524
- slug,
525
- action: "skip",
526
- reason: `over budget: toll $${cost} exceeds $${remainingUsdc()} remaining (ceiling $${round6(ceilingUsdc())}) — nothing spent`,
527
- priceUsdc: quoted.priceUsdc,
528
- 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(),
529
758
  });
530
- return structured({
531
- ok: false,
532
- error: `Toll is $${cost} but only $${remainingUsdc()} remains in the session budget ` +
533
- `($${round6(ceilingUsdc())} ceiling, $${round6(spentUsdc)} already spent). The ceiling is ` +
534
- `server-configured and cannot be raised from a tool. Nothing was spent.`,
535
- ...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 }) } : {}),
536
792
  });
537
- }
538
- // Hosted path: sign each leg via the cloud session key. With BOTH rail signers (RAS-B mixed
539
- // fleet) railBuyer picks the rail from the TENANT's advertised 402 — a gateway 402 signs the
540
- // Circle envelope even under a memo-default fleet, and vice-versa. With a single injected signer
541
- // (one-network host / stdio) keep the activeNetwork() branch: a memo-LESS network (Base + every
542
- // Gateway chain) settles via gatewayBuyer, else memoBuyer. Neither reads BUYER_PRIVATE_KEY.
543
- // Default: the BYO-key buyer selectBuyer() picks (which branches the same way for the env path).
544
- const buyer = opts.railSigners
545
- ? railBuyer(opts.railSigners)
546
- : cloudSigner
547
- ? supportsMemo(activeNetwork())
548
- ? memoBuyer(cloudSigner)
549
- : gatewayBuyer(cloudSigner)
550
- : await selectBuyer();
551
- await buyer.init();
552
- // Re-quote at pay time and abort if the toll moved past the quote we gated the
553
- // budget on (BUY-1.4 toll-moved guard). The buyer pays NOTHING if it has moved.
554
- const result = await buyer.fetch(target, KIND, { maxTotalAtomic: guardCeilingAtomic(quoted) });
555
- if (!result.ok) {
556
- // A failed pay is an accountable non-spend: the agent decided to pay, the rail refused.
557
- // 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
+ }
558
855
  emitAudit({
559
856
  slug,
560
- action: "skip",
561
- reason: `payment failed: ${result.errorCode ?? result.error ?? "unknown"} — nothing spent`,
857
+ action: "pay",
858
+ reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
562
859
  priceUsdc: quoted.priceUsdc,
860
+ paidUsdc: result.paidUsdc,
861
+ costUsdc: cost,
862
+ ...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
863
+ ...(licenseId ? { licenseId } : {}),
563
864
  agentId: policyFromConfig().agentId,
564
865
  });
565
- // A hosted session-signer refusal (needs_topup / grant_expired) is actionable, not a dead
566
- // end: surface WHERE to fund/renew and HOW MUCH the toll was, so the agent points its
567
- // operator at the fix instead of re-calling a pay that can only fail again.
568
- const actionable = result.errorCode === "needs_topup" || result.errorCode === "grant_expired";
866
+ const explorerUrl = explorerTxUrl(activeNetwork(), result.settlementRef);
569
867
  return structured({
570
- ok: false,
571
- error: result.error ?? "payment failed",
572
- ...(result.errorCode ? { errorCode: result.errorCode } : {}),
573
- ...(result.retryable === undefined ? {} : { retryable: result.retryable }),
574
- ...(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 }),
575
877
  ...envelope(),
576
878
  });
577
- }
578
- // Debit the true total the buyer ACTUALLY authorized (result.costUsdc, computed by the
579
- // buyer from the quote it signed at pay time), falling back to our pre-pay `cost`. Using
580
- // costUsdc closes the gap where a pay-time re-quote within tolerance paid more than the
581
- // pre-pay quote we gated on — the ledger would otherwise understate real spend. result.paidUsdc
582
- // is only the author leg, so it would under-count a fee'd toll against the budget.
583
- spentUsdc = round6(spentUsdc + (result.costUsdc ?? cost));
584
- if (payHost)
585
- paidByHost.set(payHost, (paidByHost.get(payHost) ?? 0) + 1);
586
- let licenseId;
587
- let licenseVerified;
588
- if (result.license) {
589
- const decoded = decodeHeld(result.license);
590
- if (decoded) {
591
- licenseId = decoded.jti;
592
- // BEST-EFFORT, exactly like emitAudit: the money has ALREADY moved by here. A hosted
593
- // store (DB/KV) that throws on a transient failure must never turn a successful paid
594
- // read into an error — that would lose the content + receipt the buyer just paid for
595
- // and push the agent to pay again. Persisting the license is a caching nicety; the
596
- // paid read is the product.
597
- try {
598
- const held = await heldStore.load();
599
- // Capture the url actually paid so a later read_held re-fetches THIS link
600
- // verbatim, not a reconstructed /essays/<slug> template that 404s off-shape.
601
- held.set(decoded.slug, { ...decoded, jws: result.license, url: target });
602
- await heldStore.save(held);
603
- }
604
- catch {
605
- /* swallow — a held-license persist failure must never fail an already-paid read */
606
- }
607
- }
608
- const jwks = await fetchJwks(gateBase());
609
- if (jwks)
610
- licenseVerified = verifyAgainst(result.license, jwks);
611
- }
612
- emitAudit({
613
- slug,
614
- action: "pay",
615
- reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
616
- priceUsdc: quoted.priceUsdc,
617
- paidUsdc: result.paidUsdc,
618
- costUsdc: cost,
619
- ...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
620
- ...(licenseId ? { licenseId } : {}),
621
- agentId: policyFromConfig().agentId,
622
879
  });
623
- return structured({
624
- ok: true,
625
- content: result.content,
626
- settlementRef: result.settlementRef,
627
- paidUsdc: result.paidUsdc,
628
- // Report the total ACTUALLY authorized (what the budget was debited), not the pre-pay quote.
629
- costUsdc: result.costUsdc ?? cost,
630
- ...(licenseId ? { licenseId } : {}),
631
- ...(licenseVerified === undefined ? {} : { licenseVerified }),
632
- ...envelope(),
633
- });
634
- }));
880
+ });
635
881
  // ── naulon_read_held (free) ──────────────────────────────────────────────────
636
882
  server.registerTool("naulon_read_held", {
637
883
  title: "Re-read a source you already licensed (free)",
638
884
  description: "Re-read a source you previously paid for, FREE, using the held Citation License — no second " +
639
885
  "payment. If the license is holder-of-key bound, a fresh wallet proof-of-possession is signed " +
640
- "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.",
641
890
  inputSchema: {
642
891
  slug: z.string().min(1).describe("Source slug you previously paid for with naulon_pay_and_read."),
643
892
  },
@@ -705,6 +954,7 @@ export function buildServer(opts = {}) {
705
954
  spent: z.number().describe("Total actually spent on this run, in USDC."),
706
955
  spentSessionUsdc: z.number().describe("Total spent across the whole MCP session (after this run)."),
707
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."),
708
958
  answer: z.string().describe("The grounded answer, citing the paid sources."),
709
959
  decisions: z.array(z.object({
710
960
  slug: z.string(),
@@ -725,81 +975,141 @@ export function buildServer(opts = {}) {
725
975
  log: z.array(z.string()).describe("The auditable, human-readable decision log for the run."),
726
976
  },
727
977
  annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
728
- }, async ({ topic, budgetUsdc }) => withSpendLock(async () => {
729
- const log = [];
730
- // Clamp the requested budget to what the session has left: the model can spend
731
- // less than the ceiling, never more. Passing the clamp into run() overrides its
732
- // config ceiling for this run only.
733
- const effective = round6(Math.min(budgetUsdc ?? ceilingUsdc(), remainingUsdc()));
734
- const result = await run(topic, (line) => log.push(line), {
735
- budgetUsdc: effective,
736
- policy: policyFromConfig(),
737
- ...(opts.tollgateUrl ? { tollgateUrl: opts.tollgateUrl } : {}),
738
- // Hosted path: pay from the buyer's custody-free session wallet, not the env key
739
- // (mirrors naulon_pay_and_read). Both rail signers win — run() then rail-picks PER-402
740
- // (mixed fleet), same as the pay_and_read buyer above; a single cloud signer keeps the
741
- // fleet-global routing. Absent ⇒ run() falls back to selectBuyer().
742
- ...(opts.railSigners
743
- ? { railSigners: opts.railSigners }
744
- : cloudSigner
745
- ? { signer: cloudSigner }
746
- : {}),
747
- // Same per-session isolation + PoP identity for the composite loop's held re-reads.
748
- ...(opts.heldStore ? { heldStore: opts.heldStore } : {}),
749
- ...(opts.popWallet ? { popWallet: opts.popWallet } : {}),
750
- });
751
- spentUsdc = round6(spentUsdc + result.spent);
752
- // BUY-4.4: audit each decision the run made. run() owns the decide()/pay loop
753
- // internally, so we replay its decisions here post-run — enriching a `pay` with the
754
- // settlement detail from the matching cited source. agentId is a policy tag (audit
755
- // attribution), read once for the whole run.
756
- const sourceBySlug = new Map(result.sources.map((s) => [s.slug, s]));
757
- const runAgentId = policyFromConfig().agentId;
758
- for (const d of result.decisions) {
759
- const src = d.action === "pay" ? sourceBySlug.get(d.slug) : undefined;
760
- emitAudit({
761
- slug: d.slug,
762
- action: d.action,
763
- reason: d.reason,
764
- relevance: d.relevance,
765
- priceUsdc: d.price,
766
- ...(src
767
- ? {
768
- paidUsdc: src.paidUsdc,
769
- ...(src.settlementRef ? { settlementRef: src.settlementRef } : {}),
770
- ...(src.licenseId ? { licenseId: src.licenseId } : {}),
771
- }
772
- : {}),
773
- ...(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],
774
994
  });
775
995
  }
776
- const wasClamped = budgetUsdc !== undefined && effective < budgetUsdc;
777
- return structured({
778
- topic: result.topic,
779
- budget: round6(result.budget),
780
- ...(wasClamped ? { requestedBudgetUsdc: budgetUsdc } : {}),
781
- spent: round6(result.spent),
782
- ...envelope(),
783
- answer: result.answer,
784
- decisions: result.decisions.map((d) => ({
785
- slug: d.slug,
786
- title: d.title,
787
- action: d.action,
788
- reason: d.reason,
789
- relevance: d.relevance,
790
- price: d.price,
791
- })),
792
- sources: result.sources.map((s) => ({
793
- slug: s.slug,
794
- title: s.title,
795
- content: s.content,
796
- paidUsdc: s.paidUsdc,
797
- ...(s.settlementRef ? { settlementRef: s.settlementRef } : {}),
798
- ...(s.licenseId ? { licenseId: s.licenseId } : {}),
799
- })),
800
- 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
+ });
801
1111
  });
802
- }));
1112
+ });
803
1113
  // ── Prompts (cross-client slash commands) ───────────────────────────────────
804
1114
  // MCP prompts are the client-agnostic UX layer: any prompts-capable host (Claude
805
1115
  // Code / Desktop as `/mcp__<server>__<name>`, Cursor, VS Code, Cline, …) surfaces
@@ -808,6 +1118,18 @@ export function buildServer(opts = {}) {
808
1118
  // loop); the cloud layers its own `naulon_ask` prompt where that tool lives. Each
809
1119
  // returns a single user message that steers the host model through the tools; the
810
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.`;
811
1133
  server.registerPrompt("research", {
812
1134
  title: "Research a topic (naulon)",
813
1135
  description: "Discover naulon-tolled sources for a topic, see prices before paying, then return a grounded, cited answer within budget.",
@@ -822,7 +1144,8 @@ export function buildServer(opts = {}) {
822
1144
  `1. Call naulon_discover("${topic}") — free — to list candidate essays.\n` +
823
1145
  `2. Use naulon_appraise and naulon_quote to judge relevance and see exact prices. Nothing is spent until a paying tool runs.\n` +
824
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` +
825
- `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,
826
1149
  },
827
1150
  },
828
1151
  ],
@@ -838,7 +1161,8 @@ export function buildServer(opts = {}) {
838
1161
  content: {
839
1162
  type: "text",
840
1163
  text: `Call naulon_discover("${topic}") and present the candidate essays as a ranked list — title, one-line summary, teaser price, and citation price. ` +
841
- `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,
842
1166
  },
843
1167
  },
844
1168
  ],
@@ -856,7 +1180,8 @@ export function buildServer(opts = {}) {
856
1180
  text: `Fact-check the claim: "${claim}".\n\n` +
857
1181
  `Use naulon_discover to find relevant tolled sources, then naulon_appraise / naulon_quote (free) to see relevance and price. ` +
858
1182
  `Only if grounding needs it, pay the most relevant sources with naulon_pay_and_read (or run naulon_research) within budget.\n\n` +
859
- `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,
860
1185
  },
861
1186
  },
862
1187
  ],