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 +23 -10
- package/async-job.js +125 -0
- package/index.js +186 -63
- package/known-assets.js +120 -0
- package/networks.js +1 -0
- package/package.json +5 -2
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` |
|
|
70
|
-
| `AGENT402_BUDGET` |
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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`
|
|
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 -
|
|
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 (
|
|
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
|
|
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
|
|
75
|
-
// runaway model cannot drain the wallet.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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
|
|
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 {
|
|
308
|
+
try { q = quoteOf(acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8")))); } catch { q = null; }
|
|
278
309
|
}
|
|
279
|
-
if (
|
|
310
|
+
if (!q) {
|
|
280
311
|
let body = null; try { body = await pre.json(); } catch { body = null; }
|
|
281
|
-
|
|
312
|
+
q = quoteOf(acceptsOf(body));
|
|
282
313
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
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}"
|
|
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
|
-
|
|
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}"
|
|
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
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
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) {
|
package/known-assets.js
ADDED
|
@@ -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.
|
|
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
|
+
"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",
|