agent402-mcp 0.13.15 → 0.13.17

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 CHANGED
@@ -15,7 +15,7 @@ wrapped with `mppx`'s `McpClient.wrap()` (USDC on Base/Celo, or native Tempo)
15
15
  pays and retries on its own, receipt in `_meta`. For no rate limit, or to pay
16
16
  over x402 or with a credits key already issued, run this package locally:
17
17
 
18
- With a funded wallet (USDC on any EVM rail the server offers - Base, Polygon, Arbitrum, Monad, Celo, Avalanche, Sei, Optimism - or USDG on Robinhood Chain, or USDC on Solana via `SOLANA_AGENT_KEY`; the service also settles on Stellar and Algorand, which this package does not sign) - every tool available:
18
+ With a funded wallet (USDC on any EVM rail the server offers - Base, Polygon, Arbitrum, Monad, Celo, Avalanche, Sei, Optimism, Arc - or USDG on Robinhood Chain, or USDC on Solana via `SOLANA_AGENT_KEY`; the service also settles on Stellar and Algorand, which this package does not sign) - every tool available:
19
19
 
20
20
  ```json
21
21
  {
@@ -64,21 +64,33 @@ Every variable the server reads (all optional):
64
64
  | `AGENT_KEY` | _(unset)_ | Hex private key of an EVM wallet funded with USDC on Base (or another EVM chain the server offers), or USDG on Robinhood Chain. |
65
65
  | `SOLANA_AGENT_KEY` | _(unset)_ | Base58 secret key (or JSON byte array) of a Solana wallet funded with USDC on Solana. |
66
66
  | `AGENT402_CREDITS_KEY` | _(unset)_ | A prepaid credits key (`a402_...`) already issued (new credits are not on sale). Sent as `Authorization: Bearer` on every catalog call; the server debits the list price only on a successful (200) call and answers `X-Credits-Balance`. Used only when no wallet key is set (a wallet key wins); in credits mode every call - pure-CPU tools included - is debited from the key. |
67
- | `AGENT402_URL` | `https://agent402.tools` | Target service (point at your own deployment). |
67
+ | `AGENT402_URL` | `https://agent402.tools` | Target service (point at your own deployment). Paid calls never follow redirects, so use the address that serves the routes directly; a redirect is refused with the origin to use. |
68
68
  | `AGENT402_TOOLS` | curated set | Comma-separated slugs to expose as first-class tools. |
69
- | `AGENT402_MAX_PER_CALL` | unlimited | Refuse any single call priced above this many USD (e.g. `0.01`). Applies to the wallet and the credits path. |
70
- | `AGENT402_BUDGET` | unlimited | Hard cap on total USD spent per session (e.g. `1.00`). Applies to the wallet and the credits path. |
69
+ | `AGENT402_MAX_PER_CALL` | `4` | Refuse any single call priced above this many USD (e.g. `0.05`); `off` disables it. Applies to the wallet and the credits path. |
70
+ | `AGENT402_BUDGET` | no session cap | Opt-in hard cap on total USD spent per session (e.g. `1.00`); unset or `off` means no session cap. Applies to the wallet and the credits path. |
71
71
  | `AGENT402_NETWORKS` | _(unset)_ | Restrict + order the chains to pay on - e.g. `robinhood` (USDG on Robinhood Chain), `base,solana`, or a raw CAIP-2 like `eip155:4663`. Unset = the client picks (effectively Base on multi-chain sellers). |
72
-
73
- Spend controls are enforced **before a payment is signed** (or a credits call is
74
- sent) - a runaway model is refused, not billed. `payment.info` reports the
75
- caps, what's been spent, and what remains. With no wallet key and no credits key,
72
+ | `AGENT402_ASYNC` | `auto` | Slow routes (reports, video, the premium image; `async.recommended` on their `/api/pricing` row) are paid with `Prefer: respond-async` and collected from their job link, so a long run never outlives the connection. `off` waits on the connection instead; `always` uses a job on every route that runs as one. |
73
+ | `AGENT402_ASYNC_TIMEOUT_MS` | `900000` | How long to poll a job before answering with its link (`structuredContent.jobUrl`) to collect later. |
74
+
75
+ The per-call cap is on by default and the session budget is opt-in; both are
76
+ enforced on the payment that is signed (or before a credits call is sent) - a
77
+ runaway model is refused, not billed. The
78
+ wallet's x402 client carries a policy that drops every payment option over the
79
+ per-call cap or what is left of the budget, so the 402 the wallet signs is held
80
+ to the caps, and a wallet call sends no unpaid preflight first. Each option is priced from the
81
+ known decimals of its asset (USDC on each rail, USDG on Robinhood Chain), never
82
+ from a decimals value in the challenge, and an option in any other asset is not
83
+ signed. A value that is not a USD amount or `off` reads as the default (the
84
+ $4 per-call cap; no session cap). Set `AGENT402_BUDGET` to bound what one
85
+ session may spend in total.
86
+ `payment.info` reports the caps, what's been spent, and what remains. With no wallet key and no credits key,
76
87
  the server runs in proof-of-work mode (pure-CPU tools stay free). Use dedicated
77
88
  low-value wallets for `AGENT_KEY` / `SOLANA_AGENT_KEY`, funded only with what
78
89
  you intend to spend. Most tools cost $0.001–$0.02. The routing tiers top out
79
90
  at $3.30 (`route-execute-pro`); multi-tool skill packs run $0.003 to $0.101; and the
80
91
  report products run $0.60 to $2.00 per report (domain-audit $0.60 up to the ticker pack at $2.00),
81
- so set `AGENT402_MAX_PER_CALL` if you want a hard per-call ceiling.
92
+ so the default per-call cap of $4 covers every tool; set `AGENT402_MAX_PER_CALL` lower for a
93
+ tighter ceiling.
82
94
 
83
95
  ## How it works
84
96
 
@@ -86,8 +98,9 @@ so set `AGENT402_MAX_PER_CALL` if you want a hard per-call ceiling.
86
98
  - **Flagship tools** are exposed as first-class MCP tools under dotted names - `web.search`, `web.answer`, `web.news`, `browser.render`, `market.quote`, `audio.transcribe`, `memory.read`, `memory.write` - search/answer is the front door. Override the set with `AGENT402_TOOLS` (slugs; a non-flagship slug is listed as its snake_case name).
87
99
  - **Decide** (`decide.plan`, `decide.execute`, `decide.feedback`): describe a job and get a call-ready plan over the catalog and outside x402 sellers (tools in order, fallbacks, validated params, chained steps), then run it yourself or through Agent402. Listed when the server offers Decide; see https://agent402.tools/decide.
