agent402-mcp 0.13.14 → 0.13.15

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
@@ -87,6 +87,7 @@ so set `AGENT402_MAX_PER_CALL` if you want a hard per-call ceiling.
87
87
  - **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
88
  - 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
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.)
90
+ - 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.
90
91
  - `payment.info` tells the model which mode it's in and what a wallet or a credits key would unlock.
91
92
  - `server.describe` returns orientation (flagship-first tools, install one-liners, free vs paid, discovery URLs). Call it first.
92
93
  - `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/discovery.js CHANGED
@@ -5,10 +5,14 @@
5
5
  // DISCOVERY_ATTEMPTS tries in all, each wait capped at DISCOVERY_MAX_WAIT_MS.
6
6
  // A paid or priced call never goes through here: a repeat of one could settle
7
7
  // twice.
8
+ import { distrustFetch } from "./networks.js";
9
+
8
10
  export const DISCOVERY_ATTEMPTS = 3;
9
11
  export const DISCOVERY_MAX_WAIT_MS = 5000;
10
12
 
11
13
  const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ // Discovery reads go through the distrusted-intermediate refusal (networks.js).
15
+ const ownFetch = distrustFetch();
12
16
 
13
17
  /** The wait (ms) a shed 503 asks for, or null when `r` is not one. */
14
18
  export async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
@@ -30,7 +34,7 @@ export async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
30
34
  * response (2xx or not) and how many attempts it took; the caller decides how
31
35
  * to report a failure, naming the surface and `res.status`.
32
36
  */
33
- export async function fetchDiscovery(url, init, { fetchImpl = globalThis.fetch, sleep = sleepMs, attempts: max = DISCOVERY_ATTEMPTS } = {}) {
37
+ export async function fetchDiscovery(url, init, { fetchImpl = ownFetch, sleep = sleepMs, attempts: max = DISCOVERY_ATTEMPTS } = {}) {
34
38
  let res;
35
39
  let attempts = 0;
36
40
  for (;;) {
package/index.js CHANGED
@@ -24,7 +24,7 @@
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 { parseNetworkPrefs, withNetworkPreference } from "./networks.js";
27
+ import { parseNetworkPrefs, withNetworkPreference, distrustFetch } from "./networks.js";
28
28
  import { fetchDiscovery, discoveryFailure, getDiscoveryJson } from "./discovery.js";
29
29
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
30
30
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -51,6 +51,10 @@ import {
51
51
  } from "./output-schemas.js";
52
52
 
53
53
  const BASE = (process.env.AGENT402_URL || "https://agent402.tools").replace(/\/$/, "");
54
+ // Catalog and seller requests (the 402 and the paid retry included) go through
55
+ // a dedicated dispatcher that refuses a TLS chain through a distrusted
56
+ // intermediate CA (networks.js).
57
+ const ownFetch = distrustFetch();
54
58
  const AGENT_KEY = process.env.AGENT_KEY || "";
55
59
  // Solana secret key: base58 string (Phantom/solana-keygen export) or a JSON
56
60
  // byte array. Either key alone enables paid calls; with both, the buyer can
@@ -165,7 +169,7 @@ function getPayFetch() {
165
169
  // Without it the client effectively always picks Base on multi-chain
166
170
  // sellers, leaving non-default rails unreachable.
167
171
  withNetworkPreference(client, parseNetworkPrefs(process.env.AGENT402_NETWORKS));
168
- return wrapFetchWithPayment(fetch, client);
172
+ return wrapFetchWithPayment(ownFetch, client);
169
173
  })();
170
174
  return payFetchPromise;
171
175
  }
@@ -265,7 +269,7 @@ function usdFromAccepts(accepts) {
265
269
  async function quotedUsd(url, init, listUsd) {
266
270
  if (!CAPS_CONFIGURED) return listUsd;
267
271
  try {
268
- const pre = await fetch(url, init);
272
+ const pre = await ownFetch(url, init);
269
273
  if (pre.status !== 402) return listUsd;
270
274
  let quoted = null;
271
275
  const header = pre.headers.get("payment-required");
@@ -321,7 +325,7 @@ async function callEndpoint(tool, args = {}) {
321
325
  if (spentUsd + price > BUDGET) {
322
326
  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 };
323
327
  }
324
- res = await fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${CREDITS_KEY}` } });
328
+ res = await ownFetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${CREDITS_KEY}` } });
325
329
  if (res.ok) spentUsd += price;
326
330
  if (res.status === 402) {
327
331
  let body = {}; try { body = await res.clone().json(); } catch { /* plain */ }
@@ -355,7 +359,7 @@ async function callEndpoint(tool, args = {}) {
355
359
  // 402 round-trip entirely (challenges are single-use and tool-scoped).
356
360
  const challenge = await getDiscoveryJson(`proof-of-work challenge for "${tool.slug}"`, `${BASE}/api/pow/challenge?slug=${encodeURIComponent(tool.slug)}`);
357
361
  const nonce = solvePow(challenge);
358
- res = await fetch(url, { ...init, headers: { ...init.headers, "X-Pow-Solution": `${challenge.token}:${nonce}` } });
362
+ res = await ownFetch(url, { ...init, headers: { ...init.headers, "X-Pow-Solution": `${challenge.token}:${nonce}` } });
359
363
  } else {
360
364
  return { content: [{ type: "text", text: walletRequiredText(tool) }], isError: true };
361
365
  }
package/networks.js CHANGED
@@ -1,4 +1,6 @@
1
- // Network preference for the x402 buyer, pure helpers, no side effects.
1
+ // Network preference for the x402 buyer, pure helpers, no side effects; and
2
+ // the TLS refusal at the end of the file (its only load-time step is a data:
3
+ // URL fetch that makes no connection).
2
4
  //
3
5
  // AGENT402_NETWORKS (comma-separated, e.g. "robinhood" or "base,solana" or a
4
6
  // raw CAIP-2 like "eip155:4663") restricts AND orders which payment options
@@ -6,6 +8,8 @@
6
8
  // underlying @x402 client picks whichever registered scheme matches first,
7
9
  // which on a multi-chain seller is effectively always Base, making rails like
8
10
  // USDG on Robinhood Chain unreachable no matter what the wallet holds.
11
+ import tls from "node:tls";
12
+ import { createHash, X509Certificate } from "node:crypto";
9
13
 
10
14
  export const NETWORK_CAIP2 = {
11
15
  base: "eip155:8453",
@@ -56,3 +60,110 @@ export function withNetworkPreference(client, prefs) {
56
60
  orig({ ...paymentRequired, accepts: filterAcceptsByNetworks(paymentRequired?.accepts, prefs) });
57
61
  return client;
58
62
  }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Transport: refuse TLS chains through a distrusted intermediate CA, for this
66
+ // server's own requests (a dedicated dispatcher, nothing global).
67
+ //
68
+ // The private keys of the intermediate CAs below are recoverable (Mozilla bug
69
+ // 2083449). A cross-signed path makes them validate against "emSign ECC Root
70
+ // CA - G3" in Node's bundled store, and Node checks no revocation by default,
71
+ // so a certificate minted with one of these keys for a seller's host would be
72
+ // accepted, and a forged 402 could name a different payTo. Keyed on the
73
+ // SubjectPublicKeyInfo sha256 of every certificate in the presented chain.
74
+ // The same code as agent402-client's tls-distrust.js (the parent repo's
75
+ // scripts/test-tls-distrust.js holds the copies equal); it lives in this file
76
+ // because the images copy an explicit file list. It applies only when the
77
+ // process-wide dispatcher is still Node's own default (a dispatcher the
78
+ // caller installed, with its proxy, CA or timeouts, is left in charge). It can be removed once the
79
+ // Node releases this package supports ship a CA store that no longer builds
80
+ // the path.
81
+ export const DISTRUSTED_SPKI_SHA256 = Object.freeze({
82
+ "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105": "EM DV TLS CA - G3B",
83
+ "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b": "EM OV TLS CA - G3B",
84
+ "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41": "EM EV TLS CA - G3B",
85
+ "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f": "EM DV TLS CA - G3C",
86
+ "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8": "EM OV TLS CA - G3C",
87
+ "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0": "EM EV TLS CA - G3C",
88
+ "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6": "EM OV CS CA - G3B",
89
+ "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30": "EM OV CS CA - G3C",
90
+ });
91
+
92
+ /** checkServerIdentity that refuses a chain through a listed key (plus any
93
+ * `extraSpki` hex hashes), else defers to Node's hostname check. */
94
+ export function distrustCheckServerIdentity(extraSpki = []) {
95
+ const extra = new Set([...extraSpki].map((h) => String(h).toLowerCase()));
96
+ const nodeCheck = tls.checkServerIdentity;
97
+ return (host, cert) => {
98
+ const seen = [];
99
+ for (let c = cert; c && typeof c === "object" && !seen.includes(c) && seen.length < 16; c = c.issuerCertificate === c ? null : c.issuerCertificate) {
100
+ seen.push(c);
101
+ if (!c.raw) continue;
102
+ let spki;
103
+ try {
104
+ spki = createHash("sha256").update(new X509Certificate(c.raw).publicKey.export({ type: "spki", format: "der" })).digest("hex");
105
+ } catch {
106
+ return Object.assign(new Error(`Refusing the TLS certificate for ${host}: distrusted intermediate check could not read the certificate chain`), { code: "ERR_TLS_DISTRUSTED_INTERMEDIATE" });
107
+ }
108
+ const name = DISTRUSTED_SPKI_SHA256[spki] || (extra.has(spki) ? "listed key" : null);
109
+ if (name) return Object.assign(new Error(`Refusing the TLS certificate for ${host}: distrusted intermediate (${name}) in the certificate chain`), { code: "ERR_TLS_DISTRUSTED_INTERMEDIATE" });
110
+ }
111
+ return nodeCheck(host, cert);
112
+ };
113
+ }
114
+
115
+ const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1");
116
+
117
+ // Node's own default dispatcher, observed when this module loads. Node fills
118
+ // the slot when fetch is first called (synchronously, before any connection),
119
+ // so when the slot is still empty here a data: URL fetch fills it with Node's
120
+ // instance and nothing else can have put it there. When the slot is already
121
+ // filled at load, whoever filled it is unknown and it is treated as the
122
+ // caller's (no dedicated dispatcher), unless another copy of this code (the
123
+ // client and the MCP server share it) observed that same instance as Node's.
124
+ const OBSERVED = Symbol.for("agent402.tlsDistrust.nodeDefaultDispatcher");
125
+ function observeNodeDefault() {
126
+ if (typeof process !== "object" || !process?.versions?.node || process.versions.bun) return null;
127
+ const current = globalThis[GLOBAL_DISPATCHER];
128
+ if (current) return globalThis[OBSERVED] === current ? current : null;
129
+ if (typeof globalThis.fetch !== "function") return null;
130
+ try { globalThis.fetch("data:,").catch(() => {}); } catch { return null; }
131
+ const observed = globalThis[GLOBAL_DISPATCHER] || null;
132
+ if (observed) Object.defineProperty(globalThis, OBSERVED, { value: observed, configurable: false, enumerable: false, writable: false });
133
+ return observed;
134
+ }
135
+ const NODE_DEFAULT_DISPATCHER = observeNodeDefault();
136
+
137
+ /** True when the process-wide dispatcher is not Node's own default: the
138
+ * caller installed one, or its origin is unknown. */
139
+ export function callerDispatcherInstalled() {
140
+ return !NODE_DEFAULT_DISPATCHER || globalThis[GLOBAL_DISPATCHER] !== NODE_DEFAULT_DISPATCHER;
141
+ }
142
+
143
+ /** A dedicated undici Agent carrying the refusal, or null when this platform
144
+ * cannot take one or the caller installed their own global dispatcher (see
145
+ * the header). */
146
+ export async function distrustDispatcher({ extraSpki = [] } = {}) {
147
+ if (callerDispatcherInstalled()) return null;
148
+ const AgentClass = NODE_DEFAULT_DISPATCHER.constructor;
149
+ if (typeof AgentClass !== "function") return null;
150
+ return new AgentClass({ connect: { checkServerIdentity: distrustCheckServerIdentity(extraSpki) } });
151
+ }
152
+
153
+ /** Wrap a fetch so every request it makes goes through distrustDispatcher().
154
+ * With no baseFetch it calls globalThis.fetch as it is at request time. A
155
+ * caller's own init.dispatcher, or a global dispatcher the caller installed
156
+ * (checked on every request), is left alone. Pass the result as the base
157
+ * fetch of a payment wrapper (wrapFetchWithPayment(distrustFetch(), client))
158
+ * so the 402 and the paid retry both go through it. */
159
+ export function distrustFetch(baseFetch, opts = {}) {
160
+ if (baseFetch !== undefined && typeof baseFetch !== "function") throw new Error("distrustFetch needs a fetch function");
161
+ let pending = null;
162
+ return async (input, init = {}) => {
163
+ const f = baseFetch || globalThis.fetch;
164
+ if (callerDispatcherInstalled() || init?.dispatcher !== undefined) return f(input, init);
165
+ pending ||= distrustDispatcher(opts).catch(() => null);
166
+ const dispatcher = await pending;
167
+ return f(input, dispatcher ? { ...init, dispatcher } : init);
168
+ };
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-mcp",
3
- "version": "0.13.14",
3
+ "version": "0.13.15",
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",