agent402-mcp 0.13.3 → 0.13.4

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.
Files changed (2) hide show
  1. package/index.js +84 -8
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -201,6 +201,76 @@ function walletRequiredText(tool) {
201
201
  ].join(" ");
202
202
  }
203
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // What THIS call will actually be asked to pay.
206
+ //
207
+ // The catalog price is the ROUTE's list price, and it is not always the amount
208
+ // in the challenge: a route may quote per request from the body (a token-
209
+ // metered route, or a chat route priced by the model named in it). A cap
210
+ // enforced against the list price is therefore not a cap at all, so both paid
211
+ // paths read the amount out of the 402 before spending anything.
212
+ //
213
+ // The preflight carries no payment header and no credits key, so it can only
214
+ // ever draw an unpaid 402 - it never signs and never debits. Only run when a
215
+ // ceiling is configured: with none, the number changes nothing and the round
216
+ // trip is pure cost.
217
+ const CAPS_CONFIGURED = Number.isFinite(MAX_PER_CALL) || Number.isFinite(BUDGET);
218
+
219
+ /** The accepts array, at the top level or under the envelope keys sellers wrap it in. */
220
+ function acceptsOf(obj) {
221
+ if (!obj || typeof obj !== "object") return null;
222
+ if (Array.isArray(obj.accepts) && obj.accepts.length) return obj.accepts;
223
+ for (const k of ["payment", "x402", "paymentRequired", "payment_required"]) {
224
+ const nested = obj[k];
225
+ if (nested && typeof nested === "object" && Array.isArray(nested.accepts) && nested.accepts.length) return nested.accepts;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ /** The MAXIMUM across accepts: the wallet picks which chain to settle on and
231
+ * this server cannot know which, so the only sound ceiling is the dearest
232
+ * offer. An entry we cannot price makes the whole quote unreadable (null),
233
+ * never a guess. */
234
+ function usdFromAccepts(accepts) {
235
+ if (!Array.isArray(accepts) || !accepts.length) return null;
236
+ let maxUsd = null;
237
+ for (const a of accepts) {
238
+ // v2 names the base-unit amount `amount`; x402 v1 called it `maxAmountRequired`.
239
+ const atomic = Number(a && (a.amount ?? a.maxAmountRequired));
240
+ if (!Number.isFinite(atomic) || atomic < 0) return null;
241
+ // USDC is 6 decimals on every chain these rails settle; a wrong exponent is
242
+ // a 1000x pricing error, so an out-of-range hint reads as unreadable.
243
+ const declared = (a && a.extra && a.extra.decimals) ?? (a && a.decimals);
244
+ const decimals = declared == null ? 6 : Number(declared);
245
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) return null;
246
+ const usd = atomic / 10 ** decimals;
247
+ if (maxUsd == null || usd > maxUsd) maxUsd = usd;
248
+ }
249
+ return maxUsd;
250
+ }
251
+
252
+ /** The quoted price for this request, or `listUsd` when the 402 cannot be read.
253
+ * x402 v2 carries the challenge base64 in the PAYMENT-REQUIRED header and
254
+ * leaves the body `{}`; v1 sellers put it in the body. Header first. */
255
+ async function quotedUsd(url, init, listUsd) {
256
+ if (!CAPS_CONFIGURED) return listUsd;
257
+ try {
258
+ const pre = await fetch(url, init);
259
+ if (pre.status !== 402) return listUsd;
260
+ let quoted = null;
261
+ const header = pre.headers.get("payment-required");
262
+ if (header) {
263
+ try { quoted = usdFromAccepts(acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8")))); } catch { quoted = null; }
264
+ }
265
+ if (quoted == null) {
266
+ let body = null; try { body = await pre.json(); } catch { body = null; }
267
+ quoted = usdFromAccepts(acceptsOf(body));
268
+ }
269
+ // Larger of the two: an understated catalog must not lower the ceiling.
270
+ return quoted == null ? listUsd : Math.max(listUsd, quoted);
271
+ } catch { return listUsd; /* fail-open to the advertised price */ }
272
+ }
273
+
204
274
  async function callEndpoint(tool, args = {}) {
205
275
  const url = new URL(`${BASE}${tool.path}`);
206
276
  const init = { method: tool.method, headers: { Accept: "application/json" } };
@@ -216,14 +286,16 @@ async function callEndpoint(tool, args = {}) {
216
286
 
217
287
  let res;
218
288
  if (!HAS_WALLET && HAS_CREDITS) {
219
- // Card credits: same budget guards as the wallet path (the server enforces
220
- // the balance; these keep a runaway session from draining a pack).
221
- const price = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
289
+ // Card credits: same budget guards as the wallet path, against the same
290
+ // number - the credits gate holds and debits what the 402 quotes for THIS
291
+ // body, not the route's list price.
292
+ const listUsd = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
293
+ const price = await quotedUsd(url, init, listUsd);
222
294
  if (price > MAX_PER_CALL) {
223
- return { content: [{ type: "text", text: `Refused: "${tool.slug}" costs ${tool.price}/call, above the AGENT402_MAX_PER_CALL cap of $${MAX_PER_CALL}.` }], isError: true };
295
+ 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 };
224
296
  }
225
297
  if (spentUsd + price > BUDGET) {
226
- return { content: [{ type: "text", text: `Refused: session budget exhausted ($${spentUsd.toFixed(4)} of $${BUDGET} spent; "${tool.slug}" costs ${tool.price}).` }], isError: true };
298
+ 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 };
227
299
  }
228
300
  res = await fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${CREDITS_KEY}` } });
229
301
  if (res.ok) spentUsd += price;
@@ -232,16 +304,20 @@ async function callEndpoint(tool, args = {}) {
232
304
  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 };
233
305
  }
234
306
  } else if (HAS_WALLET) {
235
- const price = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
307
+ // The wallet signs whatever the challenge asks for (vendor spend controls
308
+ // are off - this package bounds spend itself), so the cap is checked
309
+ // against that amount, read from the 402 before anything is signed.
310
+ const listUsd = parseFloat(String(tool.price).replace(/[^0-9.]/g, "")) || 0;
311
+ const price = await quotedUsd(url, init, listUsd);
236
312
  if (price > MAX_PER_CALL) {
237
313
  return {
238
- content: [{ type: "text", text: `Refused without paying: "${tool.slug}" costs ${tool.price}/call, above the AGENT402_MAX_PER_CALL cap of $${MAX_PER_CALL}. Raise the cap on this MCP server to allow it.` }],
314
+ 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.` }],
239
315
  isError: true,
240
316
  };
241
317
  }
242
318
  if (spentUsd + price > BUDGET) {
243
319
  return {
244
- content: [{ type: "text", text: `Refused without paying: session budget exhausted ($${spentUsd.toFixed(4)} of $${BUDGET} spent; "${tool.slug}" costs ${tool.price}). Restart the MCP server or raise AGENT402_BUDGET.` }],
320
+ 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.` }],
245
321
  isError: true,
246
322
  };
247
323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-mcp",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
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",