88
100
  - The rest of the 500+ tool catalog is reachable via the meta tools `catalog.search` / `catalog.find` + `catalog.call` - keeping your context window small. The same dotted names are listed on the hosted connector; the older snake names (`search_tools`, `find_tool`, `call_tool`, `get_payment_info`, `describe_server`, `list_top_sellers`, and the flagship `search_web` …) remain accepted as CallTool aliases, so existing configs keep working.
89
- - When a call hits HTTP 402: with a wallet key set (`AGENT_KEY` for the EVM chains, Robinhood Chain included; `SOLANA_AGENT_KEY` for Solana), the server signs an x402 payment on a chain the seller accepts and retries; with only a credits key it sends `Authorization: Bearer a402_…` and the server debits the balance on success; with neither it solves the tool's proof-of-work challenge (~0.2 s of CPU) on the eligible tools. (The service settles USDC on Base, Solana, Polygon, Arbitrum, Monad, Celo, Avalanche, Sei, Optimism, Stellar and Algorand, plus USDG on Robinhood Chain - 12 chains total - for callers using a raw x402 client rather than this package.)
101
+ - When a call hits HTTP 402: with a wallet key set (`AGENT_KEY` for the EVM chains, Robinhood Chain included; `SOLANA_AGENT_KEY` for Solana), the server signs an x402 payment on a chain the seller accepts and retries; with only a credits key it sends `Authorization: Bearer a402_…` and the server debits the balance on success; with neither it solves the tool's proof-of-work challenge (~0.2 s of CPU) on the eligible tools. (The service settles USDC on Base, Solana, Polygon, Arbitrum, Monad, Celo, Avalanche, Sei, Optimism, Arc, Stellar and Algorand, plus USDG on Robinhood Chain - 13 chains total - for callers using a raw x402 client rather than this package.)
90
102
  - Since 0.13.15 the server's requests to the catalog and to sellers (catalog reads, the 402 and the paid retry) refuse a TLS chain through one of the intermediate CAs listed in `networks.js` (keys reported recoverable in Mozilla bug 2083449), keyed on each certificate's SubjectPublicKeyInfo hash, on a dedicated dispatcher. With a proxy or custom dispatcher installed, requests go out through it unchanged.
103
+ - **Slow routes run as jobs** (since 0.13.17): a paid call to a route the catalog marks slow carries `Prefer: respond-async`, answers `202` with a job link, and the server polls `GET /api/jobs/{id}` (free), waiting `Retry-After` seconds between polls, until the answer is ready, reading the job's `state` and falling back to its `status`. Payment settles only on the delivered answer. A caller that sends a `progressToken` gets `notifications/progress` from the job's stage, steps and ETA. A job still running when `AGENT402_ASYNC_TIMEOUT_MS` ends comes back with its `jobUrl` instead.
91
104
  - `payment.info` tells the model which mode it's in and what a wallet or a credits key would unlock.
92
105
  - `server.describe` returns orientation (flagship-first tools, install one-liners, free vs paid, discovery URLs). Call it first.
93
106
  - `sellers.list` returns the live leaderboards - `wire: "x402"` (default) ranks x402 sellers settling the most USDC (primarily on Base) in the last ~24h from on-chain transfers; `wire: "mpp"` ranks live-verified MPP sellers by inbound USDC.e transfers on Tempo. Free to call (no payment, no proof-of-work). Useful for agents discovering the wider x402 / MPP economy beyond this single service's catalog.
