verify402-mcp 1.0.3 → 1.0.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.
package/README.md CHANGED
@@ -62,7 +62,7 @@ node index.mjs --help
62
62
  ## AI Agent Integration (Step-by-Step)
63
63
 
64
64
  ### Step 1: Free Mode (Zero Config, No Wallet)
65
- To give your AI agent the free **`preflight_gate`** (checks transaction calldata, payment challenges, payee/amount, approvals, and block freshness before signing), paste this into your client configuration. Honeypot/sellability evidence is the paid `check_token_safety` check, not a free-gate guarantee.
65
+ To give your AI agent the free **`preflight_gate`** (checks transaction calldata, payment challenges, payee/amount, approvals, and block freshness before signing), paste this into your client configuration. The paid `check_token_safety` check adds a quote-based sellability check; it does not execute a transfer and is not a honeypot guarantee.
66
66
 
67
67
  **Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
68
68
  ```json
@@ -142,8 +142,8 @@ Once configured, your agent automatically sees the tools in its context. You can
142
142
 
143
143
  - **Preflight Gate (Free)**:
144
144
  > *"Before signing this transaction, call `preflight_gate` on the payment request / calldata. If it returns BLOCK, abort and explain why."*
145
- - **Token Safety / Sellability Check ($0.10, not a code audit)**:
146
- > *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`. Is it sellable at a realistic size, what is the round-trip cost, and is the result TRUST / CAUTION / AVOID?"*
145
+ - **Token Quote-Based Sellability Check ($0.10, not a code audit)**:
146
+ > *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`. Can it be sold at a realistic size on the configured venues, what is the round-trip cost, and is the result TRUST / CAUTION / AVOID? Read notChecked for transfer restrictions."*
147
147
  - **Arbitrage Verification ($0.05)**:
148
148
  > *"Use `verify_arbitrage` to verify if there is an arbitrage opportunity between Uniswap V3 and Sushiswap for WETH/USDC on chain 1 at the current block."*
149
149
  - **Quote Fairness Audit ($0.05)**:
@@ -154,6 +154,11 @@ Once configured, your agent automatically sees the tools in its context. You can
154
154
  ## Safety
155
155
 
156
156
  - The bridge signs **x402 payments only**. It never signs or broadcasts a trade.
157
+ - Each paid tool call mints one `crypto.randomUUID()` idempotency key and sends it
158
+ as `x-idempotency-key`. A timeout or lost response is retried with the **same**
159
+ key, so the server replays the settled verdict (`recovery.replayed: true`)
160
+ instead of charging twice. A 402 or 409 is never auto-retried; the error is
161
+ surfaced to the MCP client. The key is a secret and is never logged or returned.
157
162
  - Before signing, the paid client requires the 402 to come from the configured
158
163
  server and a known paid route, on an allowlisted EVM network, to the
159
164
  verify402 payee. It accepts only recognized default assets (USDC), caps the
package/index.mjs CHANGED
@@ -29,10 +29,13 @@ import { ExactEvmScheme } from "@x402/evm/exact/client";
29
29
  import { privateKeyToAccount } from "viem/accounts";
30
30
  import { config } from "dotenv";
31
31
  import { z } from "zod";
32
+ import { installConsoleRedaction, logInfo, logError } from "./log.mjs";
33
+ import { paidPost } from "./paid-call.mjs";
32
34
 
35
+ installConsoleRedaction();
33
36
  config();
34
37
 
35
- const VERSION = "1.0.3";
38
+ const VERSION = "1.0.4";
36
39
  const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY;
37
40
  const SERVER_URL = process.env.VERIFY402_URL || "https://verify402.onrender.com";
38
41
  // Tell the server which client made the call. This is a label, not an identity:
@@ -106,7 +109,7 @@ async function createPaidClient() {
106
109
  client.register(network, new ExactEvmScheme(account));
107
110
  }
108
111
  client.onBeforePaymentCreation((context) => policy.checkBeforeSign(context));
109
- console.error(
112
+ logInfo(
110
113
  `paid policy: networks=${policy.allowedNetworks.join(",")} ` +
111
114
  `payee=${policy.allowedPayTo} resources=${policy.resourcePrefix} ` +
112
115
  `{${policy.paidRoutes.join(",")}}`,
@@ -127,7 +130,13 @@ const dailyBudget = createDailyBudget({
127
130
 
128
131
  async function guardedPaidCall(route, run) {
129
132
  if (dailyBudget.enabled) dailyBudget.assert(route);
130
- return run();
133
+ const res = await run();
134
+ if (res?.data?.recovery?.replayed) {
135
+ // Visible to the operator without exposing the bearer key. The same flag
136
+ // is already in the JSON returned to the MCP client.
137
+ logInfo(`${route}: recovery.replayed=true (settled verdict returned without a second charge)`);
138
+ }
139
+ return res;
131
140
  }
132
141
 
133
142
  // ------------------------------------------------------------------ tools ---
@@ -138,26 +147,31 @@ const DESCRIPTIONS = {
138
147
  "the checks that did not, and a short-lived receipt bound to the exact intent + block. " +
139
148
  "If sign_allowed is false, DO NOT SIGN. If requires_ack is true, surface the reasons to the " +
140
149
  "operator before signing. The receipt expires (default 90s): call again rather than reuse it. " +
141
- "Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks.",
150
+ "Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks. For " +
151
+ "kind=transaction, chainId is REQUIRED (400 missing_chain without it). For payment intents " +
152
+ "with no `from` or no proof, the response is PASS with structural_only:true and names what was not checked.",
142
153
  verify_arbitrage:
143
154
  "Ask verify402 whether a cross-venue arbitrage opportunity is REAL, right now. " +
144
155
  "Quotes come from Uniswap's canonical QuoterV2 and each DEX router at one pinned block. " +
145
156
  "The honest answer is usually 'no_edge' - that IS the product. Costs $0.05 per call.",
146
157
  check_token_safety:
147
- "Ask verify402 whether a token is safe to buy BEFORE the agent risks capital. " +
158
+ "Ask verify402 for a quote-based sellability check on a token BEFORE the agent risks capital. " +
148
159
  "Returns TRUST/CAUTION/AVOID with evidence: live contract facts, market liquidity, " +
149
- "a USD-sized sell probe against every venue (honeypot test), round-trip cost at your " +
150
- "intended size, and phantom-price detection. Costs $0.10 per call.",
160
+ "a USD-sized sell QUOTED through the configured venues' own routers (direct and WETH-routed), " +
161
+ "round-trip cost at your intended size, and phantom-price detection. It does NOT execute or " +
162
+ "simulate a transfer: transfer tax, blacklist, pause, and max-tx restrictions are listed in " +
163
+ "notChecked, not tested. Unconfigured venues are also named in notChecked. Costs $0.10 per call.",
151
164
  get_best_route:
152
- "Ask verify402 what the chain would ACTUALLY pay for a swap: the full execution book " +
153
- "(every venue, direct and WETH-routed) at one pinned block, best first. Costs $0.03 per call.",
165
+ "Ask verify402 what the chain would ACTUALLY pay for a swap: the full book from every " +
166
+ "configured venue (direct and WETH-routed) at one pinned block, best first. Aggregators and " +
167
+ "unconfigured DEXs are not included. Costs $0.03 per call.",
154
168
  verify_quote_fairness:
155
169
  "Audit a swap quote someone else gave you (aggregator, broker, bot) BEFORE signing. " +
156
- "Returns fair / overpaying / too_good_to_be_true with the full chain book as evidence. " +
170
+ "Returns fair / overpaying / too_good_to_be_true with the configured-venue book as evidence. " +
157
171
  "Ask this before accepting ANY number you did not produce yourself. Costs $0.05 per call.",
158
172
  get_trade_depth:
159
173
  "Position sizing: measure how much of a token the chain ACTUALLY lets you trade - " +
160
- "entry and exit across every venue at a ladder of USD sizes, per-size impact and " +
174
+ "entry and exit across every configured venue at a ladder of USD sizes, per-size impact and " +
161
175
  "round-trip cost, plus the largest size under your cost tolerance. Ask BEFORE choosing " +
162
176
  "a position size. Costs $0.05 per call.",
163
177
  };
@@ -248,13 +262,13 @@ async function main() {
248
262
  DESCRIPTIONS.verify_arbitrage,
249
263
  {
250
264
  target: z.string().describe('Token to buy and sell back, e.g. "WETH"'),
251
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
265
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
252
266
  base: z.string().optional().describe('Token to trade from, e.g. "USDC" (default USDC)'),
253
267
  size: z.number().optional().describe("Trade size in whole base units, e.g. 100000 (default 100000)"),
254
268
  },
255
269
  async (args) => {
256
- const res = await guardedPaidCall("/verify", () => paidApi.post("/verify", {
257
- chainId: args.chainId ?? 1,
270
+ const res = await guardedPaidCall("/verify", () => paidPost(paidApi, "/verify", {
271
+ chainId: args.chainId,
258
272
  target: String(args.target ?? "WETH").toUpperCase(),
259
273
  base: String(args.base ?? "USDC").toUpperCase(),
260
274
  size: args.size ?? 100000,
@@ -268,13 +282,13 @@ async function main() {
268
282
  DESCRIPTIONS.check_token_safety,
269
283
  {
270
284
  tokenAddress: z.string().describe("ERC-20 contract address, or a known symbol like WETH"),
271
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
285
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
272
286
  intendedUsd: z.number().optional().describe("Size the agent plans to buy, in USD (default 500)"),
273
287
  offeredPriceUsd: z.number().optional().describe("Optional price per token someone offered you"),
274
288
  },
275
289
  async (args) => {
276
- const res = await guardedPaidCall("/safety", () => paidApi.post("/safety", {
277
- chainId: args.chainId ?? 1,
290
+ const res = await guardedPaidCall("/safety", () => paidPost(paidApi, "/safety", {
291
+ chainId: args.chainId,
278
292
  tokenAddress: String(args.tokenAddress ?? "").trim(),
279
293
  intendedUsd: args.intendedUsd ?? 500,
280
294
  offeredPriceUsd: args.offeredPriceUsd ?? null,
@@ -290,11 +304,11 @@ async function main() {
290
304
  tokenIn: z.string().describe("Token symbol (e.g. USDC) or 0x address"),
291
305
  tokenOut: z.string().describe("Token symbol (e.g. WETH) or 0x address"),
292
306
  amountIn: z.string().describe("RAW base units of tokenIn, wei-scale, as a string"),
293
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
307
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
294
308
  },
295
309
  async (args) => {
296
- const res = await guardedPaidCall("/route", () => paidApi.post("/route", {
297
- chainId: args.chainId ?? 1,
310
+ const res = await guardedPaidCall("/route", () => paidPost(paidApi, "/route", {
311
+ chainId: args.chainId,
298
312
  tokenIn: args.tokenIn,
299
313
  tokenOut: args.tokenOut,
300
314
  amountIn: args.amountIn,
@@ -312,11 +326,11 @@ async function main() {
312
326
  amountIn: z.string().describe("RAW units of tokenIn, as in the offer"),
313
327
  quotedOut: z.string().describe("RAW units of tokenOut you were OFFERED"),
314
328
  venueClaimed: z.string().optional().describe("Optional: who gave you the offer"),
315
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
329
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
316
330
  },
317
331
  async (args) => {
318
- const res = await guardedPaidCall("/quotecheck", () => paidApi.post("/quotecheck", {
319
- chainId: args.chainId ?? 1,
332
+ const res = await guardedPaidCall("/quotecheck", () => paidPost(paidApi, "/quotecheck", {
333
+ chainId: args.chainId,
320
334
  tokenIn: args.tokenIn,
321
335
  tokenOut: args.tokenOut,
322
336
  amountIn: args.amountIn,
@@ -332,14 +346,14 @@ async function main() {
332
346
  DESCRIPTIONS.get_trade_depth,
333
347
  {
334
348
  tokenAddress: z.string().describe("Token you plan to buy (symbol or 0x address)"),
335
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
349
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
336
350
  baseSymbol: z.string().optional().describe("What you pay with (default USDC)"),
337
351
  sizesUsd: z.array(z.number()).optional().describe("Ladder of sizes in USD, max 6 (default 100, 1000, 10000, 100000)"),
338
352
  maxRoundTripPct: z.number().optional().describe("Your round-trip cost tolerance, default 1%"),
339
353
  },
340
354
  async (args) => {
341
- const res = await guardedPaidCall("/depth", () => paidApi.post("/depth", {
342
- chainId: args.chainId ?? 1,
355
+ const res = await guardedPaidCall("/depth", () => paidPost(paidApi, "/depth", {
356
+ chainId: args.chainId,
343
357
  tokenAddress: args.tokenAddress,
344
358
  baseSymbol: args.baseSymbol ?? "USDC",
345
359
  sizesUsd: args.sizesUsd ?? null,
@@ -352,7 +366,7 @@ async function main() {
352
366
 
353
367
  const transport = new StdioServerTransport();
354
368
  await server.connect(transport);
355
- console.error(
369
+ logInfo(
356
370
  paidApi
357
371
  ? `verify402 MCP bridge connected -> ${SERVER_URL} (paid tools on, max payment $${MAX_PAYMENT_USD})`
358
372
  : `verify402 MCP bridge connected -> ${SERVER_URL} (free preflight_gate only; set EVM_PRIVATE_KEY for paid tools)`,
@@ -360,6 +374,6 @@ async function main() {
360
374
  }
361
375
 
362
376
  main().catch((e) => {
363
- console.error("bridge failed:", e.message);
377
+ logError("bridge failed", e);
364
378
  process.exit(1);
365
379
  });
package/log.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // Central logging and redaction (MCP bridge copy).
2
+ // Keep in sync with ../src/log.mjs: the MCP bridge is published as a standalone
3
+ // package and cannot import files outside its own package root.
4
+ //
5
+ // Every runtime log line goes through this module. The goal is that an RPC URL
6
+ // path, query string, basic-auth credential, header value, API key, or
7
+ // idempotency key cannot reach stdout/stderr even when the source is a raw
8
+ // viem/HttpRequestError, a stack trace, or request metadata.
9
+ //
10
+ // The public error boundary already keeps these values out of HTTP responses.
11
+ // This module closes the other half: the log sink.
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ const ORIGINAL = {
16
+ log: console.log.bind(console),
17
+ info: console.info.bind(console),
18
+ warn: console.warn.bind(console),
19
+ error: console.error.bind(console),
20
+ debug: console.debug.bind(console),
21
+ };
22
+
23
+ const SENSITIVE_KEY_RE =
24
+ /^(?:x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|token|access[-_]?token|refresh[-_]?token|credential|private[-_]?key|idempotency[-_]?key|x-idempotency-key)$/i;
25
+ const IDEMPOTENCY_KEY_RE = /^(?:x-)?idempotency[-_]?key$/i;
26
+ const HEADER_OR_PARAM_RE =
27
+ /(x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|access[-_]?token|refresh[-_]?token|private[-_]?key)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r,;]+)/gi;
28
+ const IDEMPOTENCY_HEADER_RE =
29
+ /(x-idempotency-key|idempotency-key)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi;
30
+
31
+ let installed = false;
32
+
33
+ function stripQuotes(value) {
34
+ return String(value ?? "").replace(/^["']|["']$/g, "");
35
+ }
36
+
37
+ /** Stable 8-character hash prefix for a caller-supplied idempotency key. */
38
+ export function idempotencyPrefix(value) {
39
+ if (value == null || String(value) === "") return null;
40
+ return `idem:${createHash("sha256").update(String(value)).digest("hex").slice(0, 8)}`;
41
+ }
42
+
43
+ function sanitizeUrl(raw) {
44
+ try {
45
+ const u = new URL(raw);
46
+ if (!/^(?:https?|wss?):$/i.test(u.protocol)) return raw;
47
+ return `${u.protocol}//${u.host}/***`;
48
+ } catch {
49
+ // Best effort for malformed URLs: drop userinfo and everything after the
50
+ // authority, but never echo the original path/query.
51
+ return String(raw)
52
+ .replace(/\/\/[^/@\s]+@/, "//")
53
+ .replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#\s]+).*$/i, "$1/***");
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Redact a string. `idempotencyKey` is optional context: when the caller's key
59
+ * is known, any literal occurrence is replaced with its 8-character hash
60
+ * prefix instead of a generic marker.
61
+ */
62
+ export function redactText(value, { idempotencyKey = null } = {}) {
63
+ let s = String(value ?? "");
64
+ if (idempotencyKey) {
65
+ const key = String(idempotencyKey);
66
+ s = s.split(key).join(idempotencyPrefix(key));
67
+ }
68
+
69
+ // Full URLs: keep scheme + host (including port), replace the rest.
70
+ s = s.replace(/\b(?:https?|wss?):\/\/[^\s"'<>]+/gi, (match) => {
71
+ const trailing = match.match(/[),.;:!?]+$/)?.[0] ?? "";
72
+ const core = trailing ? match.slice(0, -trailing.length) : match;
73
+ return sanitizeUrl(core) + trailing;
74
+ });
75
+
76
+ // Idempotency keys get a stable 8-character hash prefix, not a generic
77
+ // marker, so a replay can still be correlated without exposing the key.
78
+ s = s.replace(IDEMPOTENCY_HEADER_RE, (match, key, sep, rawValue) => {
79
+ const value = stripQuotes(rawValue);
80
+ if (/^idem:[0-9a-f]{8}$/.test(value)) return match;
81
+ return `${key}${sep}${idempotencyPrefix(value)}`;
82
+ });
83
+
84
+ // Other headers and key=value query parameters: redact the whole value.
85
+ // This intentionally over-redacts an inline header line rather than risk
86
+ // leaving a fragment of "Bearer <token>" or a multi-word credential behind.
87
+ s = s.replace(HEADER_OR_PARAM_RE, (match, key, sep) => {
88
+ return `${key}${sep}[REDACTED]`;
89
+ });
90
+
91
+ return s;
92
+ }
93
+
94
+ function safeStringify(value, depth = 0, seen = new WeakSet()) {
95
+ if (value == null) return String(value);
96
+ if (typeof value === "bigint") return value.toString();
97
+ if (typeof value === "function") return "[Function]";
98
+ if (typeof value !== "object") return JSON.stringify(value);
99
+ if (value instanceof Error) return formatError(value);
100
+ if (depth > 5) return "[MaxDepth]";
101
+ if (seen.has(value)) return "[Circular]";
102
+ seen.add(value);
103
+ if (Array.isArray(value)) {
104
+ const out = value.slice(0, 50).map((v) => safeStringify(v, depth + 1, seen));
105
+ if (value.length > 50) out.push(`[+${value.length - 50} more]`);
106
+ return `[${out.join(",")}]`;
107
+ }
108
+ const pairs = [];
109
+ for (const [key, v] of Object.entries(value)) {
110
+ if (SENSITIVE_KEY_RE.test(key)) {
111
+ pairs.push(`${JSON.stringify(key)}:${JSON.stringify(IDEMPOTENCY_KEY_RE.test(key) ? idempotencyPrefix(v) : "[REDACTED]")}`);
112
+ } else {
113
+ pairs.push(`${JSON.stringify(key)}:${safeStringify(v, depth + 1, seen)}`);
114
+ }
115
+ }
116
+ return `{${pairs.join(",")}}`;
117
+ }
118
+
119
+ export function redactValue(value, { idempotencyKey = null } = {}) {
120
+ if (value instanceof Error) return formatError(value, { idempotencyKey });
121
+ if (typeof value === "string") return redactText(value, { idempotencyKey });
122
+ if (value == null || typeof value !== "object") return value;
123
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, { idempotencyKey }));
124
+ const out = {};
125
+ for (const [key, v] of Object.entries(value)) {
126
+ if (SENSITIVE_KEY_RE.test(key)) {
127
+ out[key] = IDEMPOTENCY_KEY_RE.test(key)
128
+ ? idempotencyPrefix(v)
129
+ : "[REDACTED]";
130
+ } else {
131
+ out[key] = redactValue(v, { idempotencyKey });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Format an Error without ever dumping its raw enumerable properties. */
138
+ export function formatError(error, { idempotencyKey = null } = {}) {
139
+ if (error == null) return "";
140
+ if (typeof error === "string") return redactText(error, { idempotencyKey });
141
+ if (!(error instanceof Error)) {
142
+ return redactText(safeStringify(error), { idempotencyKey });
143
+ }
144
+
145
+ const head = redactText(`${error.name || "Error"}: ${error.message || ""}`, { idempotencyKey });
146
+ const cause = error.cause
147
+ ? `\ncause: ${formatError(error.cause, { idempotencyKey })}`
148
+ : "";
149
+ const stack = error.stack
150
+ ? `\n${redactText(error.stack, { idempotencyKey })}`
151
+ : "";
152
+ return `${head}${cause}${stack}`;
153
+ }
154
+
155
+ function emit(level, message, error = null, context = null) {
156
+ const parts = [redactText(message)];
157
+ if (context && typeof context === "object" && Object.keys(context).length) {
158
+ parts.push(redactText(safeStringify(context)));
159
+ }
160
+ const detail = error == null ? "" : formatError(error);
161
+ const line = `${parts.join(" ")}${detail ? `\n${detail}` : ""}`;
162
+ // Use the current console method so tests and embedders can still capture
163
+ // structured events. installConsoleRedaction() makes this idempotent.
164
+ (console[level] || ORIGINAL[level])(line);
165
+ }
166
+
167
+ export function logInfo(message, error = null, context = null) {
168
+ emit("log", message, error, context);
169
+ }
170
+
171
+ export function logWarn(message, error = null, context = null) {
172
+ emit("warn", message, error, context);
173
+ }
174
+
175
+ export function logError(message, error = null, context = null) {
176
+ emit("error", message, error, context);
177
+ }
178
+
179
+ /**
180
+ * Defence in depth: wrap console.* so a future direct console call cannot dump
181
+ * a raw Error or an unredacted URL. Safe to call more than once.
182
+ */
183
+ export function installConsoleRedaction() {
184
+ if (installed) return;
185
+ installed = true;
186
+ for (const level of ["log", "info", "warn", "error", "debug"]) {
187
+ console[level] = (...args) => {
188
+ const safe = args.map((arg) =>
189
+ arg instanceof Error
190
+ ? formatError(arg)
191
+ : typeof arg === "string"
192
+ ? redactText(arg)
193
+ : arg,
194
+ );
195
+ ORIGINAL[level](...safe);
196
+ };
197
+ }
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verify402-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "mcpName": "io.github.a32116150-ctrl/verify402-mcp",
5
5
  "description": "MCP server for verify402: a free, fail-closed preflight gate before signing, plus five paid on-chain checks paid per call via x402.",
6
6
  "type": "module",
@@ -12,6 +12,8 @@
12
12
  "index.mjs",
13
13
  "budget.mjs",
14
14
  "payment-policy.mjs",
15
+ "paid-call.mjs",
16
+ "log.mjs",
15
17
  "README.md",
16
18
  ".env.example",
17
19
  "LICENSE",
@@ -52,8 +54,8 @@
52
54
  },
53
55
  "dependencies": {
54
56
  "@modelcontextprotocol/sdk": "^1.12.1",
55
- "@x402/axios": "^2.28.0",
56
- "@x402/evm": "^2.28.0",
57
+ "@x402/axios": "2.28.0",
58
+ "@x402/evm": "2.28.0",
57
59
  "axios": "^1.13.2",
58
60
  "dotenv": "^16.4.5",
59
61
  "viem": "^2.39.0",
package/paid-call.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // One paid operation = one idempotency key, reused on every retry.
2
+ //
3
+ // The key is a bearer credential (see README/recovery docs), so it is never
4
+ // logged or returned to the MCP client. The bridge only needs it to survive a
5
+ // lost response: the server returns the settled verdict with
6
+ // recovery.replayed=true instead of charging a second time.
7
+ //
8
+ // Retry rules:
9
+ // * network/timeout/5xx-gateway failures: retry with the SAME key
10
+ // * 402: never retry here (the x402 wrapper owns the payment handshake)
11
+ // * 409: never retry (a conflicting body will not become valid)
12
+
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ export const DEFAULT_RETRIES = 2;
16
+
17
+ const RETRYABLE_CODES = new Set([
18
+ "ECONNABORTED",
19
+ "ECONNRESET",
20
+ "EHOSTUNREACH",
21
+ "ENETUNREACH",
22
+ "EPIPE",
23
+ "ETIMEDOUT",
24
+ "EAI_AGAIN",
25
+ "ERR_NETWORK",
26
+ "UND_ERR_CONNECT_TIMEOUT",
27
+ "UND_ERR_HEADERS_TIMEOUT",
28
+ "UND_ERR_BODY_TIMEOUT",
29
+ "UND_ERR_SOCKET",
30
+ ]);
31
+
32
+ const RETRYABLE_STATUS = new Set([502, 503, 504]);
33
+
34
+ export function isRetryable(error) {
35
+ const status = error?.response?.status;
36
+ if (status != null) return RETRYABLE_STATUS.has(status);
37
+ if (RETRYABLE_CODES.has(error?.code)) return true;
38
+ // Axios tags transport failures as isAxiosError with no response. Anything
39
+ // else (bad config, programmer error) is not retried.
40
+ return error?.isAxiosError === true;
41
+ }
42
+
43
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
44
+
45
+ /**
46
+ * POST a paid operation with one generated idempotency key.
47
+ *
48
+ * @param {{post: Function}} paidApi - x402-wrapped axios instance (or a fake)
49
+ * @param {string} route
50
+ * @param {object} body
51
+ * @param {{idempotencyKey?: string, retries?: number, sleep?: Function}} options
52
+ * @returns {Promise<object>} the final axios response
53
+ */
54
+ export async function paidPost(paidApi, route, body, {
55
+ idempotencyKey = randomUUID(),
56
+ retries = DEFAULT_RETRIES,
57
+ sleep = delay,
58
+ } = {}) {
59
+ let lastError;
60
+ for (let attempt = 0; attempt <= retries; attempt++) {
61
+ try {
62
+ return await paidApi.post(route, body, {
63
+ headers: { "x-idempotency-key": idempotencyKey },
64
+ });
65
+ } catch (error) {
66
+ lastError = error;
67
+ if (!isRetryable(error) || attempt === retries) throw error;
68
+ // Keep the same key: a retry after a lost response must replay, not pay.
69
+ await sleep(250 * (attempt + 1));
70
+ }
71
+ }
72
+ throw lastError;
73
+ }