@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/README.md +23 -8
- package/dist/server.d.ts +36 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +580 -255
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
243
|
-
*
|
|
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
|
|
267
|
-
"catalog endpoint
|
|
268
|
-
"and summary — with no content and no payment.
|
|
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)
|
|
299
|
-
"
|
|
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
|
|
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
|
-
//
|
|
322
|
-
//
|
|
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
|
-
|
|
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
|
|
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([
|
|
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 }) =>
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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: "
|
|
561
|
-
reason: `
|
|
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
|
-
|
|
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:
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
...(
|
|
574
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
729
|
-
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
],
|