package/async-job.js ADDED
@@ -0,0 +1,125 @@
1
+ // Collect a paid call that the server answered as a job.
2
+ //
3
+ // A slow Agent402 route (its /api/pricing row carries async.recommended) takes
4
+ // longer than many clients wait. Sent with "Prefer: respond-async", the paid
5
+ // call answers 202 with a job link at once; the server runs it with this
6
+ // call's payment and settles only when the answer is ready. This module polls
7
+ // the job link (free), waiting Retry-After seconds between polls, and turns the
8
+ // final job into an ordinary Response: the route's own status, headers (the
9
+ // payment receipt) and body. A job that is still running when the caller's
10
+ // overall timeout ends throws AsyncJobPendingError carrying the job link, so
11
+ // the answer can still be collected later (the payment, if any, settles only
12
+ // on a delivered result, which may still happen).
13
+ //
14
+ // The job link must be on the same origin as the server: a Location naming
15
+ // another host is refused rather than fetched.
16
+ //
17
+ // Kept in lockstep with client/async-job.js (agent402-client).
18
+
19
+ export const DEFAULT_ASYNC_TIMEOUT_MS = 15 * 60_000;
20
+ const MIN_WAIT_MS = 1_000;
21
+ const MAX_WAIT_MS = 30_000;
22
+ const MAX_POLL_FAILURES = 5;
23
+
24
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ /** True when this call should ask for a job: mode "auto" (default) for routes
27
+ * the catalog marks recommended, true for any route that runs as a job,
28
+ * false never. `advice` is the catalog row's `async` block. */
29
+ export function shouldRequestAsync(advice, mode = "auto") {
30
+ if (!advice || typeof advice !== "object" || mode === false || mode === "off") return false;
31
+ if (mode === true || mode === "always") return true;
32
+ return advice.recommended === true;
33
+ }
34
+
35
+ export class AsyncJobPendingError extends Error {
36
+ constructor(message, { jobUrl, slug, job } = {}) {
37
+ super(message);
38
+ this.name = "AsyncJobPendingError";
39
+ Object.assign(this, { jobUrl, slug, job: job || null, mayBeCharged: true });
40
+ }
41
+ }
42
+
43
+ /** Milliseconds to wait before the next poll, from Retry-After or the body. */
44
+ export function retryAfterMs(headers, doc) {
45
+ const h = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
46
+ let s = h != null && String(h).trim() !== "" ? Number(h) : NaN;
47
+ if (!Number.isFinite(s)) s = Number(doc?.pollIntervalSeconds);
48
+ if (!Number.isFinite(s) || s < 0) s = 5;
49
+ return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, s * 1000));
50
+ }
51
+
52
+ function sameOrigin(url, baseUrl) {
53
+ try { return new URL(url).origin === new URL(baseUrl).origin; } catch { return false; }
54
+ }
55
+
56
+ // The job's lifecycle: `state` (queued | running | succeeded | failed) when
57
+ // the server sends it, else the original `status` (working | completed | failed).
58
+ const STATUS_TO_STATE = { working: "running", completed: "succeeded", failed: "failed", cancelled: "failed" };
59
+ export function jobState(doc) {
60
+ if (typeof doc?.state === "string" && doc.state) return doc.state;
61
+ const s = typeof doc?.status === "string" ? doc.status : "";
62
+ return STATUS_TO_STATE[s] || s || null;
63
+ }
64
+
65
+ /** An ordinary Response for a finished job's answer. */
66
+ export function responseFromJob(doc) {
67
+ const done = jobState(doc) === "succeeded";
68
+ const part = done ? doc.result || {} : doc?.error || {};
69
+ const status = Number(part.httpStatus) || (done ? 200 : 502);
70
+ const body = part.body !== undefined ? part.body : done ? {} : { error: part.message || doc?.message || "The job failed." };
71
+ const headers = { "content-type": "application/json" };
72
+ for (const [k, v] of Object.entries(part.headers || {})) if (typeof v === "string") headers[k.toLowerCase()] = v;
73
+ if (!done && doc?.message) headers["x-agent402-job-message"] = String(doc.message).replace(/[^\x20-\x7e]/g, " ").slice(0, 300);
74
+ return new Response(JSON.stringify(body), { status, headers });
75
+ }
76
+
77
+ /**
78
+ * Turn a 202 job answer into the route's final Response; any other response
79
+ * is returned unchanged.
80
+ * @param {Response} res the answer to the paid call
81
+ * @param {object} o
82
+ * @param {string} o.baseUrl the server's base URL (the job link must share its origin)
83
+ * @param {Function} o.fetchImpl a plain fetch (the job link is free; no payment rides on it)
84
+ * @param {number} [o.timeoutMs] overall wait for the job
85
+ * @param {Function} [o.onProgress] called with each job document
86
+ * @param {string} [o.slug]
87
+ */
88
+ export async function awaitJob(res, { baseUrl, fetchImpl, timeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, slug = "", sleep = sleepMs, now = () => Date.now() } = {}) {
89
+ if (!res || res.status !== 202) return res;
90
+ let doc = {};
91
+ try { doc = await res.json(); } catch { doc = {}; }
92
+ const loc = (res.headers && typeof res.headers.get === "function" && res.headers.get("location")) || doc.statusUrl;
93
+ if (!loc) throw new AsyncJobPendingError(`"${slug}" answered 202 without a job link`, { slug, job: doc });
94
+ let jobUrl;
95
+ try { jobUrl = new URL(loc, `${String(baseUrl).replace(/\/$/, "")}/`).href; } catch { jobUrl = null; }
96
+ if (!jobUrl || !sameOrigin(jobUrl, baseUrl)) throw new AsyncJobPendingError(`"${slug}" named a job link on another origin; it was not followed`, { slug, job: doc });
97
+ const report = (d) => { try { onProgress?.(d); } catch { /* the caller's callback never breaks the wait */ } };
98
+ report(doc);
99
+ const deadline = now() + Math.max(0, Number(timeoutMs) || DEFAULT_ASYNC_TIMEOUT_MS);
100
+ let wait = retryAfterMs(res.headers, doc);
101
+ let failures = 0;
102
+ for (;;) {
103
+ if (now() + wait > deadline) {
104
+ throw new AsyncJobPendingError(`"${slug}" is still running (${jobState(doc) || "running"}${doc.stage ? `, ${doc.stage}` : ""}); collect it from ${jobUrl} (free) until it expires`, { jobUrl, slug, job: doc });
105
+ }
106
+ await sleep(wait);
107
+ let g;
108
+ try { g = await fetchImpl(jobUrl, { headers: { Accept: "application/json" } }); }
109
+ catch { g = null; }
110
+ if (!g || g.status >= 500 || g.status === 429) {
111
+ if (++failures >= MAX_POLL_FAILURES) throw new AsyncJobPendingError(`the job link for "${slug}" could not be read (${g ? `HTTP ${g.status}` : "network error"}); collect it from ${jobUrl}`, { jobUrl, slug, job: doc });
112
+ wait = retryAfterMs(g?.headers, doc);
113
+ continue;
114
+ }
115
+ if (g.status === 404 || g.status === 410) {
116
+ throw new AsyncJobPendingError(`the job for "${slug}" is ${g.status === 410 ? "expired" : "unknown"} at ${jobUrl}`, { jobUrl, slug, job: doc });
117
+ }
118
+ failures = 0;
119
+ try { doc = await g.json(); } catch { doc = {}; }
120
+ report(doc);
121
+ const st = jobState(doc);
122
+ if (st === "succeeded" || st === "failed") return responseFromJob(doc);
123
+ wait = retryAfterMs(g.headers, doc);
124
+ }
125
+ }
package/index.js CHANGED
@@ -16,16 +16,19 @@
16
16
  // AGENT_KEY hex private key of a funded EVM wallet (USDC on Base/Polygon/Arbitrum), optional
17
17
  // SOLANA_AGENT_KEY base58 (or JSON byte-array) secret key of a funded Solana wallet (USDC on Solana), optional
18
18
  // AGENT402_TOOLS comma-separated slugs to expose first-class (overrides default)
19
- // AGENT402_MAX_PER_CALL refuse any single call priced above this many USD (e.g. 0.01)
19
+ // AGENT402_MAX_PER_CALL refuse any single call priced above this many USD (default 4; off disables)
20
20
  // AGENT402_CREDITS_KEY a prepaid credits key (a402_...) already issued (new credits are not on
21
21
  // sale); pays per successful call with no wallet
22
- // AGENT402_BUDGET hard cap on total USDC spent this session (e.g. 1.00)
22
+ // AGENT402_BUDGET hard cap on total USD spent this session (opt-in: unset = no session cap)
23
23
  // AGENT402_NETWORKS restrict + order the chains to pay on (e.g. "robinhood" for USDG on
24
24
  // Robinhood Chain, "base,solana", or a raw CAIP-2 like eip155:4663), optional
25
25
  import { createHash } from "node:crypto";
26
26
  import { readFileSync } from "node:fs";
27
+ import { AsyncLocalStorage } from "node:async_hooks";
28
+ import { quoteOf, guardSigning } from "./known-assets.js";
27
29
  import { parseNetworkPrefs, withNetworkPreference, distrustFetch } from "./networks.js";
28
30
  import { fetchDiscovery, discoveryFailure, getDiscoveryJson } from "./discovery.js";
