@naulon/wayfarer-mcp 0.2.1 → 0.4.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 +16 -9
- package/dist/server.d.ts +35 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +624 -251
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
39
39
|
import { z } from "zod";
|
|
40
|
-
import { appraise, articleUrl, authorizeOrigin, buildPopProof, decodeHeld, DEFAULT_POLICY, discover, fetchJwks, fileHeldStore, gatewayBuyer, getWallet, isLive, memoBuyer, probe, probeFailure, quotedTotalAtomic, railBuyer, rereadWithLicense, run, selectBuyer, spendGate, tollgateBase, verifyAgainst, } from "@naulon/wayfarer";
|
|
41
|
-
import { activeNetwork, getConfig, supportsMemo, usdc } from "@naulon/shared";
|
|
40
|
+
import { appraise, articleUrl, authorizeOrigin, buildPopProof, decodeHeld, DEFAULT_POLICY, discover, fetchJwks, fileHeldStore, gatewayBuyer, getWallet, isLive, licenseIdentityFor, memoBuyer, payHostOf, probe, probeFailure, quotedTotalAtomic, railBuyer, rereadWithLicense, resolvedDiscoverySourceUrl, run, selectBuyer, spendGate, tollgateBase, verifyAgainst, } from "@naulon/wayfarer";
|
|
41
|
+
import { activeNetwork, explorerTxUrl, FLEET_ORIGIN, getConfig, isFleetDefaultDiscovery, supportsMemo, usdc } from "@naulon/shared";
|
|
42
42
|
import { cloudSignerFromEnv } from "./cloud-signer.js";
|
|
43
43
|
export const SERVER_NAME = "naulon-wayfarer-mcp";
|
|
44
44
|
/** Keep in step with this package's package.json `version` — it is what the MCP
|
|
@@ -61,6 +61,23 @@ function trueTotalUsdc(quoted) {
|
|
|
61
61
|
function round6(usdcAmount) {
|
|
62
62
|
return Math.round(usdcAmount * 1_000_000) / 1_000_000;
|
|
63
63
|
}
|
|
64
|
+
/** The settlement-network descriptor echoed on every spend envelope and on status —
|
|
65
|
+
* the answer to "paid in WHAT, on WHICH chain, is it real money?" that used to be
|
|
66
|
+
* absent from every tool result. `testnet:true` means no fiat value. */
|
|
67
|
+
const networkOutputSchema = {
|
|
68
|
+
network: z.string().describe("CAIP-2 network id, e.g. eip155:5042002 (Arc Testnet) — the chain the toll settles on."),
|
|
69
|
+
chainId: z.number().describe("EVM chain id."),
|
|
70
|
+
chainName: z.string().describe("Human network name, e.g. arcTestnet / base."),
|
|
71
|
+
testnet: z.boolean().describe("TRUE = testnet play-money with NO fiat value (faucet-funded); FALSE = real-money mainnet. Report this honestly — never call a testnet toll 'real money'."),
|
|
72
|
+
token: z
|
|
73
|
+
.object({
|
|
74
|
+
symbol: z.string().describe("Settlement token symbol (USDC)."),
|
|
75
|
+
address: z.string().describe("ERC-20 token contract address on this chain."),
|
|
76
|
+
decimals: z.number().describe("Token decimals (6 for USDC)."),
|
|
77
|
+
})
|
|
78
|
+
.describe("The token every toll is paid in."),
|
|
79
|
+
explorer: z.string().optional().describe("Block-explorer origin for this chain when known — settlementRef is viewable at <explorer>/tx/<ref>. Omitted when no verified explorer exists for the chain."),
|
|
80
|
+
};
|
|
64
81
|
/** Lowercased host INCLUDING port — endpoint identity, used for the gate origin pin
|
|
65
82
|
* (a different port is a different service, so it must not satisfy the pin). */
|
|
66
83
|
function hostOf(u) {
|
|
@@ -146,10 +163,24 @@ export function buildServer(opts = {}) {
|
|
|
146
163
|
// (so it reflects deployment config), but `spentUsdc` accumulates across this
|
|
147
164
|
// server instance's lifetime — one stdio/HTTP session = one budget envelope. The
|
|
148
165
|
// model sees what remains and can plan within it; it can never raise the ceiling.
|
|
149
|
-
|
|
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,
|
|
@@ -267,7 +361,19 @@ export function buildServer(opts = {}) {
|
|
|
267
361
|
"catalog endpoint — set RSS_URL, PUBLISHER_URL, or CATALOG_URL, or use the hosted endpoint). " +
|
|
268
362
|
"Returns FREE public teasers only — slug, title, and summary — with no content and no payment. " +
|
|
269
363
|
"Call this first to see what is available before appraising, quoting, or paying. If no source " +
|
|
270
|
-
"is configured it refuses with setup guidance rather than inventing sources."
|
|
364
|
+
"is configured it refuses with setup guidance rather than inventing sources. " +
|
|
365
|
+
"A candidate may carry WHY it matched: `matchedInBody` means your terms are inside the paid " +
|
|
366
|
+
"text but NOT in the title or teaser (strong — the teaser just does not show it), " +
|
|
367
|
+
"`matchedSemantic` means only that it is near your query in meaning and your terms are " +
|
|
368
|
+
"absent (weak — judge the teaser, and do not pay one that is off-topic). " +
|
|
369
|
+
"NEITHER flag set means one of two opposite things, so read it against the rest of the " +
|
|
370
|
+
"response: if any OTHER candidate here carries a flag, this source searches, and no flag is " +
|
|
371
|
+
"the STRONGEST result of all — your terms matched the title or teaser, which is why no " +
|
|
372
|
+
"body-only or semantic explanation was needed. If NO candidate in the response carries a " +
|
|
373
|
+
"flag, the source does not search (a plain RSS feed), and the absence says nothing at all — " +
|
|
374
|
+
"judge every candidate on its teaser. " +
|
|
375
|
+
"Discovery returns the best available matches, never a promise " +
|
|
376
|
+
"that any of them answers your question.",
|
|
271
377
|
inputSchema: {
|
|
272
378
|
topic: z.string().min(1).describe("The research topic to find candidate sources for."),
|
|
273
379
|
},
|
|
@@ -283,21 +389,155 @@ export function buildServer(opts = {}) {
|
|
|
283
389
|
.describe("Canonical URL this source is served from (from the RSS <link> / catalog / directory). " +
|
|
284
390
|
"Pass it back to naulon_quote / naulon_pay_and_read so the toll targets the real link " +
|
|
285
391
|
"(e.g. /articles/<slug>) instead of a reconstructed /essays/<slug> path."),
|
|
392
|
+
// Why the source matched. A directory that searches article bodies sets AT MOST one of
|
|
393
|
+
// these; an RSS source sets neither (it does not search). They must be DECLARED here or
|
|
394
|
+
// the SDK strips them from structuredContent — which is what happened until 2026-08-03:
|
|
395
|
+
// the fleet directory had been sending matchedInBody since it gained body search, and no
|
|
396
|
+
// stdio buyer ever saw it. The hosted agent had the evidence; a self-host buyer did not.
|
|
397
|
+
//
|
|
398
|
+
// NEITHER flag is the case the schema never described, and it is the ambiguous one:
|
|
399
|
+
// `body: fullHit && !headHit` (directory `src/directory/filter.ts:37`), so a candidate
|
|
400
|
+
// whose TITLE matched carries false — the strongest evidence there is, wearing the same
|
|
401
|
+
// shape as a source that never searched at all. The tool description above carries the
|
|
402
|
+
// rule that separates them (look at whether any sibling candidate carries a flag);
|
|
403
|
+
// each field repeats it, because an agent may read one describe() and not the other.
|
|
404
|
+
matchedInBody: z
|
|
405
|
+
.boolean()
|
|
406
|
+
.optional()
|
|
407
|
+
.describe("STRONG: your query's terms appear inside this source's full text but NOT in its " +
|
|
408
|
+
"title or teaser, which is why the flag is needed at all. A flag only — the body " +
|
|
409
|
+
"itself stays behind the toll. ABSENT/false does NOT mean a weak match: a title " +
|
|
410
|
+
"or teaser hit also reads false, and is stronger than this. See the tool " +
|
|
411
|
+
"description for how to tell that apart from a source that does not search."),
|
|
412
|
+
matchedSemantic: z
|
|
413
|
+
.boolean()
|
|
414
|
+
.optional()
|
|
415
|
+
.describe("WEAK: this source is close to your query in meaning, but your terms do NOT appear " +
|
|
416
|
+
"in it. Judge it on the title and teaser; a near-miss looks exactly like this. " +
|
|
417
|
+
"ABSENT/false with `matchedInBody` also absent is the both-false case — see the " +
|
|
418
|
+
"tool description; it is either the strongest match or no signal, never a weak one."),
|
|
286
419
|
}))
|
|
287
420
|
.describe("Free teasers; the agent has paid for nothing at this stage."),
|
|
421
|
+
refused: z
|
|
422
|
+
.boolean()
|
|
423
|
+
.optional()
|
|
424
|
+
.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."),
|
|
425
|
+
note: z.string().optional().describe("Present when refused — explains why, and where to go instead."),
|
|
288
426
|
},
|
|
289
427
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
290
428
|
}, async ({ topic }) => {
|
|
429
|
+
// WP-3a — a hosted ask-only mount (WP-3c) is inert-but-honest for this tool: refuse BEFORE
|
|
430
|
+
// ever calling discover() (no config read, no network) rather than throwing a raw config
|
|
431
|
+
// error or fabricating candidates.
|
|
432
|
+
if (hostedInertSteer)
|
|
433
|
+
return structured({ candidates: [], refused: true, note: hostedInertSteer });
|
|
291
434
|
const candidates = await discover(topic);
|
|
435
|
+
// WP-2 T2: remember THIS call's distinct hostnames (reset, not merged — a stale prior
|
|
436
|
+
// topic's hosts must not linger) for the granular pay path's fleet-default allowance
|
|
437
|
+
// (see policyForGranularPay). Never widens anything for a non-fleet-default deployment.
|
|
438
|
+
lastDiscoveredHosts = [
|
|
439
|
+
...new Set(candidates.map((c) => (c.url ? hostnameOf(c.url) : null)).filter((h) => h !== null)),
|
|
440
|
+
];
|
|
292
441
|
return structured({ candidates });
|
|
293
442
|
});
|
|
443
|
+
// ── naulon_status (free — the "run first" tool) ─────────────────────────────
|
|
444
|
+
server.registerTool("naulon_status", {
|
|
445
|
+
title: "Check wallet, discovery, and gate status",
|
|
446
|
+
description: "Run this FIRST, before anything else. Reports the buyer wallet address, where discovery " +
|
|
447
|
+
"is configured to look, and — plain-language — what to do next to get a paid read working " +
|
|
448
|
+
"(e.g. fund the wallet, or point discovery/the gate at your own publisher). Free, read-only, " +
|
|
449
|
+
"no network calls beyond resolving local config.",
|
|
450
|
+
inputSchema: {},
|
|
451
|
+
outputSchema: {
|
|
452
|
+
wallet: z
|
|
453
|
+
.string()
|
|
454
|
+
.optional()
|
|
455
|
+
.describe("The buyer wallet address (0x…40 hex) that pays every toll. ABSENT when this process has " +
|
|
456
|
+
"no wallet it can honestly name — a hosted mount with no session open, or a deploy with " +
|
|
457
|
+
"neither BUYER_PRIVATE_KEY nor BUYER_ADDRESS. Absent is the truthful answer, never an " +
|
|
458
|
+
"address to fund; `ready` is false and `nextStep` says how to get one."),
|
|
459
|
+
tollgate: z
|
|
460
|
+
.string()
|
|
461
|
+
.optional()
|
|
462
|
+
.describe("The single configured pay-gate (TOLLGATE_URL), when self-hosting one. Absent for the fleet " +
|
|
463
|
+
"default — there is NO universal fleet pay-gate; each discovered publisher tolls at its own origin."),
|
|
464
|
+
discovery: z.string().describe("Where naulon_discover looks for candidates (RSS_URL > PUBLISHER_URL/rss.xml > CATALOG_URL)."),
|
|
465
|
+
ready: z.boolean().describe("True once a wallet address is resolvable — does NOT mean it is funded."),
|
|
466
|
+
nextStep: z.string().describe("Plain-language guidance for what to do next, given the current config."),
|
|
467
|
+
settlement: z
|
|
468
|
+
.object(networkOutputSchema)
|
|
469
|
+
.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."),
|
|
470
|
+
},
|
|
471
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
472
|
+
}, async () => {
|
|
473
|
+
const cfg = getConfig();
|
|
474
|
+
// The address that ACTUALLY signs tolls — the injected session EOA on the hosted path, the env
|
|
475
|
+
// wallet on BYO-key. NOT `getWallet()`: on a custody-free deploy (no BUYER_PRIVATE_KEY, which is
|
|
476
|
+
// the correct hosted posture) that resolves to the throwaway MOCK dev key, whose private key is
|
|
477
|
+
// public. This tool is documented "run this FIRST" and its nextStep says "Fund this wallet (0x…)",
|
|
478
|
+
// so reporting the wrong address does not merely confuse — it directs a buyer to fund an address
|
|
479
|
+
// that never pays their tolls and that anyone can sweep. Prod 2026-08-03 reported
|
|
480
|
+
// 0xF0c9…a6F5 (the mock key) while every toll was signed by the session EOA.
|
|
481
|
+
// …and when there is NO signer to name, name none. `payerAddress()` falls back to
|
|
482
|
+
// `getWallet()`, which without BUYER_PRIVATE_KEY is the DERIVED DEV KEY — deterministic from
|
|
483
|
+
// a fixed public seed (`wallet.ts` devDemoKey), so its private key is effectively public and
|
|
484
|
+
// anything sent there is sweepable by anyone. That is the ordinary state of a hosted mount
|
|
485
|
+
// with no session open: no `railSigners` are injected, so this tool used to answer
|
|
486
|
+
// `ready: true` and "Fund this wallet (0x…)" for an in-process key that dies with the
|
|
487
|
+
// process. Measured in prod 2026-08-06: 0xF0c9…a6F5, an address in no wallet row anywhere.
|
|
488
|
+
//
|
|
489
|
+
// An operator-supplied BUYER_ADDRESS is a different case and stays reportable: the key is
|
|
490
|
+
// still the dev one (so it cannot sign), but the ADDRESS is a real wallet its owner chose,
|
|
491
|
+
// which is the legacy no-sign demo path.
|
|
492
|
+
const envWallet = getWallet();
|
|
493
|
+
const namableEnvAddress = envWallet.mock && !cfg.BUYER_ADDRESS ? undefined : envWallet.address;
|
|
494
|
+
const wallet = hostedSigner?.address ?? namableEnvAddress;
|
|
495
|
+
const fleetDefault = isFleetDefaultDiscovery(cfg);
|
|
496
|
+
// The funding phrase is network-aware: a testnet gate is faucet-funded play-money, a
|
|
497
|
+
// mainnet gate is real USDC — never tell an operator to "fund with testnet USDC" on mainnet.
|
|
498
|
+
const net = activeNetwork();
|
|
499
|
+
const fundHint = net.testnet
|
|
500
|
+
? `with testnet USDC (play-money, no fiat value) — e.g. faucet.circle.com/Arc-Testnet — or connect a token`
|
|
501
|
+
: `with real USDC on ${net.chainName} (mainnet — real money) — or connect a funded token`;
|
|
502
|
+
// Two independent facts, said in two clauses, so neither is lost when the other changes.
|
|
503
|
+
// WHO PAYS: an address to fund, or — when there is none — how to get one, with no address
|
|
504
|
+
// printed at all. A model relays whatever hex string this field contains, so printing the
|
|
505
|
+
// dev key here IS the vulnerability; saying nothing is the only safe answer.
|
|
506
|
+
const fundClause = wallet
|
|
507
|
+
? `Fund this wallet (${wallet}) ${fundHint}.`
|
|
508
|
+
: `No buyer wallet is resolvable here yet — nothing can be paid, and this server has no address ` +
|
|
509
|
+
`to give you, so do not fund one on its word. If you reached this through a hosted naulon mount, ` +
|
|
510
|
+
`open a spending session in your naulon buyer wallet and reconnect; if you run this server ` +
|
|
511
|
+
`yourself, set BUYER_PRIVATE_KEY (or BUYER_ADDRESS for a read-only demo). Whichever wallet you ` +
|
|
512
|
+
`end up with will need funding ${fundHint}. Free tools (discover, appraise, quote) work meanwhile.`;
|
|
513
|
+
// WHERE IT PAYS. Amendment (wp2-brief): never imply a single universal pay-gate exists for
|
|
514
|
+
// the fleet default — discovery is fleet-WIDE, but payment is authorized per-publisher (the
|
|
515
|
+
// trusted directory's own discovered domains, spendGate-capped — WP-2 T2), not one gate.
|
|
516
|
+
const configClause = fleetDefault
|
|
517
|
+
? `Discovery is fleet-wide (the naulon directory); payment is authorized per-publisher for whatever it ` +
|
|
518
|
+
`discovers (each publisher's own toll, capped by your spend policy) — there is no single pay-gate to configure.`
|
|
519
|
+
: `Confirm your configured gate (TOLLGATE_URL${cfg.TOLLGATE_URL ? ` = ${cfg.TOLLGATE_URL}` : " is not set yet"}) ` +
|
|
520
|
+
`is reachable${wallet ? ` and that this wallet can pay it` : ``}.`;
|
|
521
|
+
const nextStep = `${fundClause} ${configClause}`;
|
|
522
|
+
return structured({
|
|
523
|
+
...(wallet ? { wallet } : {}),
|
|
524
|
+
...(cfg.TOLLGATE_URL ? { tollgate: cfg.TOLLGATE_URL } : {}),
|
|
525
|
+
discovery: resolvedDiscoverySourceUrl(),
|
|
526
|
+
ready: Boolean(wallet),
|
|
527
|
+
nextStep,
|
|
528
|
+
settlement: networkInfo(),
|
|
529
|
+
});
|
|
530
|
+
});
|
|
294
531
|
// ── naulon_appraise (free) ──────────────────────────────────────────────────
|
|
295
532
|
server.registerTool("naulon_appraise", {
|
|
296
533
|
title: "Appraise candidates for a topic",
|
|
297
534
|
description: "Score how relevant each candidate is to the topic, from its free teaser alone — a 0..1 " +
|
|
298
535
|
"relevance plus a one-line rationale. Pass the candidates you got from naulon_discover (or a " +
|
|
299
|
-
"curated subset)
|
|
300
|
-
"
|
|
536
|
+
"curated subset), and pass them back WHOLE — `matchedInBody` / `matchedSemantic` tell the " +
|
|
537
|
+
"scorer WHY the search returned each one, and dropping them makes a body-matched source with " +
|
|
538
|
+
"a terse teaser look identical to a near-miss with a keyword-ish one. FREE: it reads the " +
|
|
539
|
+
"teaser and those flags, never paid content, and it fetches and pays for nothing. " +
|
|
540
|
+
"Use it to decide what is worth quoting and paying for.",
|
|
301
541
|
inputSchema: {
|
|
302
542
|
topic: z.string().min(1).describe("The research topic to score relevance against."),
|
|
303
543
|
candidates: z
|
|
@@ -305,9 +545,21 @@ export function buildServer(opts = {}) {
|
|
|
305
545
|
slug: z.string(),
|
|
306
546
|
title: z.string(),
|
|
307
547
|
summary: z.string().describe("The free teaser to judge — title + summary, no paid content."),
|
|
548
|
+
// Declared for the same reason naulon_discover declares them on the way out: an
|
|
549
|
+
// undeclared key is stripped by schema validation. Without these two lines the
|
|
550
|
+
// evidence survives discovery and dies one tool later, and the scorer is back to
|
|
551
|
+
// judging a summary it was never safe to judge alone.
|
|
552
|
+
matchedInBody: z
|
|
553
|
+
.boolean()
|
|
554
|
+
.optional()
|
|
555
|
+
.describe("From naulon_discover: the topic's words are inside this source's full text."),
|
|
556
|
+
matchedSemantic: z
|
|
557
|
+
.boolean()
|
|
558
|
+
.optional()
|
|
559
|
+
.describe("From naulon_discover: near the topic in meaning only — its words are absent."),
|
|
308
560
|
}))
|
|
309
561
|
.min(1)
|
|
310
|
-
.describe("Candidates to appraise
|
|
562
|
+
.describe("Candidates to appraise — pass naulon_discover's rows through unmodified."),
|
|
311
563
|
},
|
|
312
564
|
outputSchema: {
|
|
313
565
|
appraised: z.array(z.object({
|
|
@@ -319,8 +571,9 @@ export function buildServer(opts = {}) {
|
|
|
319
571
|
},
|
|
320
572
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
321
573
|
}, async ({ topic, candidates }) => {
|
|
322
|
-
//
|
|
323
|
-
//
|
|
574
|
+
// Price plays no part in relevance, so appraise at zero and drop it from the output. The
|
|
575
|
+
// spread carries the match-evidence flags through to the scorer — that is the point of
|
|
576
|
+
// declaring them above.
|
|
324
577
|
const priced = candidates.map((c) => ({ ...c, price: usdc(0) }));
|
|
325
578
|
const scored = await appraise(topic, priced);
|
|
326
579
|
const appraised = scored.map((a) => ({
|
|
@@ -376,14 +629,22 @@ export function buildServer(opts = {}) {
|
|
|
376
629
|
ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session (cannot be raised from a tool)."),
|
|
377
630
|
spentSessionUsdc: z.number().describe("Total already spent in this MCP session."),
|
|
378
631
|
remainingUsdc: z.number().describe("Budget left for this session — plan spend within this."),
|
|
632
|
+
settlement: z.object(networkOutputSchema).describe("The chain + token this session settles on. Check settlement.testnet before reporting a spend as real money."),
|
|
379
633
|
note: z.string().optional(),
|
|
380
634
|
},
|
|
381
635
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
382
636
|
}, async ({ slug, url }) => {
|
|
637
|
+
// WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
|
|
638
|
+
// resolving gateBase() or probing, using the SAME refused/note shape the origin refusal
|
|
639
|
+
// below already returns.
|
|
640
|
+
if (hostedInertSteer)
|
|
641
|
+
return structured({ refused: true, note: hostedInertSteer, ...envelope() });
|
|
383
642
|
const target = url ?? slugUrl(slug);
|
|
384
643
|
// Origin + policy even on the free probe: an unauthorized url is an SSRF surface and would
|
|
385
|
-
// return an attacker-authored price/payTo the model might then act on.
|
|
386
|
-
|
|
644
|
+
// return an attacker-authored price/payTo the model might then act on. policyForGranularPay
|
|
645
|
+
// (WP-2 T2) is the SAME fleet-default allowance naulon_pay_and_read applies below, so a
|
|
646
|
+
// fleet-discovered publisher domain can be quoted, not just paid.
|
|
647
|
+
const refusal = originRefusal(target, gateBase(), policyForGranularPay());
|
|
387
648
|
if (refusal) {
|
|
388
649
|
// A refusal is neither payable nor free: signal refused (not gated:false, which the tool
|
|
389
650
|
// contract defines as "free read — just fetch it" and a buyer would act on).
|
|
@@ -437,25 +698,40 @@ export function buildServer(opts = {}) {
|
|
|
437
698
|
ok: z.boolean(),
|
|
438
699
|
content: z.string().optional().describe("The paid-for content."),
|
|
439
700
|
settlementRef: z.string().optional().describe("On-chain / settlement reference for the payment."),
|
|
701
|
+
explorerTxUrl: z.string().optional().describe("A clickable block-explorer link for settlementRef (<explorer>/tx/<ref>), when the chain has a known explorer. Cite this so a human can verify the on-chain settlement."),
|
|
440
702
|
paidUsdc: z.number().optional().describe("The author leg paid, in USDC."),
|
|
441
703
|
costUsdc: z.number().optional().describe("The true total debited from the session budget (author + any fee legs)."),
|
|
442
704
|
licenseId: z.string().optional().describe("Citation License jti — cite this as proof of a paid read."),
|
|
443
705
|
licenseVerified: z
|
|
444
706
|
.boolean()
|
|
445
707
|
.optional()
|
|
446
|
-
.describe("True/false if the license
|
|
708
|
+
.describe("True/false if the license was checked against the gate's JWKS and canonical iss/aud identity; " +
|
|
709
|
+
"omitted if the JWKS or the gate's canonical identity is unavailable (never a fabricated match)."),
|
|
447
710
|
ceilingUsdc: z.number().describe("The server-configured spend ceiling for this session."),
|
|
448
711
|
spentSessionUsdc: z.number().describe("Total spent in this MCP session (after this call)."),
|
|
449
712
|
remainingUsdc: z.number().describe("Budget left for this session (after this call)."),
|
|
713
|
+
settlement: z.object(networkOutputSchema).describe("The chain + token this toll settled on. If settlement.testnet is true the amount is play-money with no fiat value — report it as such."),
|
|
450
714
|
error: z.string().optional(),
|
|
451
715
|
errorCode: z
|
|
452
|
-
.enum([
|
|
716
|
+
.enum([
|
|
717
|
+
"not_gated",
|
|
718
|
+
"not_found",
|
|
719
|
+
"toll_moved",
|
|
720
|
+
"insufficient_funds",
|
|
721
|
+
"expired",
|
|
722
|
+
"rejected",
|
|
723
|
+
"origin_error",
|
|
724
|
+
"needs_topup",
|
|
725
|
+
"grant_expired",
|
|
726
|
+
"settlement_ambiguous",
|
|
727
|
+
"payee_refused",
|
|
728
|
+
])
|
|
453
729
|
.optional()
|
|
454
|
-
.describe("Typed failure reason when ok:false — lets you decide whether to retry. not_found = the probed URL 404'd (pass the canonical url; it is not a free read). needs_topup = the funding session is exhausted/unset — fund it at topUpUrl. grant_expired = the funding window lapsed (funds intact) — renew at topUpUrl."),
|
|
730
|
+
.describe("Typed failure reason when ok:false — lets you decide whether to retry. not_found = the probed URL 404'd (pass the canonical url; it is not a free read). needs_topup = the funding session is exhausted/unset — fund it at topUpUrl. grant_expired = the funding window lapsed (funds intact) — renew at topUpUrl. settlement_ambiguous = the payment-signature was sent and may have settled, but reading the response failed — do NOT blind-retry (a fresh pay could double-charge); verify via settlementRef or a held license first. payee_refused = the 402 named a payTo this server does not authorize for the origin — a spend-safety stop, nothing was paid; do NOT retry (the same 402 re-refuses)."),
|
|
455
731
|
retryable: z
|
|
456
732
|
.boolean()
|
|
457
733
|
.optional()
|
|
458
|
-
.describe("True if re-quoting/retrying may succeed (toll moved, expired, rejected); false for a hard stop (insufficient funds, needs_topup, grant_expired — the wallet needs funding or renewal, not a retry)."),
|
|
734
|
+
.describe("True if re-quoting/retrying may succeed (toll moved, expired, rejected); false for a hard stop (insufficient funds, needs_topup, grant_expired — the wallet needs funding or renewal, not a retry; settlement_ambiguous — a retry could double-charge)."),
|
|
459
735
|
topUpUrl: z
|
|
460
736
|
.string()
|
|
461
737
|
.optional()
|
|
@@ -466,179 +742,200 @@ export function buildServer(opts = {}) {
|
|
|
466
742
|
.describe("The toll (true total, USDC) this call could not cover — present on a needs_topup refusal so the operator knows how much the session is short."),
|
|
467
743
|
},
|
|
468
744
|
annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
|
|
469
|
-
}, async ({ slug, url }) =>
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// reconstructed /essays/ template. Absent, fall back to the template.
|
|
476
|
-
const target = url ?? slugUrl(slug);
|
|
477
|
-
// Endpoint identity first — refused targets are never even fetched, so an off-gate url
|
|
478
|
-
// costs nothing and reaches no attacker origin. Operator policy is applied below, once the
|
|
479
|
-
// toll is known (the approval threshold is price-dependent).
|
|
480
|
-
const policy = policyFromConfig();
|
|
481
|
-
const refusal = originRefusal(target, gateBase(), policy);
|
|
482
|
-
if (refusal) {
|
|
483
|
-
emitAudit({ slug, action: "skip", reason: refusal, agentId: policy.agentId });
|
|
484
|
-
return structured({ ok: false, error: refusal, errorCode: "rejected", retryable: false, ...envelope() });
|
|
485
|
-
}
|
|
486
|
-
const outcome = await probe(target, KIND, payerAddress());
|
|
487
|
-
if (outcome.status !== "gated") {
|
|
488
|
-
const failure = probeFailure(outcome, target);
|
|
489
|
-
emitAudit({
|
|
490
|
-
slug,
|
|
491
|
-
action: "skip",
|
|
492
|
-
reason: `not payable: ${failure.errorCode ?? "not_gated"} — ${failure.error ?? ""}`.trim(),
|
|
493
|
-
agentId: policyFromConfig().agentId,
|
|
494
|
-
});
|
|
495
|
-
return structured({
|
|
496
|
-
ok: false,
|
|
497
|
-
error: failure.error ?? "not gated — no payment is required.",
|
|
498
|
-
...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
|
|
499
|
-
...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
|
|
500
|
-
...envelope(),
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
const quoted = outcome.quoted;
|
|
504
|
-
const cost = round6(trueTotalUsdc(quoted));
|
|
505
|
-
// Operator policy — the ONE shared evaluator `decide()` uses, so this granular path and
|
|
506
|
-
// naulon_research enforce byte-identical rules (kill-switch, deny/allow, per-domain cap,
|
|
507
|
-
// approval threshold). `paidCount`/`remainingUsdc` are omitted: the session envelope below
|
|
508
|
-
// owns budget accounting with its own message, and maxPaid is a per-run planning cap.
|
|
509
|
-
const payHost = hostnameOf(target);
|
|
510
|
-
const verdict = spendGate({
|
|
511
|
-
host: payHost ?? undefined,
|
|
512
|
-
priceUsdc: cost,
|
|
513
|
-
policy,
|
|
514
|
-
paidForHost: payHost ? (paidByHost.get(payHost) ?? 0) : 0,
|
|
515
|
-
});
|
|
516
|
-
if (!verdict.ok) {
|
|
517
|
-
const reason = verdict.action === "approve"
|
|
518
|
-
? `${verdict.reason} — NOT auto-paid. Nothing was spent.`
|
|
519
|
-
: `${verdict.reason} — nothing was spent.`;
|
|
520
|
-
emitAudit({ slug, action: verdict.action, reason, priceUsdc: quoted.priceUsdc, agentId: policy.agentId });
|
|
521
|
-
return structured({ ok: false, error: reason, errorCode: "rejected", retryable: false, ...envelope() });
|
|
745
|
+
}, async ({ slug, url }) => {
|
|
746
|
+
// WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
|
|
747
|
+
// joining the spend lock or resolving gateBase(), using the SAME ok:false/errorCode shape
|
|
748
|
+
// the origin refusal below already returns. Nothing is spent.
|
|
749
|
+
if (hostedInertSteer) {
|
|
750
|
+
return structured({ ok: false, error: hostedInertSteer, errorCode: "rejected", retryable: false, ...envelope() });
|
|
522
751
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
752
|
+
return withSpendLock(async () => {
|
|
753
|
+
// Quote first and gate on the SESSION BUDGET before any spend. The price is the
|
|
754
|
+
// buyer's true total across legs; refusing here is the budget ceiling (the
|
|
755
|
+
// on-chain insufficient-funds + toll-moved-at-pay tolerance are BUY-1.4).
|
|
756
|
+
// The canonical url (when the model passes it) is the pay target, verbatim — one
|
|
757
|
+
// buyer pays any publisher's URL shape (/articles/, custom domain) without a
|
|
758
|
+
// reconstructed /essays/ template. Absent, fall back to the template.
|
|
759
|
+
const target = url ?? slugUrl(slug);
|
|
760
|
+
// Endpoint identity first — refused targets are never even fetched, so an off-gate url
|
|
761
|
+
// costs nothing and reaches no attacker origin. Operator policy is applied below, once the
|
|
762
|
+
// toll is known (the approval threshold is price-dependent). policyForGranularPay (WP-2 T2)
|
|
763
|
+
// folds in the fleet-default allowance (this session's last naulon_discover hosts) so a
|
|
764
|
+
// directory-discovered publisher domain is payable, not off-gate-skipped.
|
|
765
|
+
const policy = policyForGranularPay();
|
|
766
|
+
const refusal = originRefusal(target, gateBase(), policy);
|
|
767
|
+
if (refusal) {
|
|
768
|
+
emitAudit({ slug, action: "skip", reason: refusal, agentId: policy.agentId });
|
|
769
|
+
return structured({ ok: false, error: refusal, errorCode: "rejected", retryable: false, ...envelope() });
|
|
770
|
+
}
|
|
771
|
+
const outcome = await probe(target, KIND, payerAddress());
|
|
772
|
+
if (outcome.status !== "gated") {
|
|
773
|
+
const failure = probeFailure(outcome, target);
|
|
774
|
+
emitAudit({
|
|
775
|
+
slug,
|
|
776
|
+
action: "skip",
|
|
777
|
+
reason: `not payable: ${failure.errorCode ?? "not_gated"} — ${failure.error ?? ""}`.trim(),
|
|
778
|
+
agentId: policyFromConfig().agentId,
|
|
779
|
+
});
|
|
780
|
+
return structured({
|
|
781
|
+
ok: false,
|
|
782
|
+
error: failure.error ?? "not gated — no payment is required.",
|
|
783
|
+
...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
|
|
784
|
+
...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
|
|
785
|
+
...envelope(),
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const quoted = outcome.quoted;
|
|
789
|
+
const cost = round6(trueTotalUsdc(quoted));
|
|
790
|
+
// Operator policy — the ONE shared evaluator `decide()` uses, so this granular path and
|
|
791
|
+
// naulon_research enforce byte-identical rules IN THE SAME ORDER (kill-switch, deny/allow,
|
|
792
|
+
// per-domain cap, budget, approval threshold — decide.ts's order is load-bearing). `remainingUsdc`
|
|
793
|
+
// MUST be passed: decide()'s spendGate checks budget BEFORE the approval threshold, so omitting
|
|
794
|
+
// it here let a toll that was BOTH over budget and over the approval threshold fall through to
|
|
795
|
+
// the approval branch and misreport as "needs human approval" — implying approving it would let
|
|
796
|
+
// it proceed, which it would not (the toll is over budget regardless). `paidCount` (the maxPaid
|
|
797
|
+
// gate) stays omitted on purpose — maxPaid is a PER-RUN planning cap for naulon_research's own
|
|
798
|
+
// loop, not a session-wide cap on this granular tool; passing it would newly enforce a 5-pay
|
|
799
|
+
// ceiling across the whole MCP session, which nothing here asked for.
|
|
800
|
+
const payHost = hostnameOf(target);
|
|
801
|
+
const verdict = spendGate({
|
|
802
|
+
host: payHost ?? undefined,
|
|
803
|
+
priceUsdc: cost,
|
|
804
|
+
policy,
|
|
805
|
+
paidForHost: payHost ? (paidByHost.get(payHost) ?? 0) : 0,
|
|
806
|
+
remainingUsdc: remainingUsdc(),
|
|
530
807
|
});
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
...envelope()
|
|
808
|
+
if (!verdict.ok) {
|
|
809
|
+
const reason = verdict.action === "approve"
|
|
810
|
+
? `${verdict.reason} — NOT auto-paid. Nothing was spent.`
|
|
811
|
+
: `${verdict.reason} — nothing was spent.`;
|
|
812
|
+
emitAudit({ slug, action: verdict.action, reason, priceUsdc: quoted.priceUsdc, agentId: policy.agentId });
|
|
813
|
+
return structured({ ok: false, error: reason, errorCode: "rejected", retryable: false, ...envelope() });
|
|
814
|
+
}
|
|
815
|
+
// NOTE: a redundant `cost > remainingUsdc()` re-check used to live here, AFTER spendGate. It is
|
|
816
|
+
// now unreachable by construction — spendGate is given the same `cost` and the same
|
|
817
|
+
// `remainingUsdc()` above, so if this line is reached the budget gate has already passed. Two
|
|
818
|
+
// implementations of one rule is exactly how the "misreports as approval" bug above happened;
|
|
819
|
+
// it is not re-introduced.
|
|
820
|
+
// Hosted path: sign each leg via the cloud session key. With BOTH rail signers (RAS-B mixed
|
|
821
|
+
// fleet) railBuyer picks the rail from the TENANT's advertised 402 — a gateway 402 signs the
|
|
822
|
+
// Circle envelope even under a memo-default fleet, and vice-versa. With a single injected signer
|
|
823
|
+
// (one-network host / stdio) keep the activeNetwork() branch: a memo-LESS network (Base + every
|
|
824
|
+
// Gateway chain) settles via gatewayBuyer, else memoBuyer. Neither reads BUYER_PRIVATE_KEY.
|
|
825
|
+
// Default: the BYO-key buyer selectBuyer() picks (which branches the same way for the env path).
|
|
826
|
+
const buyer = opts.railSigners
|
|
827
|
+
? railBuyer(opts.railSigners)
|
|
828
|
+
: cloudSigner
|
|
829
|
+
? supportsMemo(activeNetwork())
|
|
830
|
+
? memoBuyer(cloudSigner)
|
|
831
|
+
: gatewayBuyer(cloudSigner)
|
|
832
|
+
: await selectBuyer();
|
|
833
|
+
await buyer.init();
|
|
834
|
+
// Re-quote at pay time and abort if the toll moved past the quote we gated the
|
|
835
|
+
// budget on (BUY-1.4 toll-moved guard). The buyer pays NOTHING if it has moved.
|
|
836
|
+
const result = await buyer.fetch(target, KIND, {
|
|
837
|
+
maxTotalAtomic: guardCeilingAtomic(quoted),
|
|
838
|
+
// Bind the fetched url so the cloud host can resolve the tenant + its owner-declared payees; the
|
|
839
|
+
// guard passes each leg's payTo through and refuses (payee_refused) any the host does not authorize.
|
|
840
|
+
...(opts.authorizePayee ? { authorizePayee: (payTo) => opts.authorizePayee({ url: target, payTo }) } : {}),
|
|
537
841
|
});
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
842
|
+
if (!result.ok) {
|
|
843
|
+
// A failed pay is an accountable non-spend: the agent decided to pay, the rail refused.
|
|
844
|
+
// Audit it as a skip carrying the typed failure so the org can see the attempt + cause.
|
|
845
|
+
emitAudit({
|
|
846
|
+
slug,
|
|
847
|
+
action: "skip",
|
|
848
|
+
reason: `payment failed: ${result.errorCode ?? result.error ?? "unknown"} — nothing spent`,
|
|
849
|
+
priceUsdc: quoted.priceUsdc,
|
|
850
|
+
agentId: policyFromConfig().agentId,
|
|
851
|
+
});
|
|
852
|
+
// A hosted session-signer refusal (needs_topup / grant_expired) is actionable, not a dead
|
|
853
|
+
// end: surface WHERE to fund/renew and HOW MUCH the toll was, so the agent points its
|
|
854
|
+
// operator at the fix instead of re-calling a pay that can only fail again.
|
|
855
|
+
const actionable = result.errorCode === "needs_topup" || result.errorCode === "grant_expired";
|
|
856
|
+
return structured({
|
|
857
|
+
ok: false,
|
|
858
|
+
error: result.error ?? "payment failed",
|
|
859
|
+
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
|
860
|
+
...(result.retryable === undefined ? {} : { retryable: result.retryable }),
|
|
861
|
+
...(actionable ? { topUpUrl: buyerWalletUrl, requiredUsdc: cost } : {}),
|
|
862
|
+
...envelope(),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
// Debit the true total the buyer ACTUALLY authorized (result.costUsdc, computed by the
|
|
866
|
+
// buyer from the quote it signed at pay time), falling back to our pre-pay `cost`. Using
|
|
867
|
+
// costUsdc closes the gap where a pay-time re-quote within tolerance paid more than the
|
|
868
|
+
// pre-pay quote we gated on — the ledger would otherwise understate real spend. result.paidUsdc
|
|
869
|
+
// is only the author leg, so it would under-count a fee'd toll against the budget.
|
|
870
|
+
spentUsdc = round6(spentUsdc + (result.costUsdc ?? cost));
|
|
871
|
+
if (payHost)
|
|
872
|
+
paidByHost.set(payHost, (paidByHost.get(payHost) ?? 0) + 1);
|
|
873
|
+
let licenseId;
|
|
874
|
+
let licenseVerified;
|
|
875
|
+
if (result.license) {
|
|
876
|
+
const decoded = decodeHeld(result.license);
|
|
877
|
+
if (decoded) {
|
|
878
|
+
licenseId = decoded.jti;
|
|
879
|
+
// BEST-EFFORT, exactly like emitAudit: the money has ALREADY moved by here. A hosted
|
|
880
|
+
// store (DB/KV) that throws on a transient failure must never turn a successful paid
|
|
881
|
+
// read into an error — that would lose the content + receipt the buyer just paid for
|
|
882
|
+
// and push the agent to pay again. Persisting the license is a caching nicety; the
|
|
883
|
+
// paid read is the product.
|
|
884
|
+
try {
|
|
885
|
+
const held = await heldStore.load();
|
|
886
|
+
// Capture the url actually paid so a later read_held re-fetches THIS link
|
|
887
|
+
// verbatim, not a reconstructed /essays/<slug> template that 404s off-shape.
|
|
888
|
+
held.set(decoded.slug, { ...decoded, jws: result.license, url: target });
|
|
889
|
+
await heldStore.save(held);
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
/* swallow — a held-license persist failure must never fail an already-paid read */
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const jwks = await fetchJwks(gateBase());
|
|
896
|
+
// The canonical identity of the gate this read just settled into — derived from
|
|
897
|
+
// `target` (the paid url), never from the token's own claims (A4). Cosmetic/log
|
|
898
|
+
// field only: it does not gate the payment or the license persist above, both of
|
|
899
|
+
// which already happened by this point regardless of licenseVerified's value.
|
|
900
|
+
const identity = licenseIdentityFor(target);
|
|
901
|
+
if (jwks && identity)
|
|
902
|
+
licenseVerified = verifyAgainst(result.license, jwks, { issuer: identity, audience: identity });
|
|
903
|
+
}
|
|
559
904
|
emitAudit({
|
|
560
905
|
slug,
|
|
561
|
-
action: "
|
|
562
|
-
reason: `
|
|
906
|
+
action: "pay",
|
|
907
|
+
reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
|
|
563
908
|
priceUsdc: quoted.priceUsdc,
|
|
909
|
+
paidUsdc: result.paidUsdc,
|
|
910
|
+
costUsdc: cost,
|
|
911
|
+
...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
|
|
912
|
+
...(licenseId ? { licenseId } : {}),
|
|
564
913
|
agentId: policyFromConfig().agentId,
|
|
565
914
|
});
|
|
566
|
-
|
|
567
|
-
// end: surface WHERE to fund/renew and HOW MUCH the toll was, so the agent points its
|
|
568
|
-
// operator at the fix instead of re-calling a pay that can only fail again.
|
|
569
|
-
const actionable = result.errorCode === "needs_topup" || result.errorCode === "grant_expired";
|
|
915
|
+
const explorerUrl = explorerTxUrl(activeNetwork(), result.settlementRef);
|
|
570
916
|
return structured({
|
|
571
|
-
ok:
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
...(
|
|
575
|
-
|
|
917
|
+
ok: true,
|
|
918
|
+
content: result.content,
|
|
919
|
+
settlementRef: result.settlementRef,
|
|
920
|
+
...(explorerUrl ? { explorerTxUrl: explorerUrl } : {}),
|
|
921
|
+
paidUsdc: result.paidUsdc,
|
|
922
|
+
// Report the total ACTUALLY authorized (what the budget was debited), not the pre-pay quote.
|
|
923
|
+
costUsdc: result.costUsdc ?? cost,
|
|
924
|
+
...(licenseId ? { licenseId } : {}),
|
|
925
|
+
...(licenseVerified === undefined ? {} : { licenseVerified }),
|
|
576
926
|
...envelope(),
|
|
577
927
|
});
|
|
578
|
-
}
|
|
579
|
-
// Debit the true total the buyer ACTUALLY authorized (result.costUsdc, computed by the
|
|
580
|
-
// buyer from the quote it signed at pay time), falling back to our pre-pay `cost`. Using
|
|
581
|
-
// costUsdc closes the gap where a pay-time re-quote within tolerance paid more than the
|
|
582
|
-
// pre-pay quote we gated on — the ledger would otherwise understate real spend. result.paidUsdc
|
|
583
|
-
// is only the author leg, so it would under-count a fee'd toll against the budget.
|
|
584
|
-
spentUsdc = round6(spentUsdc + (result.costUsdc ?? cost));
|
|
585
|
-
if (payHost)
|
|
586
|
-
paidByHost.set(payHost, (paidByHost.get(payHost) ?? 0) + 1);
|
|
587
|
-
let licenseId;
|
|
588
|
-
let licenseVerified;
|
|
589
|
-
if (result.license) {
|
|
590
|
-
const decoded = decodeHeld(result.license);
|
|
591
|
-
if (decoded) {
|
|
592
|
-
licenseId = decoded.jti;
|
|
593
|
-
// BEST-EFFORT, exactly like emitAudit: the money has ALREADY moved by here. A hosted
|
|
594
|
-
// store (DB/KV) that throws on a transient failure must never turn a successful paid
|
|
595
|
-
// read into an error — that would lose the content + receipt the buyer just paid for
|
|
596
|
-
// and push the agent to pay again. Persisting the license is a caching nicety; the
|
|
597
|
-
// paid read is the product.
|
|
598
|
-
try {
|
|
599
|
-
const held = await heldStore.load();
|
|
600
|
-
// Capture the url actually paid so a later read_held re-fetches THIS link
|
|
601
|
-
// verbatim, not a reconstructed /essays/<slug> template that 404s off-shape.
|
|
602
|
-
held.set(decoded.slug, { ...decoded, jws: result.license, url: target });
|
|
603
|
-
await heldStore.save(held);
|
|
604
|
-
}
|
|
605
|
-
catch {
|
|
606
|
-
/* swallow — a held-license persist failure must never fail an already-paid read */
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
const jwks = await fetchJwks(gateBase());
|
|
610
|
-
if (jwks)
|
|
611
|
-
licenseVerified = verifyAgainst(result.license, jwks);
|
|
612
|
-
}
|
|
613
|
-
emitAudit({
|
|
614
|
-
slug,
|
|
615
|
-
action: "pay",
|
|
616
|
-
reason: `paid $${round6(result.paidUsdc ?? 0)} (true total $${cost})`,
|
|
617
|
-
priceUsdc: quoted.priceUsdc,
|
|
618
|
-
paidUsdc: result.paidUsdc,
|
|
619
|
-
costUsdc: cost,
|
|
620
|
-
...(result.settlementRef ? { settlementRef: result.settlementRef } : {}),
|
|
621
|
-
...(licenseId ? { licenseId } : {}),
|
|
622
|
-
agentId: policyFromConfig().agentId,
|
|
623
928
|
});
|
|
624
|
-
|
|
625
|
-
ok: true,
|
|
626
|
-
content: result.content,
|
|
627
|
-
settlementRef: result.settlementRef,
|
|
628
|
-
paidUsdc: result.paidUsdc,
|
|
629
|
-
// Report the total ACTUALLY authorized (what the budget was debited), not the pre-pay quote.
|
|
630
|
-
costUsdc: result.costUsdc ?? cost,
|
|
631
|
-
...(licenseId ? { licenseId } : {}),
|
|
632
|
-
...(licenseVerified === undefined ? {} : { licenseVerified }),
|
|
633
|
-
...envelope(),
|
|
634
|
-
});
|
|
635
|
-
}));
|
|
929
|
+
});
|
|
636
930
|
// ── naulon_read_held (free) ──────────────────────────────────────────────────
|
|
637
931
|
server.registerTool("naulon_read_held", {
|
|
638
932
|
title: "Re-read a source you already licensed (free)",
|
|
639
933
|
description: "Re-read a source you previously paid for, FREE, using the held Citation License — no second " +
|
|
640
934
|
"payment. If the license is holder-of-key bound, a fresh wallet proof-of-possession is signed " +
|
|
641
|
-
"automatically. Returns ok:false (telling you to pay) if no live license is held for the slug."
|
|
935
|
+
"automatically. Returns ok:false (telling you to pay) if no live license is held for the slug. " +
|
|
936
|
+
"A citation must always carry a LIVE license (jti): when the held one has expired this returns " +
|
|
937
|
+
"ok:false — re-read here to re-verify, or pay again. Any locally-cached copy of earlier content " +
|
|
938
|
+
"is your own continuity only; it carries no live license and must never be cited as a paid read.",
|
|
642
939
|
inputSchema: {
|
|
643
940
|
slug: z.string().min(1).describe("Source slug you previously paid for with naulon_pay_and_read."),
|
|
644
941
|
},
|
|
@@ -706,6 +1003,7 @@ export function buildServer(opts = {}) {
|
|
|
706
1003
|
spent: z.number().describe("Total actually spent on this run, in USDC."),
|
|
707
1004
|
spentSessionUsdc: z.number().describe("Total spent across the whole MCP session (after this run)."),
|
|
708
1005
|
remainingUsdc: z.number().describe("Budget left for this session (after this run)."),
|
|
1006
|
+
settlement: z.object(networkOutputSchema).describe("The chain + token these tolls settled on. settlement.testnet true ⇒ play-money with no fiat value."),
|
|
709
1007
|
answer: z.string().describe("The grounded answer, citing the paid sources."),
|
|
710
1008
|
decisions: z.array(z.object({
|
|
711
1009
|
slug: z.string(),
|
|
@@ -726,81 +1024,141 @@ export function buildServer(opts = {}) {
|
|
|
726
1024
|
log: z.array(z.string()).describe("The auditable, human-readable decision log for the run."),
|
|
727
1025
|
},
|
|
728
1026
|
annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
|
|
729
|
-
}, async ({ topic, budgetUsdc }) =>
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
: cloudSigner
|
|
746
|
-
? { signer: cloudSigner }
|
|
747
|
-
: {}),
|
|
748
|
-
// Same per-session isolation + PoP identity for the composite loop's held re-reads.
|
|
749
|
-
...(opts.heldStore ? { heldStore: opts.heldStore } : {}),
|
|
750
|
-
...(opts.popWallet ? { popWallet: opts.popWallet } : {}),
|
|
751
|
-
});
|
|
752
|
-
spentUsdc = round6(spentUsdc + result.spent);
|
|
753
|
-
// BUY-4.4: audit each decision the run made. run() owns the decide()/pay loop
|
|
754
|
-
// internally, so we replay its decisions here post-run — enriching a `pay` with the
|
|
755
|
-
// settlement detail from the matching cited source. agentId is a policy tag (audit
|
|
756
|
-
// attribution), read once for the whole run.
|
|
757
|
-
const sourceBySlug = new Map(result.sources.map((s) => [s.slug, s]));
|
|
758
|
-
const runAgentId = policyFromConfig().agentId;
|
|
759
|
-
for (const d of result.decisions) {
|
|
760
|
-
const src = d.action === "pay" ? sourceBySlug.get(d.slug) : undefined;
|
|
761
|
-
emitAudit({
|
|
762
|
-
slug: d.slug,
|
|
763
|
-
action: d.action,
|
|
764
|
-
reason: d.reason,
|
|
765
|
-
relevance: d.relevance,
|
|
766
|
-
priceUsdc: d.price,
|
|
767
|
-
...(src
|
|
768
|
-
? {
|
|
769
|
-
paidUsdc: src.paidUsdc,
|
|
770
|
-
...(src.settlementRef ? { settlementRef: src.settlementRef } : {}),
|
|
771
|
-
...(src.licenseId ? { licenseId: src.licenseId } : {}),
|
|
772
|
-
}
|
|
773
|
-
: {}),
|
|
774
|
-
...(runAgentId ? { agentId: runAgentId } : {}),
|
|
1027
|
+
}, async ({ topic, budgetUsdc }) => {
|
|
1028
|
+
// WP-3a — a hosted ask-only mount is inert-but-honest for this tool: refuse BEFORE ever
|
|
1029
|
+
// joining the spend lock, calling discover(), or resolving gateBase() inside run(). This
|
|
1030
|
+
// tool's outputSchema has no dedicated ok/error field, so the refusal is carried in the
|
|
1031
|
+
// SAME already-required fields every real run reports (answer, log) — zero spend, zero
|
|
1032
|
+
// decisions, nothing invented.
|
|
1033
|
+
if (hostedInertSteer) {
|
|
1034
|
+
return structured({
|
|
1035
|
+
topic,
|
|
1036
|
+
budget: 0,
|
|
1037
|
+
spent: 0,
|
|
1038
|
+
...envelope(),
|
|
1039
|
+
answer: hostedInertSteer,
|
|
1040
|
+
decisions: [],
|
|
1041
|
+
sources: [],
|
|
1042
|
+
log: [hostedInertSteer],
|
|
775
1043
|
});
|
|
776
1044
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1045
|
+
return withSpendLock(async () => {
|
|
1046
|
+
const log = [];
|
|
1047
|
+
// Clamp the requested budget to what the session has left: the model can spend
|
|
1048
|
+
// less than the ceiling, never more. Passing the clamp into run() overrides its
|
|
1049
|
+
// config ceiling for this run only.
|
|
1050
|
+
const effective = round6(Math.min(budgetUsdc ?? ceilingUsdc(), remainingUsdc()));
|
|
1051
|
+
const cfg = getConfig();
|
|
1052
|
+
const result = await run(topic, (line) => log.push(line), {
|
|
1053
|
+
budgetUsdc: effective,
|
|
1054
|
+
policy: policyFromConfig(),
|
|
1055
|
+
// Same per-payment payee authority as naulon_pay_and_read: run() binds each candidate's url and
|
|
1056
|
+
// refuses (skips) a leg paying a payTo the host does not authorize. Absent ⇒ no payee check.
|
|
1057
|
+
...(opts.authorizePayee ? { authorizePayee: opts.authorizePayee } : {}),
|
|
1058
|
+
// B3: seed decide()'s per-host tally from the SAME session `paidByHost` the granular
|
|
1059
|
+
// naulon_pay_and_read path maintains (declared once above), so a perDomainCap already hit
|
|
1060
|
+
// via pay_and_read carries into this run instead of decide() starting every call at 0.
|
|
1061
|
+
// One counter for the whole session — never a second, run-scoped one.
|
|
1062
|
+
decideContext: { priorDomainCounts: Object.fromEntries(paidByHost) },
|
|
1063
|
+
// gateBase() (not opts.tollgateUrl directly): resolves the injected per-session override,
|
|
1064
|
+
// else env TOLLGATE_URL, else — ONLY for fleet-default discovery — FLEET_ORIGIN, so run()
|
|
1065
|
+
// never crashes on an eagerly-evaluated tollgateBase() call when the fleet's zero-config
|
|
1066
|
+
// path deliberately leaves TOLLGATE_URL unset (there is no universal fleet pay-gate).
|
|
1067
|
+
tollgateUrl: gateBase(),
|
|
1068
|
+
// WP-2 T2 (corrected — see oss-fix-architecture.md §G6): discovery happens INSIDE run(),
|
|
1069
|
+
// so this server can only pass the INTENT, not candidates, to policyFromConfig. run()
|
|
1070
|
+
// itself derives the effective allowDomains from ITS OWN discovery when this is true. Gated
|
|
1071
|
+
// exactly like policyForGranularPay: fleet-default discovery AND no operator-stated
|
|
1072
|
+
// WAYFARER_ALLOW_DOMAINS — a self-hosted CATALOG_URL is never auto-trusted.
|
|
1073
|
+
autoTrustDiscoveredDomains: isFleetDefaultDiscovery(cfg) && !cfg.WAYFARER_ALLOW_DOMAINS,
|
|
1074
|
+
// Hosted path: pay from the buyer's custody-free session wallet, not the env key
|
|
1075
|
+
// (mirrors naulon_pay_and_read). Both rail signers win — run() then rail-picks PER-402
|
|
1076
|
+
// (mixed fleet), same as the pay_and_read buyer above; a single cloud signer keeps the
|
|
1077
|
+
// fleet-global routing. Absent ⇒ run() falls back to selectBuyer().
|
|
1078
|
+
...(opts.railSigners
|
|
1079
|
+
? { railSigners: opts.railSigners }
|
|
1080
|
+
: cloudSigner
|
|
1081
|
+
? { signer: cloudSigner }
|
|
1082
|
+
: {}),
|
|
1083
|
+
// Same per-session isolation + PoP identity for the composite loop's held re-reads.
|
|
1084
|
+
...(opts.heldStore ? { heldStore: opts.heldStore } : {}),
|
|
1085
|
+
...(opts.popWallet ? { popWallet: opts.popWallet } : {}),
|
|
1086
|
+
});
|
|
1087
|
+
spentUsdc = round6(spentUsdc + result.spent);
|
|
1088
|
+
// `result.decisions` is decide()'s PLAN, filled in BEFORE the obtain loop runs the actual
|
|
1089
|
+
// pays — a "pay" decision whose buyer.fetch() then fails (rejected, insufficient funds,
|
|
1090
|
+
// toll-moved, settlement_ambiguous, …) never gets its `action` revised, so counting it here
|
|
1091
|
+
// would burn the session per-domain cap for a read that never settled and $0 spent (B3
|
|
1092
|
+
// follow-up). `result.sources` is the outcome, not the plan: agent.ts only pushes a Source
|
|
1093
|
+
// for a "pay" decision AFTER `buyer.fetch()` resolves `ok: true` (see agent.ts's obtain
|
|
1094
|
+
// loop) — a failed pay `continue`s without ever reaching that push. So a slug present here
|
|
1095
|
+
// under a "pay" decision is, by construction, a CONFIRMED settlement, never a free "cache"
|
|
1096
|
+
// re-read (those are pushed under `d.action === "cache"`, filtered out below) and never a
|
|
1097
|
+
// failed attempt. Built BEFORE the increment loop so both loops below share the one Map.
|
|
1098
|
+
const sourceBySlug = new Map(result.sources.map((s) => [s.slug, s]));
|
|
1099
|
+
// B3: fold this run's actual SETTLED pays back into the session counter, resolving each
|
|
1100
|
+
// host via the SAME `payHostOf` decide() itself counts by (never `Decision.url` blindly,
|
|
1101
|
+
// never a second resolver) — so a later naulon_pay_and_read or naulon_research call in this
|
|
1102
|
+
// session sees them too. Gated on `sourceBySlug.has(d.slug)` (confirmed settlement), not
|
|
1103
|
+
// merely `d.action === "pay"` (the plan) — a pay that never settled must never burn the cap.
|
|
1104
|
+
for (const d of result.decisions) {
|
|
1105
|
+
if (d.action !== "pay" || !sourceBySlug.has(d.slug))
|
|
1106
|
+
continue;
|
|
1107
|
+
const host = payHostOf(d.url, gateBase(), d.slug);
|
|
1108
|
+
if (host)
|
|
1109
|
+
paidByHost.set(host, (paidByHost.get(host) ?? 0) + 1);
|
|
1110
|
+
}
|
|
1111
|
+
// BUY-4.4: audit each decision the run made. run() owns the decide()/pay loop
|
|
1112
|
+
// internally, so we replay its decisions here post-run — enriching a `pay` with the
|
|
1113
|
+
// settlement detail from the matching cited source. agentId is a policy tag (audit
|
|
1114
|
+
// attribution), read once for the whole run.
|
|
1115
|
+
const runAgentId = policyFromConfig().agentId;
|
|
1116
|
+
for (const d of result.decisions) {
|
|
1117
|
+
const src = d.action === "pay" ? sourceBySlug.get(d.slug) : undefined;
|
|
1118
|
+
emitAudit({
|
|
1119
|
+
slug: d.slug,
|
|
1120
|
+
action: d.action,
|
|
1121
|
+
reason: d.reason,
|
|
1122
|
+
relevance: d.relevance,
|
|
1123
|
+
priceUsdc: d.price,
|
|
1124
|
+
...(src
|
|
1125
|
+
? {
|
|
1126
|
+
paidUsdc: src.paidUsdc,
|
|
1127
|
+
...(src.settlementRef ? { settlementRef: src.settlementRef } : {}),
|
|
1128
|
+
...(src.licenseId ? { licenseId: src.licenseId } : {}),
|
|
1129
|
+
}
|
|
1130
|
+
: {}),
|
|
1131
|
+
...(runAgentId ? { agentId: runAgentId } : {}),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
const wasClamped = budgetUsdc !== undefined && effective < budgetUsdc;
|
|
1135
|
+
return structured({
|
|
1136
|
+
topic: result.topic,
|
|
1137
|
+
budget: round6(result.budget),
|
|
1138
|
+
...(wasClamped ? { requestedBudgetUsdc: budgetUsdc } : {}),
|
|
1139
|
+
spent: round6(result.spent),
|
|
1140
|
+
...envelope(),
|
|
1141
|
+
answer: result.answer,
|
|
1142
|
+
decisions: result.decisions.map((d) => ({
|
|
1143
|
+
slug: d.slug,
|
|
1144
|
+
title: d.title,
|
|
1145
|
+
action: d.action,
|
|
1146
|
+
reason: d.reason,
|
|
1147
|
+
relevance: d.relevance,
|
|
1148
|
+
price: d.price,
|
|
1149
|
+
})),
|
|
1150
|
+
sources: result.sources.map((s) => ({
|
|
1151
|
+
slug: s.slug,
|
|
1152
|
+
title: s.title,
|
|
1153
|
+
content: s.content,
|
|
1154
|
+
paidUsdc: s.paidUsdc,
|
|
1155
|
+
...(s.settlementRef ? { settlementRef: s.settlementRef } : {}),
|
|
1156
|
+
...(s.licenseId ? { licenseId: s.licenseId } : {}),
|
|
1157
|
+
})),
|
|
1158
|
+
log,
|
|
1159
|
+
});
|
|
802
1160
|
});
|
|
803
|
-
})
|
|
1161
|
+
});
|
|
804
1162
|
// ── Prompts (cross-client slash commands) ───────────────────────────────────
|
|
805
1163
|
// MCP prompts are the client-agnostic UX layer: any prompts-capable host (Claude
|
|
806
1164
|
// Code / Desktop as `/mcp__<server>__<name>`, Cursor, VS Code, Cline, …) surfaces
|
|
@@ -809,6 +1167,18 @@ export function buildServer(opts = {}) {
|
|
|
809
1167
|
// loop); the cloud layers its own `naulon_ask` prompt where that tool lives. Each
|
|
810
1168
|
// returns a single user message that steers the host model through the tools; the
|
|
811
1169
|
// model still sees every price and spends only when a paying tool is called.
|
|
1170
|
+
//
|
|
1171
|
+
// WP-2 T4 — self-healing: before this, a discovery dead-end (no source configured, or an
|
|
1172
|
+
// empty/failing result) left the model either stuck or — worse — printing a raw env-var name
|
|
1173
|
+
// ("set RSS_URL") at a non-technical user who has no idea what that means. Every prompt now
|
|
1174
|
+
// appends the SAME recovery instruction: on empty/failed discovery, call naulon_status and
|
|
1175
|
+
// relay its plain-language `nextStep` (fund the wallet it shows, or connect a token), then
|
|
1176
|
+
// offer to retry. One shared instruction, not three drifting copies.
|
|
1177
|
+
const SELF_HEAL_PROMPT_TAIL = `\n\nIf naulon_discover comes back empty, or any tool call fails, call naulon_status and relay ` +
|
|
1178
|
+
`its "nextStep" guidance in your own plain language (e.g. fund the wallet address it shows, or ` +
|
|
1179
|
+
`connect a token) — never print a raw environment-variable name to the user. Then offer to retry. ` +
|
|
1180
|
+
`If you fall back to your own general knowledge instead of a naulon-tolled source, say so ` +
|
|
1181
|
+
`explicitly and label it clearly as NOT naulon-cited.`;
|
|
812
1182
|
server.registerPrompt("research", {
|
|
813
1183
|
title: "Research a topic (naulon)",
|
|
814
1184
|
description: "Discover naulon-tolled sources for a topic, see prices before paying, then return a grounded, cited answer within budget.",
|
|
@@ -823,7 +1193,8 @@ export function buildServer(opts = {}) {
|
|
|
823
1193
|
`1. Call naulon_discover("${topic}") — free — to list candidate essays.\n` +
|
|
824
1194
|
`2. Use naulon_appraise and naulon_quote to judge relevance and see exact prices. Nothing is spent until a paying tool runs.\n` +
|
|
825
1195
|
`3. Pay only the most relevant sources with naulon_pay_and_read, or call naulon_research to run the whole discover→quote→pay→ground loop within the session budget.\n\n` +
|
|
826
|
-
`Return a grounded answer with numbered citations and report exactly what was spent. Distinguish naulon-cited evidence from your own general knowledge
|
|
1196
|
+
`Return a grounded answer with numbered citations and report exactly what was spent. Distinguish naulon-cited evidence from your own general knowledge.` +
|
|
1197
|
+
SELF_HEAL_PROMPT_TAIL,
|
|
827
1198
|
},
|
|
828
1199
|
},
|
|
829
1200
|
],
|
|
@@ -839,7 +1210,8 @@ export function buildServer(opts = {}) {
|
|
|
839
1210
|
content: {
|
|
840
1211
|
type: "text",
|
|
841
1212
|
text: `Call naulon_discover("${topic}") and present the candidate essays as a ranked list — title, one-line summary, teaser price, and citation price. ` +
|
|
842
|
-
`This is FREE: do not pay for anything. If a grounded answer is wanted next, use the "research" prompt or naulon_research
|
|
1213
|
+
`This is FREE: do not pay for anything. If a grounded answer is wanted next, use the "research" prompt or naulon_research.` +
|
|
1214
|
+
SELF_HEAL_PROMPT_TAIL,
|
|
843
1215
|
},
|
|
844
1216
|
},
|
|
845
1217
|
],
|
|
@@ -857,7 +1229,8 @@ export function buildServer(opts = {}) {
|
|
|
857
1229
|
text: `Fact-check the claim: "${claim}".\n\n` +
|
|
858
1230
|
`Use naulon_discover to find relevant tolled sources, then naulon_appraise / naulon_quote (free) to see relevance and price. ` +
|
|
859
1231
|
`Only if grounding needs it, pay the most relevant sources with naulon_pay_and_read (or run naulon_research) within budget.\n\n` +
|
|
860
|
-
`State whether the claim is SUPPORTED, REFUTED, or UNVERIFIABLE, cite the paid sources by title, and report the spend. Keep naulon-cited evidence separate from your own general knowledge
|
|
1232
|
+
`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.` +
|
|
1233
|
+
SELF_HEAL_PROMPT_TAIL,
|
|
861
1234
|
},
|
|
862
1235
|
},
|
|
863
1236
|
],
|