@naulon/wayfarer-mcp 0.2.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 ADDED
@@ -0,0 +1,866 @@
1
+ /**
2
+ * @naulon/wayfarer-mcp — the MCP server factory.
3
+ *
4
+ * Exposes naulon's pay-per-citation pipeline to any MCP-capable LLM. The wayfarer
5
+ * brain runs IN-PROCESS here (no remote service, BYO wallet never leaves the
6
+ * machine) — the deliberate contrast to a thin client that pays a hosted brain.
7
+ *
8
+ * Tools are deliberately GRANULAR and quote-first: the host model sees prices and
9
+ * plans spend before any payment, rather than calling one black-box "research"
10
+ * verb. The §3.1 surface (BUY-1.2) is:
11
+ *
12
+ * naulon_discover free — candidate teasers for a topic
13
+ * naulon_appraise free — relevance + rationale for teasers the model holds
14
+ * naulon_quote free — the x402 402 probe: real price + terms, NO spend
15
+ * naulon_pay_and_read $ — pays, returns content + settlementRef + license jti
16
+ * naulon_read_held free — re-read a held live license (PoP-signed if cnf-bound)
17
+ * naulon_research $ — one composite that runs the whole loop for lazy clients
18
+ *
19
+ * Two deliberate shapes vs the spec's conceptual `url(...)` signatures:
20
+ * - Tools take a SLUG, never a raw URL. The server resolves it against the
21
+ * server-configured gate (TOLLGATE_URL), so a prompt-injected model can never
22
+ * redirect a payment to an attacker's endpoint — payment only ever flows to
23
+ * the configured gate. (Same "config, not tool args" principle BUY-1.3 applies
24
+ * to the budget + wallet.)
25
+ * - `kind` is pinned to "citation": the MCP's purpose is grounded, citable
26
+ * research, so every quote/pay/re-read asks for a citation license.
27
+ *
28
+ * Budget + wallet are SERVER-CONFIG, never tool args (BUY-1.3). The wallet comes
29
+ * from the env (`BUYER_PRIVATE_KEY` / the dev key) via `getWallet()`; the budget is
30
+ * a single ceiling (`WAYFARER_BUDGET_USDC`) the model cannot raise. Each server
31
+ * instance carries a SESSION SPEND ENVELOPE: every paid read debits a running total,
32
+ * the free tools report "$Y remaining", and a spend that would exceed the ceiling is
33
+ * refused (spending nothing). `naulon_research` accepts an optional `budgetUsdc` the
34
+ * server CLAMPS to what remains — the model can spend less, never more.
35
+ *
36
+ * The explicit toll-moved tolerance + validity-at-pay margin guards are BUY-1.4.
37
+ */
38
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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";
42
+ import { cloudSignerFromEnv } from "./cloud-signer.js";
43
+ export const SERVER_NAME = "naulon-wayfarer-mcp";
44
+ /** Keep in step with this package's package.json `version` — it is what the MCP
45
+ * handshake reports as `serverInfo.version`. */
46
+ export const SERVER_VERSION = "0.2.0";
47
+ /** Every MCP toll is a citation license — the agent gathers citable sources. */
48
+ const KIND = "citation";
49
+ /** The buyer's TRUE outflow for a quote: the sum of every settlement leg (author +
50
+ * any operator fee), in USDC. `priceUsdc` is only the author leg, so a fee'd toll
51
+ * costs the buyer more than the advertised price — the budget must debit the total. */
52
+ function trueTotalUsdc(quoted) {
53
+ const legs = quoted.legs;
54
+ // `> 1` MUST match quotedTotalAtomic / assemblePayment: the buyer signs the legs array
55
+ // only for 2+ legs, else it pays priceUsdc (the author leg). Gating the budget on a lone
56
+ // leg would debit a number the buyer never signs — the same ceiling/sign split-brain.
57
+ return legs && legs.length > 1 ? legs.reduce((sum, leg) => sum + Number(leg.amount), 0) / 1_000_000 : quoted.priceUsdc;
58
+ }
59
+ /** Round a USDC figure to whole micro-USDC for display, dropping float dust (e.g.
60
+ * 0.30000000000000004 → 0.3) so reported budgets read cleanly. */
61
+ function round6(usdcAmount) {
62
+ return Math.round(usdcAmount * 1_000_000) / 1_000_000;
63
+ }
64
+ /** Lowercased host INCLUDING port — endpoint identity, used for the gate origin pin
65
+ * (a different port is a different service, so it must not satisfy the pin). */
66
+ function hostOf(u) {
67
+ try {
68
+ return new URL(u).host.toLowerCase();
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** Lowercased hostname EXCLUDING port — what operator domain policy matches on, since an
75
+ * allow/deny list names domains ("evil.example"), never host:port. Also the perDomainCap key. */
76
+ function hostnameOf(u) {
77
+ try {
78
+ return new URL(u).hostname.toLowerCase();
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * BUY-1.3 — the ORIGIN PIN, now the SHARED decision. `url` is a MODEL-supplied tool arg and nothing
86
+ * used to check it, so a prompt-injected model (a poisoned discover teaser is enough) could aim
87
+ * quote/pay at an attacker origin whose 402 names the attacker's OWN payTo — and the buyer would
88
+ * sign a real USDC authorization to it.
89
+ *
90
+ * Two questions, two owners, neither reimplemented here:
91
+ * - `authorizeOrigin` — endpoint IDENTITY. host:port must equal the configured gate, UNLESS the
92
+ * operator stated a domain boundary, in which case identity steps aside (matching decide.ts,
93
+ * which this used to have drifted from).
94
+ * - `spendGate` — operator DOMAIN POLICY (kill-switch / allow / deny / caps).
95
+ *
96
+ * Both run on the FREE probe as well as the pay. That is not belt-and-braces: once an allowlist can
97
+ * replace the identity pin, identity alone no longer bounds what quote may fetch, and an empty
98
+ * allowlist (`[]` — stated, deny-by-default) would leave the probe an open SSRF surface. Whatever
99
+ * spendGate would refuse to pay for, quote must refuse to reach.
100
+ *
101
+ * `priceUsdc: 0` because there is no price yet at origin time — the kill-switch, deny-list and
102
+ * allowlist gates are all price-independent and run first. The price-dependent gates (approval
103
+ * threshold, budget, caps) stay where they are, applied on the pay path once the toll is known.
104
+ *
105
+ * Returns a human-readable refusal, or null when the target is clear to proceed.
106
+ */
107
+ function originRefusal(target, gate, policy) {
108
+ const verdict = authorizeOrigin({
109
+ target,
110
+ gate,
111
+ ...(policy.allowDomains ? { allowDomains: policy.allowDomains } : {}),
112
+ });
113
+ if (!verdict.ok)
114
+ return verdict.refusal;
115
+ const gated = spendGate({ host: hostnameOf(target) ?? undefined, priceUsdc: 0, policy });
116
+ return gated.ok ? null : gated.reason;
117
+ }
118
+ /** The pay-time spend ceiling (atomic micro-USDC, integer) the buyer must not exceed:
119
+ * the gated quote's true total plus the configured tolerance (basis points). With 0
120
+ * tolerance this is the exact quoted total, so any upward move aborts the pay. */
121
+ function guardCeilingAtomic(quoted) {
122
+ const total = quotedTotalAtomic(quoted);
123
+ const bps = BigInt(getConfig().WAYFARER_TOLL_TOLERANCE_BPS);
124
+ return (total + (total * bps) / 10000n).toString();
125
+ }
126
+ /** Wrap a structured payload as the dual content/structuredContent an MCP tool
127
+ * returns: the text block is what a non-structured client sees; structuredContent
128
+ * is the machine-readable shape matching the tool's outputSchema. */
129
+ function structured(payload) {
130
+ return {
131
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
132
+ structuredContent: payload,
133
+ };
134
+ }
135
+ /**
136
+ * Build a fresh, unconnected MCP server with the tool surface registered. The
137
+ * caller connects it to a transport (stdio for the OSS funnel; the cloud wraps it
138
+ * over an authenticated HTTP transport). A factory — not a singleton — so tests
139
+ * can stand up an isolated server per case. `opts` supplies per-session config for
140
+ * the hosted path (BUY-4); absent, every value falls back to process env.
141
+ */
142
+ export function buildServer(opts = {}) {
143
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
144
+ // ── Session spend envelope ──────────────────────────────────────────────────
145
+ // The budget is server-config, not a tool arg: the ceiling is read fresh from env
146
+ // (so it reflects deployment config), but `spentUsdc` accumulates across this
147
+ // server instance's lifetime — one stdio/HTTP session = one budget envelope. The
148
+ // 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).
152
+ const paidByHost = new Map();
153
+ // ── Spend lock ──────────────────────────────────────────────────────────────
154
+ // The budget check and its debit are separated by network I/O (probe → sign → paid GET), and an
155
+ // MCP client may issue tool calls CONCURRENTLY (a normal parallel tool_use block). Without
156
+ // serialization two pays both read the same `remainingUsdc()`, both pass, and both spend — the
157
+ // ceiling is breached by design-by-accident. The same interleaving loses held licenses, since
158
+ // the persist is a read-modify-write of one store. One lock fixes both: every spending path
159
+ // runs to completion (check → pay → debit → persist) before the next begins.
160
+ let spendChain = Promise.resolve();
161
+ function withSpendLock(fn) {
162
+ const run = spendChain.then(fn, fn);
163
+ // Never let a rejection poison the chain — the next waiter must still run.
164
+ spendChain = run.then(() => undefined, () => undefined);
165
+ return run;
166
+ }
167
+ // Where a needs_topup / grant_expired refusal points the agent to fund/renew (default: the
168
+ // portal wallet path). Server-config, resolved once — never a tool arg.
169
+ const buyerWalletUrl = opts.buyerWalletUrl ?? "/buyer/wallet";
170
+ // Hosted-wallet opt-in (BUY-2): when the cloud env is configured, tolls are signed by naulon's
171
+ // grant-checked /sign-memo BFF (the custody-free session key), so this process holds NO private
172
+ // key. Unset ⇒ the OSS default (BYO BUYER_PRIVATE_KEY via selectBuyer). Server-config, not a tool
173
+ // arg — the model can't point the signer elsewhere. Resolved ONCE per server (one session = one
174
+ // wallet), mirroring the budget envelope.
175
+ // Per-session override wins; else the env default (stdio funnel unchanged).
176
+ const cloudSigner = opts.signer ?? cloudSignerFromEnv();
177
+ // RAS-B mixed fleet: when both rail signers are injected, they wrap the SAME sealed session key,
178
+ // so either one's address is the payer identity. This is the hosted signer for identity + the
179
+ // hosted-armed checks below; the actual rail (which signer pays) is picked per-402 by railBuyer.
180
+ const hostedSigner = opts.railSigners?.memo ?? opts.railSigners?.gateway ?? cloudSigner;
181
+ // The buyer identity a 402 quote / license is bound to. On the hosted path this MUST be the
182
+ // cloud session EOA that actually pays (`hostedSigner.address`) — NOT `getWallet()`, which on a
183
+ // custody-free deploy (no BUYER_PRIVATE_KEY) is a throwaway dev key, so the quote would bind to
184
+ // an identity the buyer never pays from. BYO-key path: fall back to the env wallet, unchanged.
185
+ const payerAddress = () => hostedSigner?.address ?? getWallet().address;
186
+ // Per-session held-license store: the injected store (hosted, isolated) wins over the
187
+ // process-global file, so many buyers in one process never cross-read each other's licenses.
188
+ const heldStore = opts.heldStore ?? fileHeldStore;
189
+ // The PoP signer for a cnf-bound held re-read: the injected session-key wallet (hosted) wins
190
+ // over the env wallet. A function so the env default stays fresh when no override is supplied.
191
+ const popWallet = () => opts.popWallet ?? getWallet();
192
+ // C3 — loud warn when the hosted pay path is armed (a cloud signer is present) but no PoP
193
+ // wallet was injected: held re-reads of cnf-bound licenses will fall back to the mock dev key
194
+ // and fail, silently degrading to re-pay. Surface it instead of letting it look like a toll bug.
195
+ if (hostedSigner && !opts.popWallet && getWallet().mock) {
196
+ console.warn("[wayfarer-mcp] hosted session active (cloud signer present) but no popWallet injected — " +
197
+ "proof-of-possession re-reads will use the mock dev key and cannot satisfy a cnf-bound license. " +
198
+ "Inject BuildServerOptions.popWallet (the /sign-pop session signer) to enable free held re-reads.");
199
+ }
200
+ // This session's gate: the injected fleet tenant (BUY-4.2) wins over env TOLLGATE_URL.
201
+ // A function so the env default stays fresh per call when no override is supplied.
202
+ // Resolved server-side, never from a tool arg — the model can't aim a payment off it.
203
+ const gateBase = () => opts.tollgateUrl ?? tollgateBase();
204
+ const slugUrl = (slug) => articleUrl(gateBase(), slug);
205
+ // A non-gated quote must say WHY it isn't gated: a genuine free (2xx) read is very
206
+ // different from a 404 (wrong path — usually the /essays/<slug> fallback missing a
207
+ // /articles/<slug> publisher) or an unreachable origin. Collapsing them into a bare
208
+ // "free read" is the money-correctness footgun this note prevents.
209
+ const quoteNote = (outcome, target) => {
210
+ switch (outcome.status) {
211
+ case "free":
212
+ return "Not gated — this is a free read; no payment is required.";
213
+ case "not_found":
214
+ return (`Probed ${target} and got HTTP 404 — this is NOT a free read, the path was not found. ` +
215
+ `Pass the canonical url from naulon_discover (the /essays/<slug> fallback does not match every ` +
216
+ `publisher — many serve /articles/<slug> or a custom path).`);
217
+ case "unreachable":
218
+ return `Probed ${target} and got HTTP ${outcome.httpStatus || "no response"} — the origin/gate is unreachable, not a free read. Retry.`;
219
+ case "malformed":
220
+ return `Probed ${target} and got a 402 but ${outcome.reason} — the gate looks misconfigured; cannot quote.`;
221
+ }
222
+ };
223
+ const ceilingUsdc = () => opts.budgetUsdc ?? getConfig().WAYFARER_BUDGET_USDC;
224
+ // BUY-3 policy: an injected per-session policy (hosted path) wins; else it is
225
+ // folded from env at call time (server-config, never a tool arg — the model can't
226
+ // relax the allowlist, lift the cap, or disarm the kill-switch), over
227
+ // DEFAULT_POLICY. Enforced inside run()'s decide() step for `naulon_research`.
228
+ const policyFromConfig = () => {
229
+ if (opts.policy)
230
+ return opts.policy;
231
+ const cfg = getConfig();
232
+ return {
233
+ ...DEFAULT_POLICY,
234
+ ...(cfg.WAYFARER_ALLOW_DOMAINS ? { allowDomains: cfg.WAYFARER_ALLOW_DOMAINS } : {}),
235
+ ...(cfg.WAYFARER_DENY_DOMAINS ? { denyDomains: cfg.WAYFARER_DENY_DOMAINS } : {}),
236
+ ...(cfg.WAYFARER_PER_DOMAIN_CAP !== undefined ? { perDomainCap: cfg.WAYFARER_PER_DOMAIN_CAP } : {}),
237
+ ...(cfg.WAYFARER_APPROVAL_USDC !== undefined ? { approvalThresholdUsdc: cfg.WAYFARER_APPROVAL_USDC } : {}),
238
+ killSwitch: cfg.WAYFARER_KILL_SWITCH,
239
+ };
240
+ };
241
+ 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. */
244
+ const envelope = () => ({
245
+ ceilingUsdc: round6(ceilingUsdc()),
246
+ spentSessionUsdc: round6(spentUsdc),
247
+ remainingUsdc: remainingUsdc(),
248
+ });
249
+ // BUY-4.4: hand each buyer decision to the injected audit sink (the cloud writes it to
250
+ // its org audit plane). Best-effort — a misbehaving sink must never break a paid read,
251
+ // mirroring the cloud's own fire-and-forget AuditTrail. No-op when no sink is injected
252
+ // (the stdio funnel: the OSS path is unaudited).
253
+ const emitAudit = (event) => {
254
+ if (!opts.auditSink)
255
+ return;
256
+ try {
257
+ opts.auditSink(event);
258
+ }
259
+ catch {
260
+ /* swallow — auditing is never on the critical path of a read */
261
+ }
262
+ };
263
+ // ── naulon_discover (free) ──────────────────────────────────────────────────
264
+ server.registerTool("naulon_discover", {
265
+ 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.",
270
+ inputSchema: {
271
+ topic: z.string().min(1).describe("The research topic to find candidate sources for."),
272
+ },
273
+ outputSchema: {
274
+ candidates: z
275
+ .array(z.object({
276
+ slug: z.string().describe("Stable identifier used to quote and pay for this source."),
277
+ title: z.string(),
278
+ summary: z.string().describe("Free teaser — what the agent reads to judge relevance before paying."),
279
+ url: z
280
+ .string()
281
+ .optional()
282
+ .describe("Canonical URL this source is served from (from the RSS <link> / catalog / directory). " +
283
+ "Pass it back to naulon_quote / naulon_pay_and_read so the toll targets the real link " +
284
+ "(e.g. /articles/<slug>) instead of a reconstructed /essays/<slug> path."),
285
+ }))
286
+ .describe("Free teasers; the agent has paid for nothing at this stage."),
287
+ },
288
+ annotations: { readOnlyHint: true, openWorldHint: true },
289
+ }, async ({ topic }) => {
290
+ const candidates = await discover(topic);
291
+ return structured({ candidates });
292
+ });
293
+ // ── naulon_appraise (free) ──────────────────────────────────────────────────
294
+ server.registerTool("naulon_appraise", {
295
+ title: "Appraise candidates for a topic",
296
+ description: "Score how relevant each candidate is to the topic, from its free teaser alone — a 0..1 " +
297
+ "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.",
300
+ inputSchema: {
301
+ topic: z.string().min(1).describe("The research topic to score relevance against."),
302
+ candidates: z
303
+ .array(z.object({
304
+ slug: z.string(),
305
+ title: z.string(),
306
+ summary: z.string().describe("The free teaser to judge — title + summary, no paid content."),
307
+ }))
308
+ .min(1)
309
+ .describe("Candidates to appraise (typically from naulon_discover)."),
310
+ },
311
+ outputSchema: {
312
+ appraised: z.array(z.object({
313
+ slug: z.string(),
314
+ title: z.string(),
315
+ relevance: z.number().describe("0..1 estimate of usefulness for the topic."),
316
+ rationale: z.string().describe("One-line justification for the score."),
317
+ })),
318
+ },
319
+ annotations: { readOnlyHint: true, openWorldHint: false },
320
+ }, 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.
323
+ const priced = candidates.map((c) => ({ ...c, price: usdc(0) }));
324
+ const scored = await appraise(topic, priced);
325
+ const appraised = scored.map((a) => ({
326
+ slug: a.slug,
327
+ title: a.title,
328
+ relevance: a.relevance,
329
+ rationale: a.rationale,
330
+ }));
331
+ return structured({ appraised });
332
+ });
333
+ // ── naulon_quote (free — the killer tool) ────────────────────────────────────
334
+ server.registerTool("naulon_quote", {
335
+ title: "Quote the toll (free price probe)",
336
+ description: "Probe the real x402 toll for a source WITHOUT paying — the free 402 price check. Returns " +
337
+ "the author price, the buyer's true total (when the publisher adds extra settlement legs such " +
338
+ "as an operator fee, the total is higher than the author price), and the settlement terms. " +
339
+ "If the source is not gated, returns gated:false (it is a free read — just fetch it). If the " +
340
+ "server refuses to reach the url (off-gate identity, or operator policy such as a kill-switch " +
341
+ "or deny-list), returns refused:true with the reason in note — do NOT fetch it; it is neither " +
342
+ "payable nor free. Quote before paying so you can plan spend against real prices.",
343
+ inputSchema: {
344
+ slug: z.string().min(1).describe("Source slug from naulon_discover."),
345
+ url: z
346
+ .string()
347
+ .optional()
348
+ .describe("Canonical URL from naulon_discover (the source's real link). When present it is probed VERBATIM; " +
349
+ "absent, the slug is reconstructed to the /essays/<slug> template."),
350
+ },
351
+ outputSchema: {
352
+ gated: z
353
+ .boolean()
354
+ .optional()
355
+ .describe("True if the source requires payment; false if it is a free read. Absent on a refusal (see refused)."),
356
+ refused: z
357
+ .boolean()
358
+ .optional()
359
+ .describe("True if the server refused to reach this url — off-gate identity or operator policy (kill-switch / deny). " +
360
+ "The reason is in note; do NOT fetch it. Mutually exclusive with a gated/free result."),
361
+ priceUsdc: z.number().optional().describe("The author leg price in USDC."),
362
+ totalUsdc: z.number().optional().describe("The buyer's true total across all settlement legs — what the budget is debited."),
363
+ affordable: z
364
+ .boolean()
365
+ .optional()
366
+ .describe("True if totalUsdc fits within the remaining session budget (only meaningful when gated)."),
367
+ amountAtomic: z.string().optional().describe("The author amount in atomic units (micro-USDC string)."),
368
+ network: z.string().optional(),
369
+ asset: z.string().optional(),
370
+ payTo: z.string().optional().describe("The author payee address."),
371
+ legs: z
372
+ .array(z.object({ role: z.string(), payTo: z.string(), amount: z.string() }))
373
+ .optional()
374
+ .describe("Present only for a multi-leg toll (e.g. author + operator fee)."),
375
+ ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session (cannot be raised from a tool)."),
376
+ spentSessionUsdc: z.number().describe("Total already spent in this MCP session."),
377
+ remainingUsdc: z.number().describe("Budget left for this session — plan spend within this."),
378
+ note: z.string().optional(),
379
+ },
380
+ annotations: { readOnlyHint: true, openWorldHint: true },
381
+ }, async ({ slug, url }) => {
382
+ const target = url ?? slugUrl(slug);
383
+ // 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());
386
+ if (refusal) {
387
+ // A refusal is neither payable nor free: signal refused (not gated:false, which the tool
388
+ // contract defines as "free read — just fetch it" and a buyer would act on).
389
+ return structured({ refused: true, note: refusal, ...envelope() });
390
+ }
391
+ const outcome = await probe(target, KIND, payerAddress());
392
+ if (outcome.status !== "gated") {
393
+ return structured({
394
+ gated: false,
395
+ note: quoteNote(outcome, target),
396
+ ...envelope(),
397
+ });
398
+ }
399
+ const quoted = outcome.quoted;
400
+ const legs = quoted.legs;
401
+ const totalUsdc = round6(trueTotalUsdc(quoted));
402
+ return structured({
403
+ gated: true,
404
+ priceUsdc: quoted.priceUsdc,
405
+ totalUsdc,
406
+ affordable: totalUsdc <= remainingUsdc(),
407
+ amountAtomic: quoted.amountAtomic,
408
+ network: quoted.requirements.network,
409
+ asset: quoted.requirements.asset,
410
+ payTo: quoted.requirements.payTo,
411
+ ...(legs?.length
412
+ ? { legs: legs.map((leg) => ({ role: leg.role, payTo: leg.payTo, amount: leg.amount })) }
413
+ : {}),
414
+ ...envelope(),
415
+ });
416
+ });
417
+ // ── naulon_pay_and_read ($ — spends) ─────────────────────────────────────────
418
+ server.registerTool("naulon_pay_and_read", {
419
+ title: "Pay the toll and read the source",
420
+ description: "Pay the x402 toll for a source and return its full content, the settlement reference, and the " +
421
+ "Citation License id (jti) — the verifiable proof this read was paid for, which you cite. The " +
422
+ "license is kept so you can re-read this source FREE later with naulon_read_held. This SPENDS " +
423
+ "MONEY from the server-configured wallet, debited from the session budget. The toll is quoted " +
424
+ "first: if it would exceed the remaining session budget the call is REFUSED and spends nothing " +
425
+ "(the budget ceiling is server-configured and cannot be raised from a tool). If the source is not " +
426
+ "gated, or payment is rejected, it returns ok:false and spends nothing.",
427
+ inputSchema: {
428
+ slug: z.string().min(1).describe("Source slug from naulon_discover / naulon_quote."),
429
+ url: z
430
+ .string()
431
+ .optional()
432
+ .describe("Canonical URL from naulon_discover / naulon_quote (the source's real link). When present the toll " +
433
+ "is paid at it VERBATIM; absent, the slug is reconstructed to the /essays/<slug> template."),
434
+ },
435
+ outputSchema: {
436
+ ok: z.boolean(),
437
+ content: z.string().optional().describe("The paid-for content."),
438
+ settlementRef: z.string().optional().describe("On-chain / settlement reference for the payment."),
439
+ paidUsdc: z.number().optional().describe("The author leg paid, in USDC."),
440
+ costUsdc: z.number().optional().describe("The true total debited from the session budget (author + any fee legs)."),
441
+ licenseId: z.string().optional().describe("Citation License jti — cite this as proof of a paid read."),
442
+ licenseVerified: z
443
+ .boolean()
444
+ .optional()
445
+ .describe("True/false if the license signature was checked against the gate's JWKS; omitted if JWKS unavailable."),
446
+ ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session."),
447
+ spentSessionUsdc: z.number().describe("Total spent in this MCP session (after this call)."),
448
+ remainingUsdc: z.number().describe("Budget left for this session (after this call)."),
449
+ error: z.string().optional(),
450
+ errorCode: z
451
+ .enum(["not_gated", "not_found", "toll_moved", "insufficient_funds", "expired", "rejected", "origin_error", "needs_topup", "grant_expired"])
452
+ .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."),
454
+ retryable: z
455
+ .boolean()
456
+ .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)."),
458
+ topUpUrl: z
459
+ .string()
460
+ .optional()
461
+ .describe("Where to fund / renew the session — present only on a needs_topup / grant_expired refusal. Send the operator here; retrying the pay without acting only re-fails."),
462
+ requiredUsdc: z
463
+ .number()
464
+ .optional()
465
+ .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
+ },
467
+ 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() });
521
+ }
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,
529
+ });
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(),
536
+ });
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.
558
+ emitAudit({
559
+ slug,
560
+ action: "skip",
561
+ reason: `payment failed: ${result.errorCode ?? result.error ?? "unknown"} — nothing spent`,
562
+ priceUsdc: quoted.priceUsdc,
563
+ agentId: policyFromConfig().agentId,
564
+ });
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";
569
+ 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 } : {}),
575
+ ...envelope(),
576
+ });
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
+ });
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
+ }));
635
+ // ── naulon_read_held (free) ──────────────────────────────────────────────────
636
+ server.registerTool("naulon_read_held", {
637
+ title: "Re-read a source you already licensed (free)",
638
+ description: "Re-read a source you previously paid for, FREE, using the held Citation License — no second " +
639
+ "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.",
641
+ inputSchema: {
642
+ slug: z.string().min(1).describe("Source slug you previously paid for with naulon_pay_and_read."),
643
+ },
644
+ outputSchema: {
645
+ ok: z.boolean(),
646
+ content: z.string().optional(),
647
+ licenseId: z.string().optional(),
648
+ paidUsdc: z.number().optional().describe("Always 0 on a held re-read."),
649
+ error: z.string().optional(),
650
+ },
651
+ annotations: { readOnlyHint: true, openWorldHint: true },
652
+ }, async ({ slug }) => {
653
+ const held = await heldStore.load();
654
+ const license = held.get(slug);
655
+ if (!license) {
656
+ return structured({
657
+ ok: false,
658
+ error: "No held license for this slug — pay for it first with naulon_pay_and_read.",
659
+ });
660
+ }
661
+ if (!isLive(license, Math.floor(Date.now() / 1000))) {
662
+ return structured({ ok: false, error: "Held license has expired — pay again with naulon_pay_and_read." });
663
+ }
664
+ let proof;
665
+ if (license.pop) {
666
+ proof = (await buildPopProof(license, popWallet(), Date.now())) ?? undefined;
667
+ if (!proof) {
668
+ return structured({
669
+ ok: false,
670
+ error: "License is holder-of-key bound but the wallet cannot sign — pay again instead.",
671
+ });
672
+ }
673
+ }
674
+ // Re-read the exact url the license was paid at; fall back to the template only
675
+ // for a legacy license captured before the url was stored.
676
+ const target = license.url ?? slugUrl(slug);
677
+ const reread = await rereadWithLicense(target, KIND, license.jws, popWallet().address, proof);
678
+ if (!reread.ok) {
679
+ return structured({ ok: false, error: reread.error ?? "re-read failed" });
680
+ }
681
+ return structured({ ok: true, content: reread.content, licenseId: license.jti, paidUsdc: 0 });
682
+ });
683
+ // ── naulon_research ($ — composite) ──────────────────────────────────────────
684
+ server.registerTool("naulon_research", {
685
+ title: "Research a topic end-to-end (composite)",
686
+ description: "The lazy-client convenience: run the whole loop — discover → quote → appraise → decide → pay → " +
687
+ "ground — for a topic and return a grounded answer with cited, paid-for sources, the spend, the " +
688
+ "per-candidate decisions (with reasons), and the full decision log. Spend is bounded by the " +
689
+ "session budget. You MAY pass budgetUsdc to spend LESS on this run, but it is clamped to what " +
690
+ "remains in the session — you can lower the cap, never raise it. This SPENDS MONEY. Prefer the " +
691
+ "granular tools when you want to see prices and plan spend yourself before paying.",
692
+ inputSchema: {
693
+ topic: z.string().min(1).describe("The research topic."),
694
+ budgetUsdc: z
695
+ .number()
696
+ .positive()
697
+ .optional()
698
+ .describe("Optional cap for THIS run, in USDC. Clamped to the remaining session budget — lowers the cap only, never raises it."),
699
+ },
700
+ outputSchema: {
701
+ topic: z.string(),
702
+ budget: z.number().describe("The effective spend cap applied to this run (the clamped budget), in USDC."),
703
+ requestedBudgetUsdc: z.number().optional().describe("The budgetUsdc you asked for, if it was clamped down to the remaining session budget."),
704
+ ceilingUsdc: z.number().describe("The server-configured session ceiling (cannot be raised from a tool)."),
705
+ spent: z.number().describe("Total actually spent on this run, in USDC."),
706
+ spentSessionUsdc: z.number().describe("Total spent across the whole MCP session (after this run)."),
707
+ remainingUsdc: z.number().describe("Budget left for this session (after this run)."),
708
+ answer: z.string().describe("The grounded answer, citing the paid sources."),
709
+ decisions: z.array(z.object({
710
+ slug: z.string(),
711
+ title: z.string(),
712
+ action: z.string().describe("pay | skip | cache"),
713
+ reason: z.string(),
714
+ relevance: z.number(),
715
+ price: z.number(),
716
+ })),
717
+ sources: z.array(z.object({
718
+ slug: z.string(),
719
+ title: z.string(),
720
+ content: z.string(),
721
+ paidUsdc: z.number(),
722
+ settlementRef: z.string().optional(),
723
+ licenseId: z.string().optional(),
724
+ })),
725
+ log: z.array(z.string()).describe("The auditable, human-readable decision log for the run."),
726
+ },
727
+ 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 } : {}),
774
+ });
775
+ }
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,
801
+ });
802
+ }));
803
+ // ── Prompts (cross-client slash commands) ───────────────────────────────────
804
+ // MCP prompts are the client-agnostic UX layer: any prompts-capable host (Claude
805
+ // Code / Desktop as `/mcp__<server>__<name>`, Cursor, VS Code, Cline, …) surfaces
806
+ // these as first-class, argument-taking slash commands — zero per-user config. They
807
+ // only orchestrate the tools THIS server exposes (the free-first quote→pay→ground
808
+ // loop); the cloud layers its own `naulon_ask` prompt where that tool lives. Each
809
+ // returns a single user message that steers the host model through the tools; the
810
+ // model still sees every price and spends only when a paying tool is called.
811
+ server.registerPrompt("research", {
812
+ title: "Research a topic (naulon)",
813
+ description: "Discover naulon-tolled sources for a topic, see prices before paying, then return a grounded, cited answer within budget.",
814
+ argsSchema: { topic: z.string().describe("The topic to research.") },
815
+ }, ({ topic }) => ({
816
+ messages: [
817
+ {
818
+ role: "user",
819
+ content: {
820
+ type: "text",
821
+ text: `Research "${topic}" using naulon's tolled sources.\n\n` +
822
+ `1. Call naulon_discover("${topic}") — free — to list candidate essays.\n` +
823
+ `2. Use naulon_appraise and naulon_quote to judge relevance and see exact prices. Nothing is spent until a paying tool runs.\n` +
824
+ `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.`,
826
+ },
827
+ },
828
+ ],
829
+ }));
830
+ server.registerPrompt("discover", {
831
+ title: "Discover sources (naulon, free)",
832
+ description: "List naulon-tolled sources for a topic — free teasers only, no payment.",
833
+ argsSchema: { topic: z.string().describe("The topic to find sources for.") },
834
+ }, ({ topic }) => ({
835
+ messages: [
836
+ {
837
+ role: "user",
838
+ content: {
839
+ type: "text",
840
+ 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.`,
842
+ },
843
+ },
844
+ ],
845
+ }));
846
+ server.registerPrompt("verify", {
847
+ title: "Fact-check a claim (naulon)",
848
+ description: "Check whether a claim is supported by naulon-tolled sources, citing what it paid for.",
849
+ argsSchema: { claim: z.string().describe("The claim to fact-check.") },
850
+ }, ({ claim }) => ({
851
+ messages: [
852
+ {
853
+ role: "user",
854
+ content: {
855
+ type: "text",
856
+ text: `Fact-check the claim: "${claim}".\n\n` +
857
+ `Use naulon_discover to find relevant tolled sources, then naulon_appraise / naulon_quote (free) to see relevance and price. ` +
858
+ `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.`,
860
+ },
861
+ },
862
+ ],
863
+ }));
864
+ return server;
865
+ }
866
+ //# sourceMappingURL=server.js.map