31
+ import { awaitJob, shouldRequestAsync, AsyncJobPendingError, DEFAULT_ASYNC_TIMEOUT_MS, jobState } from "./async-job.js";
29
32
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
30
33
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
31
34
  import {
@@ -66,17 +69,49 @@ const HAS_WALLET = Boolean(AGENT_KEY || SOLANA_AGENT_KEY);
66
69
  // list price only on a successful (200) call and returns X-Credits-Balance.
67
70
  const CREDITS_KEY = (process.env.AGENT402_CREDITS_KEY || "").trim();
68
71
  const HAS_CREDITS = /^a402_[A-Za-z0-9_-]{32,64}$/.test(CREDITS_KEY);
72
+ // Slow paid routes (async.recommended on their /api/pricing row) are sent with
73
+ // Prefer: respond-async and collected from their job link (./async-job.js).
74
+ // AGENT402_ASYNC=off waits on the connection instead; =always uses a job on
75
+ // every route that runs as one. AGENT402_ASYNC_TIMEOUT_MS bounds the wait.
76
+ const ASYNC_MODE = (() => { const v = String(process.env.AGENT402_ASYNC || "auto").trim().toLowerCase(); return v === "off" || v === "false" ? false : v === "always" || v === "true" ? true : "auto"; })();
77
+ const ASYNC_TIMEOUT_MS = Number(process.env.AGENT402_ASYNC_TIMEOUT_MS) > 0 ? Number(process.env.AGENT402_ASYNC_TIMEOUT_MS) : DEFAULT_ASYNC_TIMEOUT_MS;
69
78
  // Version comes from package.json, the serverInfo self-report drifted from
70
79
  // the published version once (0.11.5 vs 0.12.1) because this was a hardcoded
71
80
  // string bumped by hand. Reading the manifest makes drift impossible.
72
81
  const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
73
82
 
74
- // Spend controls, enforced BEFORE a payment is ever signed, so a confused or
75
- // runaway model cannot drain the wallet. Unset = unlimited (back-compat).
76
- const num = (v) => (v !== undefined && v !== "" && Number.isFinite(Number(v)) ? Number(v) : undefined);
77
- const MAX_PER_CALL = num(process.env.AGENT402_MAX_PER_CALL) ?? Infinity;
78
- const BUDGET = num(process.env.AGENT402_BUDGET) ?? Infinity;
83
+ // Spend controls, enforced on the payment that is signed (see getPayFetch), so
84
+ // a confused or runaway model cannot drain the wallet. The per-call cap is on
85
+ // by default, at the dearest list price in the Agent402 catalog rounded up to
86
+ // a whole dollar (mcp/test.js checks it still covers the catalog), and `off`
87
+ // disables it; a malformed value reads as that default, never as off. The
88
+ // session budget is opt-in: unset (or `off`) is no session cap, so a long
89
+ // session is never refused by default.
90
+ const DEFAULT_MAX_PER_CALL_USD = 4;
91
+ const DEFAULT_BUDGET_USD = Infinity;
92
+ const capFromEnv = (name, def) => {
93
+ const raw = String(process.env[name] ?? "").trim().toLowerCase();
94
+ if (raw === "") return def;
95
+ if (raw === "off") return Infinity;
96
+ const n = Number(raw);
97
+ if (Number.isFinite(n) && n >= 0) return n;
98
+ console.error(`[agent402-mcp] ${name}=${JSON.stringify(process.env[name])} is not a USD amount or "off"; using the default (${def === Infinity ? "no cap" : `$${def}`})`);
99
+ return def;
100
+ };
101
+ const MAX_PER_CALL = capFromEnv("AGENT402_MAX_PER_CALL", DEFAULT_MAX_PER_CALL_USD);
102
+ const BUDGET = capFromEnv("AGENT402_BUDGET", DEFAULT_BUDGET_USD);
79
103
  let spentUsd = 0;
104
+ // Wallet calls in flight, each holding what it may sign until it settles or
105
+ // fails, so concurrent calls cannot each pass against the same budget. The
106
+ // signing guard reads the one being signed from signCtx.
107
+ const pendingSpend = new Set();
108
+ const signCtx = new AsyncLocalStorage();
109
+ const pendingUsd = (except = null) => { let t = 0; for (const e of pendingSpend) if (e !== except) t += e.usd; return t; };
110
+ /** The most the payment being signed may ask for: the per-call cap and what is
111
+ * left of the session budget after every other call in flight. */
112
+ function signAllowance() {
113
+ return Math.max(0, Math.min(MAX_PER_CALL, BUDGET - spentUsd - pendingUsd(signCtx.getStore() || null)));
114
+ }
80
115
 
81
116
  const DEFAULT_CURATED = [
82
117
  // Flagship demand set, keep aligned with src/mcp-flagship.js FLAGSHIP_SLUGS.
@@ -139,6 +174,7 @@ async function loadCatalog() {
139
174
  description: e.description,
140
175
  category: e.category,
141
176
  computePayable: !!e.computePayable,
177
+ async: e.async && typeof e.async === "object" ? e.async : null,
142
178
  inputSchema,
143
179
  });
144
180
  }
@@ -153,7 +189,17 @@ function getPayFetch() {
153
189
  const { x402Client } = await import("@x402/core/client");
154
190
  const { wrapFetchWithPayment } = await import("@x402/fetch");
155
191
  const client = new x402Client();
156
- client.setSpendControls?.(false); // @x402/core 2.23+ defaults to a $1 pegged-assets-only cap; this package bounds spend itself
192
+ // This package's guard (./known-assets.js) holds every signature to
193
+ // AGENT402_MAX_PER_CALL and what is left of AGENT402_BUDGET, priced from
194
+ // known assets only. It replaces the library's default control, which
195
+ // allows only that library's default assets (so no USDG on Robinhood
196
+ // Chain) under a fixed $1 cap.
197
+ client.setSpendControls?.(false);
198
+ guardSigning(client, {
199
+ allowance: signAllowance,
200
+ onSign: (usd) => { const e = signCtx.getStore(); if (e) { e.usd = usd; e.signed = true; } },
201
+ onRefuse: (detail) => { const e = signCtx.getStore(); if (e) e.refusal = detail; },
202
+ });
157
203
  if (AGENT_KEY) {
158
204
  const { registerExactEvmScheme } = await import("@x402/evm/exact/client");
159
205
  const { privateKeyToAccount } = await import("viem/accounts");
@@ -225,9 +271,12 @@ function walletRequiredText(tool) {
225
271
  // paths read the amount out of the 402 before spending anything.
226
272
  //
227
273
  // The preflight carries no payment header and no credits key, so it can only
228
- // ever draw an unpaid 402 - it never signs and never debits. Only run when a
229
- // ceiling is configured: with none, the number changes nothing and the round
230
- // trip is pure cost.
274
+ // ever draw an unpaid 402 - it never signs and never debits. Only the credits
275
+ // path sends one: it has no signing step, so the preflight's quote is what its
276
+ // caps are checked against. The wallet path sends none; its caps hold at
277
+ // signing (guardSigning in getPayFetch). Only run when a ceiling is
278
+ // configured: with none, the number changes nothing and the round trip is
279
+ // pure cost.
231
280
  const CAPS_CONFIGURED = Number.isFinite(MAX_PER_CALL) || Number.isFinite(BUDGET);
232
281
 
233
282
  /** The accepts array, at the top level or under the envelope keys sellers wrap it in. */
@@ -241,48 +290,31 @@ function acceptsOf(obj) {
241
290
  return null;
242
291
  }
243
292
 
244
- /** The MAXIMUM across accepts: the wallet picks which chain to settle on and
245
- * this server cannot know which, so the only sound ceiling is the dearest
246
- * offer. An entry we cannot price makes the whole quote unreadable (null),
247
- * never a guess. */
248
- function usdFromAccepts(accepts) {
249
- if (!Array.isArray(accepts) || !accepts.length) return null;
250
- let maxUsd = null;
251
- for (const a of accepts) {
252
- // v2 names the base-unit amount `amount`; x402 v1 called it `maxAmountRequired`.
253
- const atomic = Number(a && (a.amount ?? a.maxAmountRequired));
254
- if (!Number.isFinite(atomic) || atomic < 0) return null;
255
- // USDC is 6 decimals on every chain these rails settle; a wrong exponent is
256
- // a 1000x pricing error, so an out-of-range hint reads as unreadable.
257
- const declared = (a && a.extra && a.extra.decimals) ?? (a && a.decimals);
258
- const decimals = declared == null ? 6 : Number(declared);
259
- if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) return null;
260
- const usd = atomic / 10 ** decimals;
261
- if (maxUsd == null || usd > maxUsd) maxUsd = usd;
262
- }
263
- return maxUsd;
264
- }
265
-
266
- /** The quoted price for this request, or `listUsd` when the 402 cannot be read.
293
+ /** The quoted price for this request, or `listUsd` when no 402 can be read.
267
294
  * x402 v2 carries the challenge base64 in the PAYMENT-REQUIRED header and
268
- * leaves the body `{}`; v1 sellers put it in the body. Header first. */
295
+ * leaves the body `{}`; v1 sellers put it in the body. Header first.
296
+ *
297
+ * Each accept is priced from the known-asset table (./known-assets.js), never
298
+ * from a decimals value the seller sends. The credits gate debits its own
299
+ * quote, so the dearest known accept is the price. */
269
300
  async function quotedUsd(url, init, listUsd) {
270
- if (!CAPS_CONFIGURED) return listUsd;
301
+ if (!CAPS_CONFIGURED) return { usd: listUsd };
302
+ let q = null;
271
303
  try {
272
304
  const pre = await ownFetch(url, init);
273
- if (pre.status !== 402) return listUsd;
274
- let quoted = null;
305
+ if (pre.status !== 402) return { usd: listUsd };
275
306
  const header = pre.headers.get("payment-required");
276
307
  if (header) {
277
- try { quoted = usdFromAccepts(acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8")))); } catch { quoted = null; }
308
+ try { q = quoteOf(acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8")))); } catch { q = null; }
278
309
  }
279
- if (quoted == null) {
310
+ if (!q) {
280
311
  let body = null; try { body = await pre.json(); } catch { body = null; }
281
- quoted = usdFromAccepts(acceptsOf(body));
312
+ q = quoteOf(acceptsOf(body));
282
313
  }
283
- // Larger of the two: an understated catalog must not lower the ceiling.
284
- return quoted == null ? listUsd : Math.max(listUsd, quoted);
285
- } catch { return listUsd; /* fail-open to the advertised price */ }
314
+ } catch { return { usd: listUsd }; /* no readable 402: the advertised price */ }
315
+ if (!q || q.max == null) return { usd: listUsd };
316
+ // Larger of the two: an understated catalog must not lower the ceiling.
317
+ return { usd: Math.max(listUsd, q.max) };
286
318
  }
287
319
 
288
320
  // A refused call's tool text. A 402 body from an x402 v2 seller may also carry
@@ -299,9 +331,35 @@ function failureText(status, text) {
299
331
  return JSON.stringify({ ...rest, status, paymentOptions: Array.isArray(accepts) ? accepts.length : 0, note: "The full offer is in the PAYMENT-REQUIRED header of this 402." });
300
332
  }
301
333
 
302
- async function callEndpoint(tool, args = {}) {
334
+ /** A job's progress as MCP notifications/progress, when the caller asked for
335
+ * progress (a progressToken on the tools/call). Progress only ever rises. */
336
+ function progressNotifier(req, extra) {
337
+ const token = req?.params?._meta?.progressToken;
338
+ if (token === undefined || token === null || typeof extra?.sendNotification !== "function") return null;
339
+ let last = -1;
340
+ return (doc) => {
341
+ const pct = Number.isFinite(doc?.progress) ? Math.round(doc.progress * 100) : 0;
342
+ // MCP requires progress to increase with each notification.
343
+ if (pct <= last) return;
344
+ const value = pct;
345
+ last = value;
346
+ const eta = Number.isFinite(doc?.etaSeconds) ? `, about ${doc.etaSeconds} s left` : "";
347
+ const message = `${jobState(doc) || "running"}${doc?.stage ? `: ${doc.stage}` : ""}${doc?.steps ? ` (${doc.steps.done} of ${doc.steps.total} steps)` : ""}${doc?.progressEstimated ? " (estimated)" : ""}${eta}`;
348
+ extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: value, total: 100, message } }).catch(() => {});
349
+ };
350
+ }
351
+
352
+ function isRedirect(r) {
353
+ return r?.type === "opaqueredirect" || (r?.status >= 300 && r?.status < 400);
354
+ }
355
+
356
+ async function callEndpoint(tool, args = {}, { onProgress = null } = {}) {
303
357
  const url = new URL(`${BASE}${tool.path}`);
304
358
  const init = { method: tool.method, headers: { Accept: "application/json" } };
359
+ // A slow route runs as a job (./async-job.js): the paid call asks for one,
360
+ // and the job link is polled (free) until the answer is ready.
361
+ const asyncHeaders = shouldRequestAsync(tool.async, ASYNC_MODE) ? { Prefer: "respond-async" } : {};
362
+ const collect = (r) => awaitJob(r, { baseUrl: BASE, fetchImpl: ownFetch, timeoutMs: ASYNC_TIMEOUT_MS, onProgress, slug: tool.slug });
305
363
  if (tool.method === "GET") {
306
364
  for (const [k, v] of Object.entries(args)) {
307
365
  if (v === undefined || v === null) continue;
@@ -312,48 +370,89 @@ async function callEndpoint(tool, args = {}) {
312
370
  init.body = JSON.stringify(args);
313
371
  }
314
372
 
373
+ // A request that can carry a payment never follows a redirect: followed, it
374
+ // would pay another origin's 402, or forward a signature already made to
375
+ // another host, and return that host's answer as this tool's.
376
+ const paidInit = { ...init, redirect: "manual" };
377
+ const redirectRefusal = (r) => {
378
+ const loc = r.headers.get("location");
379
+ const to = loc ? new URL(loc, url).href : null;
380
+ return { content: [{ type: "text", text: `Refused: ${url.href} answered a paid call to "${tool.slug}" with HTTP ${r.status || "redirect"}${to ? ` pointing to ${to}` : ""}. Paid requests never follow redirects${to ? `; if that is the address you meant, set AGENT402_URL to ${new URL(to).origin}` : ""}.` }], isError: true };
381
+ };
382
+
315
383
  let res;
316
384
  if (!HAS_WALLET && HAS_CREDITS) {
317
385
  // Card credits: same budget guards as the wallet path, against the same
318
386
  // number - the credits gate holds and debits what the 402 quotes for THIS
319
387
  // body, not the route's list price.
320
388
  const listUsd = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
321
- const price = await quotedUsd(url, init, listUsd);
389
+ const { usd: price } = await quotedUsd(url, paidInit, listUsd);
322
390
  if (price > MAX_PER_CALL) {
323
391
  return { content: [{ type: "text", text: `Refused: "${tool.slug}" is quoted $${price} for this request${price !== listUsd ? ` (list price ${tool.price})` : ""}, above the AGENT402_MAX_PER_CALL cap of $${MAX_PER_CALL}.` }], isError: true };
324
392
  }
325
393
  if (spentUsd + price > BUDGET) {
326
394
  return { content: [{ type: "text", text: `Refused: session budget exhausted ($${spentUsd.toFixed(4)} of $${BUDGET} spent; "${tool.slug}" is quoted $${price} for this request).` }], isError: true };
327
395
  }
328
- res = await ownFetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${CREDITS_KEY}` } });
396
+ try {
397
+ const first = await ownFetch(url, { ...paidInit, headers: { ...init.headers, ...asyncHeaders, Authorization: `Bearer ${CREDITS_KEY}` } });
398
+ if (isRedirect(first)) return redirectRefusal(first);
399
+ res = await collect(first);
400
+ }
401
+ catch (e) { if (e instanceof AsyncJobPendingError) { spentUsd += price; return pendingJobResult(tool, e); } throw e; }
329
402
  if (res.ok) spentUsd += price;
330
403
  if (res.status === 402) {
331
404
  let body = {}; try { body = await res.clone().json(); } catch { /* plain */ }
332
405
  return { content: [{ type: "text", text: `Credits refused for "${tool.slug}": ${body.error || "payment required"}${body.balanceUsd != null ? ` (balance $${body.balanceUsd})` : ""}. Top up at ${body.topup || `${BASE}/credits`}.` }], isError: true };
333
406
  }
334
407
  } else if (HAS_WALLET) {
335
- // The wallet signs whatever the challenge asks for (vendor spend controls
336
- // are off - this package bounds spend itself), so the cap is checked
337
- // against that amount, read from the 402 before anything is signed.
338
- const listUsd = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
339
- const price = await quotedUsd(url, init, listUsd);
408
+ // The signing guard (installed on every key-built client, getPayFetch)
409
+ // holds the payment that is signed to the caps and refuses an asset it
410
+ // cannot price, so no unpaid preflight is sent: the paid request's own 402
411
+ // is the one quote, read once, at signing. The list price is checked here
412
+ // first only because it costs no request: the quote is never below it for
413
+ // the cap's purposes (an understated catalog must not lower the ceiling).
414
+ const price = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
340
415
  if (price > MAX_PER_CALL) {
341
416
  return {
342
- content: [{ type: "text", text: `Refused without paying: "${tool.slug}" is quoted $${price} for this request${price !== listUsd ? ` (list price ${tool.price})` : ""}, above the AGENT402_MAX_PER_CALL cap of $${MAX_PER_CALL}. Raise the cap on this MCP server to allow it.` }],
417
+ content: [{ type: "text", text: `Refused without paying: "${tool.slug}" lists at ${tool.price}, above the AGENT402_MAX_PER_CALL cap of $${MAX_PER_CALL}. Raise the cap on this MCP server to allow it.` }],
343
418
  isError: true,
344
419
  };
345
420
  }
346
- if (spentUsd + price > BUDGET) {
421
+ const inFlight = pendingUsd();
422
+ if (spentUsd + inFlight + price > BUDGET) {
347
423
  return {
348
- content: [{ type: "text", text: `Refused without paying: session budget exhausted ($${spentUsd.toFixed(4)} of $${BUDGET} spent; "${tool.slug}" is quoted $${price} for this request). Restart the MCP server or raise AGENT402_BUDGET.` }],
424
+ content: [{ type: "text", text: `Refused without paying: session budget exhausted ($${spentUsd.toFixed(4)} of $${BUDGET} spent${inFlight ? `, $${inFlight.toFixed(4)} in flight` : ""}; "${tool.slug}" lists at ${tool.price}). Restart the MCP server or raise AGENT402_BUDGET.` }],
349
425
  isError: true,
350
426
  };
351
427
  }
352
428
  const payFetch = await getPayFetch();
353
- res = await payFetch(url, init);
354
- // Count spend when the server confirms settlement (payment receipt header),
355
- // falling back to any 2xx, conservative in the buyer's favor.
356
- if (res.headers.get("payment-response") || res.headers.get("x-payment-response") || res.ok) spentUsd += price;
429
+ // Held until the call settles or fails; the guard replaces `usd` with the
430
+ // amount it lets the wallet sign.
431
+ const entry = { usd: price, signed: false, refusal: null };
432
+ pendingSpend.add(entry);
433
+ try {
434
+ const first = await signCtx.run(entry, () => payFetch(url, { ...paidInit, headers: { ...init.headers, ...asyncHeaders } }));
435
+ if (isRedirect(first)) {
436
+ pendingSpend.delete(entry);
437
+ // A signature already sent leaves that payment's outcome unknown, so
438
+ // it counts against the session budget.
439
+ if (entry.signed) spentUsd += entry.usd;
440
+ return redirectRefusal(first);
441
+ }
442
+ res = await collect(first);
443
+ }
444
+ catch (e) {
445
+ pendingSpend.delete(entry);
446
+ // Still running past the wait: it may yet settle, so it counts as spent.
447
+ if (e instanceof AsyncJobPendingError) { if (entry.signed) spentUsd += entry.usd; return pendingJobResult(tool, e); }
448
+ if (entry.refusal) return signingRefusal(tool, entry.refusal);
449
+ throw e;
450
+ }
451
+ pendingSpend.delete(entry);
452
+ // Count spend when a payment was signed and the server confirms settlement
453
+ // (payment receipt header), falling back to any 2xx, conservative in the
454
+ // buyer's favor.
455
+ if (entry.signed && (res.headers.get("payment-response") || res.headers.get("x-payment-response") || res.ok)) spentUsd += entry.usd;
357
456
  } else if (tool.computePayable) {
358
457
  // No wallet: pay with compute up front, solving before the call skips the
359
458
  // 402 round-trip entirely (challenges are single-use and tool-scoped).
@@ -382,9 +481,33 @@ async function callEndpoint(tool, args = {}) {
382
481
  let parsed;
383
482
  try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; }
384
483
  const structured = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { result: parsed };
484
+ // A binary answer collected from a job arrives as { contentType, encoding: "base64", data }.
485
+ if (structured.encoding === "base64" && typeof structured.data === "string" && /^image\//.test(String(structured.contentType || ""))) {
486
+ return {
487
+ content: [{ type: "image", data: structured.data, mimeType: String(structured.contentType) }],
488
+ structuredContent: { slug: tool.slug, result: { contentType: structured.contentType, encoding: "base64", note: "Binary payload is in the image content block" } },
489
+ };
490
+ }
385
491
  return { content: [{ type: "text", text }], structuredContent: structured };
386
492
  }
387
493
 
494
+ /** The tool result for a payment the signing guard refused. */
495
+ function signingRefusal(tool, d) {
496
+ const text = d.reason === "unknown-asset"
497
+ ? `Refused without paying: "${tool.slug}" asked to be paid in an asset this server cannot price (USDC or USDG on a known network), so nothing was signed.`
498
+ : `Refused without paying: "${tool.slug}" asked for $${d.priceUsd} on the paid request, above the $${Number(d.allowanceUsd.toFixed(6))} this server may sign (AGENT402_MAX_PER_CALL ${MAX_PER_CALL === Infinity ? "off" : `$${MAX_PER_CALL}`}, AGENT402_BUDGET ${BUDGET === Infinity ? "off" : `$${BUDGET}`} with $${spentUsd.toFixed(4)} spent). Nothing was signed.`;
499
+ return { content: [{ type: "text", text }], isError: true };
500
+ }
501
+
502
+ /** The tool result for a job still running when the wait ended. */
503
+ function pendingJobResult(tool, e) {
504
+ return {
505
+ content: [{ type: "text", text: `${tool.slug}: ${e.message}. The call was paid for as a job; payment settles only if the answer is delivered. Poll the job link (free, no payment) for the answer.` }],
506
+ structuredContent: { slug: tool.slug, pending: true, jobUrl: e.jobUrl || null, job: e.job || null },
507
+ isError: true,
508
+ };
509
+ }
510
+
388
511
  // ---------------------------------------------------------------------------
389
512
  // Tool search over the full catalog (for everything not exposed first-class).
390
513
  // Front-door phrase boosts mirror src/find.js applyFrontDoorTerms so stdio
@@ -543,7 +666,7 @@ async function ensureCatalog() {
543
666
  log(`Could not load the catalog from ${BASE}: ${err.message}: next read in ${CATALOG_RETRY_MS / 1000}s`);
544
667
  }
545
668
  }
546
- const withCatalog = (handler) => async (req) => { await ensureCatalog(); return handler(req); };
669
+ const withCatalog = (handler) => async (req, extra) => { await ensureCatalog(); return handler(req, extra); };
547
670
 
548
671
  server.setRequestHandler(ListToolsRequestSchema, withCatalog(async () => {
549
672
  const SAFE = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -674,7 +797,7 @@ server.setRequestHandler(ListToolsRequestSchema, withCatalog(async () => {
674
797
  return { tools };
675
798
  }));
676
799
 
677
- server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req) => {
800
+ server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req, extra) => {
678
801
  const { name: rawName, arguments: args = {} } = req.params;
679
802
  const name = resolveListedName(rawName);
680
803
  const overBound = toolInputBoundRefusal(rawName, args);
@@ -907,7 +1030,7 @@ server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req) => {
907
1030
  if (!tool) return { content: [{ type: "text", text: `External routing (${slug}) is not in the catalog from ${BASE} yet. It may be rolling out; retry shortly.` }], isError: true };
908
1031
  // Body matches the route-execute input schema; include:"external" is what
909
1032
  // pays an OUTSIDE seller (vs this host's catalog). Paid via callEndpoint.
910
- return await callEndpoint(tool, { task, include: "external", maxUsd, ...(args.params && typeof args.params === "object" ? { params: args.params } : {}) });
1033
+ return await callEndpoint(tool, { task, include: "external", maxUsd, ...(args.params && typeof args.params === "object" ? { params: args.params } : {}) }, { onProgress: progressNotifier(req, extra) });
911
1034
  }
912
1035
  // Curated tools are exposed verb_noun for tools/list consistency, but the
913
1036
  // real slug is kebab, accept exposed name, plain snake, or raw slug.
@@ -920,7 +1043,7 @@ server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req) => {
920
1043
  if (!tool) {
921
1044
  return { content: [{ type: "text", text: `Unknown tool slug "${wanted}". Use catalog.search to find the right slug.` }], isError: true };
922
1045
  }
923
- const out = await callEndpoint(tool, name === "catalog.call" ? (args.params ?? {}) : args);
1046
+ const out = await callEndpoint(tool, name === "catalog.call" ? (args.params ?? {}) : args, { onProgress: progressNotifier(req, extra) });
924
1047
  // call_tool: wrap structuredContent in {slug, result} to match META_OUTPUT_SCHEMAS["catalog.call"]
925
1048
  // while keeping content text as the native tool JSON.
926
1049
  if (name === "catalog.call" && !out.isError && out.structuredContent) {
@@ -0,0 +1,120 @@
1
+ // Known payment assets, and the USD value of an x402 accept priced from them.
2
+ //
3
+ // An x402 "exact" signer signs the accept's raw base-unit `amount` for the
4
+ // accept's `asset`. A spending cap therefore has to price that amount with the
5
+ // asset's real decimals, and a decimals hint the seller sends beside it (for
6
+ // example `extra.decimals`) is not evidence of anything. So the price of an
7
+ // accept comes from this table alone: the stablecoins each network settles on
8
+ // the Agent402 rails, with their on-chain decimals. An accept whose
9
+ // (network, asset) pair is not in the table cannot be priced, and a buyer with
10
+ // a cap does not sign it.
11
+ //
12
+ // The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
13
+ // the parent repo's scripts/test-known-assets.js keeps the copies identical and
14
+ // checks the table against the assets the server offers.
15
+
16
+ /** network (CAIP-2) -> the assets a buyer may price there. */
17
+ export const KNOWN_ASSETS = Object.freeze({
18
+ // EVM: USDC (native Circle) unless named otherwise.
19
+ "eip155:8453": [{ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, symbol: "USDC" }], // Base
20
+ "eip155:84532": [{ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", decimals: 6, symbol: "USDC" }], // Base Sepolia
21
+ "eip155:137": [{ asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, symbol: "USDC" }], // Polygon
22
+ "eip155:42161": [{ asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, symbol: "USDC" }], // Arbitrum
23
+ "eip155:143": [{ asset: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", decimals: 6, symbol: "USDC" }], // Monad
24
+ "eip155:42220": [{ asset: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, symbol: "USDC" }], // Celo
25
+ "eip155:43114": [{ asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6, symbol: "USDC" }], // Avalanche
26
+ "eip155:1329": [{ asset: "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392", decimals: 6, symbol: "USDC" }], // Sei
27
+ "eip155:10": [{ asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6, symbol: "USDC" }], // Optimism
28
+ "eip155:5042": [{ asset: "0x3600000000000000000000000000000000000000", decimals: 6, symbol: "USDC" }], // Arc
29
+ "eip155:4663": [{ asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, symbol: "USDG" }], // Robinhood Chain
30
+ // Solana: the USDC mint.
31
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [{ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, symbol: "USDC" }],
32
+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [{ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", decimals: 6, symbol: "USDC" }], // devnet
33
+ // Stellar: the USDC Stellar Asset Contract (7 decimals on Stellar).
34
+ "stellar:pubnet": [{ asset: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", decimals: 7, symbol: "USDC" }],
35
+ // Algorand: USDC is ASA 31566704.
36
+ "algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=": [{ asset: "31566704", decimals: 6, symbol: "USDC" }],
37
+ });
38
+
39
+ const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
40
+ // EVM addresses compare without case (checksum casing is presentation);
41
+ // base58, Stellar and Algorand identifiers compare exactly.
42
+ const sameAsset = (a, b) => (EVM_ADDRESS.test(a) && EVM_ADDRESS.test(b) ? a.toLowerCase() === b.toLowerCase() : a === b);
43
+
44
+ /** The table entry for (network, asset), or null when it is not a known asset. */
45
+ export function knownAsset(network, asset) {
46
+ if (typeof network !== "string" || typeof asset !== "string" || !asset) return null;
47
+ const list = KNOWN_ASSETS[network];
48
+ if (!list) return null;
49
+ return list.find((e) => sameAsset(e.asset, asset)) || null;
50
+ }
51
+
52
+ /** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
53
+ * `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
54
+ * the asset is not known or the amount is not a whole number of base units. */
55
+ export function acceptUsd(accept) {
56
+ if (!accept || typeof accept !== "object") return null;
57
+ const raw = accept.amount ?? accept.maxAmountRequired;
58
+ if (raw == null || !/^\d+$/.test(String(raw))) return null;
59
+ const entry = knownAsset(accept.network, accept.asset);
60
+ if (!entry) return null;
61
+ return Number(raw) / 10 ** entry.decimals;
62
+ }
63
+
64
+ /** The USD values of a 402's accepts: `max` and `min` over the accepts that can
65
+ * be priced, and `unknown`, the count that cannot. null when there are none. */
66
+ export function quoteOf(accepts) {
67
+ if (!Array.isArray(accepts) || !accepts.length) return null;
68
+ let max = null, min = null, unknown = 0;
69
+ for (const a of accepts) {
70
+ const usd = acceptUsd(a);
71
+ if (usd == null) { unknown++; continue; }
72
+ if (max == null || usd > max) max = usd;
73
+ if (min == null || usd < min) min = usd;
74
+ }
75
+ return { max, min, unknown, total: accepts.length };
76
+ }
77
+
78
+ /** Enforce a USD allowance at signing on an @x402/core x402Client.
79
+ *
80
+ * `allowance()` is the most this buyer may sign for the payment being built
81
+ * (a per-call cap, a remaining budget, or the smaller of the two; Infinity
82
+ * for none). A registered policy drops every accept that cannot be priced or
83
+ * is over the allowance, so the client selects among the ones that fit, and a
84
+ * hook before payload creation checks the selected accept again and passes
85
+ * its USD value to `onSign`. A refusal throws (from the policy) or aborts
86
+ * (from the hook) before any signature exists; `onRefuse` hears why first. */
87
+ export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
88
+ if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
89
+ throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
90
+ }
91
+ if (typeof allowance !== "function") throw new TypeError("guardSigning needs an allowance() function");
92
+ const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
93
+ client.registerPolicy((version, accepts) => {
94
+ const cap = allowance();
95
+ const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
96
+ if (!kept.length) {
97
+ const q = quoteOf(accepts);
98
+ const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
99
+ throw new Error(d.reason === "unknown-asset"
100
+ ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
101
+ : `spending guard: the cheapest payment option asks $${d.priceUsd}, above the $${cap} this buyer may sign; nothing was signed`);
102
+ }
103
+ return kept;
104
+ });
105
+ client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
106
+ const usd = acceptUsd(r);
107
+ const cap = allowance();
108
+ if (usd == null) {
109
+ refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
110
+ return { abort: true, reason: "spending guard: the selected payment option is not in a known asset; nothing was signed" };
111
+ }
112
+ if (usd > cap) {
113
+ refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
114
+ return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
115
+ }
116
+ onSign?.(usd, r);
117
+ return undefined;
118
+ });
119
+ return client;
120
+ }
package/networks.js CHANGED
@@ -17,6 +17,7 @@ export const NETWORK_CAIP2 = {
17
17
  arbitrum: "eip155:42161",
18
18
  "base-sepolia": "eip155:84532",
19
19
  robinhood: "eip155:4663",
20
+ arc: "eip155:5042",
20
21
  solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
21
22
  "solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
22
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-mcp",
3
- "version": "0.13.15",
3
+ "version": "0.13.17",
4
4
  "mcpName": "io.github.MikeyPetrillo/agent402",
5
5
  "author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
6
6
  "homepage": "https://agent402.tools",
@@ -9,13 +9,15 @@
9
9
  "url": "git+https://github.com/MikeyPetrillo/Agent402.git",
10
10
  "directory": "mcp"
11
11
  },
12
- "description": "Agent402 MCP server: 500+ pay-per-call tools + skill packs for AI agents over x402 (USDC or USDG on 12 chains) or MPP on the same 402, or free via proof-of-work - the applied layer of Agentic Finance. Catalog: browser, web search + cited answers, PDFs, OCR, images, LLM inference, image generation, code execution, STT, embeddings, live financial/crypto/macro data, SEC EDGAR filings, deterministic stats & forecasting, compression, payments. Open source, self-hostable.",
12
+ "description": "Agent402 MCP server: 500+ pay-per-call tools + skill packs for AI agents over x402 (USDC or USDG on 13 chains) or MPP on the same 402, or free via proof-of-work - the applied layer of Agentic Finance. Catalog: browser, web search + cited answers, PDFs, OCR, images, LLM inference, image generation, code execution, STT, embeddings, live financial/crypto/macro data, SEC EDGAR filings, deterministic stats & forecasting, compression, payments. Open source, self-hostable.",
13
13
  "type": "module",
14
14
  "bin": {
15
15
  "agent402-mcp": "index.js"
16
16
  },
17
17
  "files": [
18
18
  "index.js",
19
+ "async-job.js",
20
+ "known-assets.js",
19
21
  "networks.js",
20
22
  "discovery.js",
21
23
  "output-schemas.js",
@@ -56,6 +58,7 @@
56
58
  "multi-chain",
57
59
  "monad",
58
60
  "celo",
61
+ "arc",
59
62
  "avalanche",
60
63
  "sei",
61
64
  "optimism",