@quackai/q402-mcp 0.8.22 → 0.8.23

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/dist/index.js +126 -12
  2. package/package.json +73 -75
package/dist/index.js CHANGED
@@ -211,7 +211,7 @@ var isValidPrivateKey = (s) => typeof s === "string" && PRIVATE_KEY_RE.test(s);
211
211
  // package.json
212
212
  var package_default = {
213
213
  name: "@quackai/q402-mcp",
214
- version: "0.8.22",
214
+ version: "0.8.23",
215
215
  description: "MCP server for Q402 \u2014 gasless USDC/USDT/RLUSD payments on 10 EVM chains + Chainlink CCIP USDC bridge on the eth/avax/arbitrum triangle, callable from Claude (Desktop / Code), OpenAI Codex CLI, and any other Model Context Protocol client.",
216
216
  mcpName: "io.github.bitgett/q402-mcp",
217
217
  keywords: [
@@ -262,7 +262,7 @@ var package_default = {
262
262
  },
263
263
  devDependencies: {
264
264
  "@types/node": "^20.11.0",
265
- tsup: "^8.3.0",
265
+ tsup: "^8.5.1",
266
266
  typescript: "^5.5.0"
267
267
  },
268
268
  repository: {
@@ -279,9 +279,7 @@ var package_default = {
279
279
  access: "public"
280
280
  },
281
281
  overrides: {
282
- ws: "^8.20.1",
283
- qs: "^6.15.2",
284
- hono: "^4.12.21"
282
+ esbuild: "^0.28.1"
285
283
  }
286
284
  };
287
285
 
@@ -927,6 +925,29 @@ function sandboxPay(chain, input) {
927
925
  };
928
926
  }
929
927
 
928
+ // src/consent.ts
929
+ import { sha256, toUtf8Bytes } from "ethers";
930
+ function canonicalIntent(intent) {
931
+ const sortValue = (v) => {
932
+ if (Array.isArray(v)) return v.map(sortValue);
933
+ if (v && typeof v === "object") {
934
+ const src = v;
935
+ const out = {};
936
+ for (const k of Object.keys(src).sort()) out[k] = sortValue(src[k]);
937
+ return out;
938
+ }
939
+ return v;
940
+ };
941
+ return JSON.stringify(sortValue(intent));
942
+ }
943
+ function consentTokenFor(intent) {
944
+ return "ct_" + sha256(toUtf8Bytes(canonicalIntent(intent))).slice(2, 18);
945
+ }
946
+ function checkConsent(intent, provided) {
947
+ const expected = consentTokenFor(intent);
948
+ return { ok: provided === expected, expected };
949
+ }
950
+
930
951
  // src/tools/pay.ts
931
952
  var PayInputSchema = z2.object({
932
953
  chain: z2.enum(["avax", "bnb", "eth", "xlayer", "stable", "mantle", "injective", "monad", "scroll", "arbitrum"]),
@@ -951,6 +972,9 @@ When MORE THAN ONE wallet is configured in the user's environment, you MUST ask
951
972
  confirm: z2.literal(true).describe(
952
973
  "MUST be true. Prove the user explicitly approved this exact payment in the conversation right before this tool was called. When hookParams is set you MUST confirm what it actually does to the money: the split RECIPIENTS and their shares (funds go to those addresses, not `to`), and any oracle condition gating the settlement \u2014 not just the top-level recipient and amount. Setting this to true on behalf of the user without that confirmation is a violation of the tool contract."
953
974
  ),
975
+ consentToken: z2.string().optional().describe(
976
+ 'Two-phase consent. LEAVE THIS UNSET on the first call: the tool will NOT send \u2014 it returns status="needs_confirmation" with a human-readable `preview` of the exact payment and a `consentToken`. Relay that preview to the user verbatim, get their explicit yes, then call again with the SAME args plus this `consentToken`. The tool re-derives the token from the params it is about to execute and refuses on mismatch, so you cannot preview one payment and execute another. Never fabricate a token.'
977
+ ),
954
978
  hookParams: z2.object({
955
979
  recipientAgentId: z2.string().optional().describe("ReputationGate: the recipient's ERC-8004 agent id."),
956
980
  condition: z2.object({
@@ -1086,6 +1110,29 @@ async function runPay(input) {
1086
1110
  if (CONFIG.allowedRecipients.length > 0) {
1087
1111
  guardsApplied.push(`recipient_allowlist[${CONFIG.allowedRecipients.length}]`);
1088
1112
  }
1113
+ const consentIntent = {
1114
+ t: "pay",
1115
+ chain: input.chain,
1116
+ to: input.to.toLowerCase(),
1117
+ amount: input.amount,
1118
+ token: input.token,
1119
+ ...input.hookParams?.splits ? { splits: input.hookParams.splits.map((s) => ({ r: s.recipient.toLowerCase(), bps: s.bps })) } : {}
1120
+ };
1121
+ const consent = checkConsent(consentIntent, input.consentToken);
1122
+ if (!consent.ok) {
1123
+ const splitNote = input.hookParams?.splits ? ` \u2014 split ${input.hookParams.splits.length} ways; funds go to the split recipients, not ${input.to}` : "";
1124
+ const fromNote = senderWallet ? ` from ${senderWallet.addressShort}` : "";
1125
+ return {
1126
+ result: failureResult("consent"),
1127
+ guardsApplied: [...guardsApplied, "two_phase_consent"],
1128
+ senderWallet,
1129
+ needsConsent: {
1130
+ status: "needs_confirmation",
1131
+ preview: `Send ${input.amount} ${input.token} to ${input.to} on ${chain.key}${fromNote}${splitNote}. Confirm with the user, then re-call q402_pay with the same args plus consentToken="${consent.expected}".`,
1132
+ consentToken: consent.expected
1133
+ }
1134
+ };
1135
+ }
1089
1136
  const scopeRequest = input.keyScope ?? "auto";
1090
1137
  const resolved = resolveApiKey(input.chain, scopeRequest);
1091
1138
  guardsApplied.push(`scope=${resolved.scope}${resolved.fromLegacyFallback ? "(legacy)" : ""}`);
@@ -1427,9 +1474,13 @@ var BatchPayInputSchema = z3.object({
1427
1474
  ),
1428
1475
  confirm: z3.literal(true).describe(
1429
1476
  "MUST be true. The user must have explicitly approved this exact set of recipients, amounts, chain, and token in the conversation right before this tool was called. Setting confirm=true on behalf of the user without that approval is a violation of the tool contract."
1477
+ ),
1478
+ consentToken: z3.string().optional().describe(
1479
+ 'Two-phase consent. LEAVE UNSET on the first call: the tool will NOT send \u2014 it returns status="needs_confirmation" with a `setupHint` preview of every recipient + amount and a `consentToken`. Relay that preview to the user, get an explicit yes, then re-call with the SAME args plus this `consentToken`. The tool re-derives it from the batch it is about to send and refuses on mismatch, so you cannot preview one batch and execute another. Never fabricate a token.'
1430
1480
  )
1431
1481
  });
1432
1482
  function maxAmountGuardBatch(recipients, cap) {
1483
+ let total = 0;
1433
1484
  for (let i = 0; i < recipients.length; i++) {
1434
1485
  const r = recipients[i];
1435
1486
  const numeric = Number(r.amount);
@@ -1441,6 +1492,12 @@ function maxAmountGuardBatch(recipients, cap) {
1441
1492
  `recipients[${i}]: amount $${r.amount} exceeds the per-call cap of $${cap}. Set Q402_MAX_AMOUNT_PER_CALL to a higher value if intentional.`
1442
1493
  );
1443
1494
  }
1495
+ total += numeric;
1496
+ }
1497
+ if (total > cap) {
1498
+ throw new Error(
1499
+ `batch total $${total.toFixed(2)} across ${recipients.length} recipients exceeds the per-call cap of $${cap}. Q402_MAX_AMOUNT_PER_CALL bounds the WHOLE batch, not each row. Raise the cap if this batch is intentional, or split it into smaller batches.`
1500
+ );
1444
1501
  }
1445
1502
  }
1446
1503
  function recipientAllowlistGuardBatch(recipients, allow) {
@@ -1464,11 +1521,31 @@ async function runBatchPay(input) {
1464
1521
  }
1465
1522
  const guardsApplied = [];
1466
1523
  maxAmountGuardBatch(input.recipients, CONFIG.maxAmountPerCallUsd);
1467
- guardsApplied.push(`max_amount<=${CONFIG.maxAmountPerCallUsd} (per recipient)`);
1524
+ guardsApplied.push(`max_amount<=${CONFIG.maxAmountPerCallUsd} (per row AND batch total)`);
1468
1525
  recipientAllowlistGuardBatch(input.recipients, CONFIG.allowedRecipients);
1469
1526
  if (CONFIG.allowedRecipients.length > 0) {
1470
1527
  guardsApplied.push(`recipient_allowlist[${CONFIG.allowedRecipients.length}]`);
1471
1528
  }
1529
+ const consentIntent = {
1530
+ t: "batch",
1531
+ chain: input.chain,
1532
+ token: input.token,
1533
+ recipients: input.recipients.map((r) => ({ to: r.to.toLowerCase(), amount: r.amount }))
1534
+ };
1535
+ const consent = checkConsent(consentIntent, input.consentToken);
1536
+ if (!consent.ok) {
1537
+ const total = input.recipients.reduce((s, r) => s + Number(r.amount), 0);
1538
+ const lines = input.recipients.map((r, i) => ` ${i + 1}. ${r.amount} ${input.token} -> ${r.to}`).join("\n");
1539
+ return {
1540
+ mode: "none",
1541
+ status: "needs_confirmation",
1542
+ guardsApplied: [...guardsApplied, "two_phase_consent"],
1543
+ consentToken: consent.expected,
1544
+ setupHint: `Batch on ${input.chain}: ${input.recipients.length} recipients, total ${total} ${input.token}.
1545
+ ${lines}
1546
+ Confirm the full list with the user, then re-call q402_batch_pay with the same args plus consentToken="${consent.expected}".`
1547
+ };
1548
+ }
1472
1549
  const modes = detectAgenticModes(CONFIG);
1473
1550
  const available = [];
1474
1551
  if (modes.modeA && CONFIG.privateKey && isValidPrivateKey(CONFIG.privateKey)) {
@@ -1953,7 +2030,7 @@ var BALANCE_TOOL = {
1953
2030
 
1954
2031
  // src/tools/receipt.ts
1955
2032
  import { z as z5 } from "zod";
1956
- import { keccak256, toUtf8Bytes, getBytes, verifyMessage } from "ethers";
2033
+ import { keccak256, toUtf8Bytes as toUtf8Bytes2, getBytes, verifyMessage } from "ethers";
1957
2034
  var ReceiptShape = z5.object({
1958
2035
  receiptId: z5.string(),
1959
2036
  createdAt: z5.string(),
@@ -1994,8 +2071,9 @@ function canonicalize(fields) {
1994
2071
  return JSON.stringify(sorted);
1995
2072
  }
1996
2073
  function digest(canonical) {
1997
- return keccak256(toUtf8Bytes(canonical));
2074
+ return keccak256(toUtf8Bytes2(canonical));
1998
2075
  }
2076
+ var RELAYER_SIGNER = "0xfc77ff29178b7286a8ba703d7a70895ca74ff466";
1999
2077
  function verifyReceiptSignature(r) {
2000
2078
  try {
2001
2079
  const fields = {
@@ -2012,7 +2090,7 @@ function verifyReceiptSignature(r) {
2012
2090
  sandbox: r.sandbox
2013
2091
  };
2014
2092
  const recovered = verifyMessage(getBytes(digest(canonicalize(fields))), r.signature).toLowerCase();
2015
- return recovered === r.signedBy.toLowerCase();
2093
+ return recovered === RELAYER_SIGNER && r.signedBy.toLowerCase() === RELAYER_SIGNER;
2016
2094
  } catch {
2017
2095
  return false;
2018
2096
  }
@@ -3091,6 +3169,9 @@ var BridgeSendInputSchema = z11.object({
3091
3169
  maxFeeRaw: z11.string().regex(/^\d+$/).optional().describe("Optional client-side fee cap in raw 18-dec wei. Server still clamps to its 10% slippage ceiling; clients may LOWER but not RAISE."),
3092
3170
  confirm: z11.boolean().optional().describe(
3093
3171
  "MUST be true to fire a LIVE bridge (ignored in sandbox). Set this only after the user has explicitly approved this exact bridge (src, dst, amount, feeToken) in the conversation. When omitted or false on a live call the tool previews the action and does NOT move any funds. Never set confirm:true on the user's behalf without approval."
3172
+ ),
3173
+ consentToken: z11.string().optional().describe(
3174
+ "Two-phase consent. LEAVE UNSET on the first live call: the tool previews the bridge (without moving funds) and returns a `consentToken`. Relay the preview to the user, get an explicit yes, then re-call with sandbox:false, confirm:true, AND this consentToken. The tool re-derives it from the bridge it is about to execute (src, dst, amount, feeToken) and refuses on mismatch."
3094
3175
  )
3095
3176
  }).refine((d) => d.src !== d.dst, {
3096
3177
  // Local Zod rejection saves a network round-trip + a Q402 backend log
@@ -3141,6 +3222,10 @@ var BRIDGE_SEND_TOOL = {
3141
3222
  confirm: {
3142
3223
  type: "boolean",
3143
3224
  description: "MUST be true to fire a LIVE bridge (ignored in sandbox) \u2014 set only after the user explicitly approved this exact bridge in chat. Omit (or false) on a live call to preview without moving funds."
3225
+ },
3226
+ consentToken: {
3227
+ type: "string",
3228
+ description: "Two-phase consent token. Leave unset on the first live call to get a preview + token; re-call with confirm:true AND this token after the user approves. Bound to (src, dst, amount, feeToken) \u2014 re-derived server-side-of-the-tool and refused on mismatch."
3144
3229
  }
3145
3230
  },
3146
3231
  required: ["src", "dst", "amount"]
@@ -3170,13 +3255,21 @@ async function runBridgeSend(input) {
3170
3255
  }]
3171
3256
  };
3172
3257
  }
3173
- if (input.confirm !== true) {
3258
+ const consentIntent = {
3259
+ t: "bridge",
3260
+ src: input.src,
3261
+ dst: input.dst,
3262
+ amount: input.amount,
3263
+ feeToken: input.feeToken === "native" ? "native" : "LINK"
3264
+ };
3265
+ const consent = checkConsent(consentIntent, input.consentToken);
3266
+ if (input.confirm !== true || !consent.ok) {
3174
3267
  const walletDesc = typeof input.walletId === "string" && input.walletId.length > 0 ? `wallet ${input.walletId.toLowerCase()}` : "your default Agent Wallet";
3175
3268
  const fee = input.feeToken === "native" ? "native" : "LINK";
3176
3269
  return {
3177
3270
  content: [{
3178
3271
  type: "text",
3179
- text: `Will bridge ${input.amount} raw USDC units from ${input.src} \u2192 ${input.dst} via Chainlink CCIP from ${walletDesc} (fee paid in ${fee}). This MOVES FUNDS on-chain. Re-call with confirm:true to execute.`
3272
+ text: `Will bridge ${input.amount} raw USDC units from ${input.src} -> ${input.dst} via Chainlink CCIP from ${walletDesc} (fee paid in ${fee}). This MOVES FUNDS on-chain. Confirm with the user, then re-call with sandbox:false, confirm:true, AND consentToken="${consent.expected}".`
3180
3273
  }]
3181
3274
  };
3182
3275
  }
@@ -4007,7 +4100,7 @@ var RecurringCreateInputSchema = z19.object({
4007
4100
  });
4008
4101
  var RECURRING_CREATE_TOOL = {
4009
4102
  name: "q402_recurring_create",
4010
- description: "Author a new recurring-payment rule on the user's Agent Wallet. Single-recipient (use the dashboard for multi-recipient payroll). Pick a cadence \u2014 hourly:N, daily, weekly:{day}, monthly:N, or monthly:last \u2014 and a recipient + amount + chain + token. Authenticated by the configured Multichain API key; no private key required. Recurring requires the paid Multichain subscription on EVERY chain including bnb \u2014 trial keys are rejected at create time with MULTICHAIN_REQUIRED and should keep using q402_pay for one-shot Trial sends. Each fire is bounded by the wallet's perTxMax (configured on the dashboard) \u2014 the dashboard's dailyLimit cap currently applies to manual sends only, NOT recurring fires, so an attacker with the apiKey could schedule N rules at perTxMax and drain the wallet's USDC balance over time. The user can stop a rule any time via q402_recurring_cancel.",
4103
+ description: "Author a new recurring-payment rule on the user's Agent Wallet. Single-recipient (use the dashboard for multi-recipient payroll). Pick a cadence \u2014 hourly:N, daily, weekly:{day}, monthly:N, or monthly:last \u2014 and a recipient + amount + chain + token. Authenticated by the configured Multichain API key; no private key required. Recurring requires the paid Multichain subscription on EVERY chain including bnb \u2014 trial keys are rejected at create time with MULTICHAIN_REQUIRED and should keep using q402_pay for one-shot Trial sends. Each fire is bounded server-side by BOTH the wallet's perTxMax AND its dailyLimit \u2014 a rule's daily total reserves against the same daily bucket as manual sends (the scheduler skips the fire if the bucket can't cover it), so scheduled rules can't outrun the dashboard caps. This tool also enforces your local Q402_MAX_AMOUNT_PER_CALL + Q402_ALLOWED_RECIPIENTS rails at create time. The user can stop a rule any time via q402_recurring_cancel.",
4011
4104
  inputSchema: {
4012
4105
  type: "object",
4013
4106
  properties: {
@@ -4082,6 +4175,27 @@ async function runRecurringCreate(input) {
4082
4175
  dashboardUrl
4083
4176
  };
4084
4177
  }
4178
+ const amountNum = Number(input.amount);
4179
+ if (Number.isFinite(amountNum) && amountNum > CONFIG.maxAmountPerCallUsd) {
4180
+ return {
4181
+ ok: false,
4182
+ walletId: null,
4183
+ rule: null,
4184
+ error: "AMOUNT_EXCEEDS_CAP",
4185
+ message: `Per-fire amount $${input.amount} exceeds your Q402_MAX_AMOUNT_PER_CALL cap of $${CONFIG.maxAmountPerCallUsd}. Each recurring fire is bounded by the same per-call cap as a one-shot q402_pay \u2014 raise the cap if this schedule is intentional.`,
4186
+ dashboardUrl
4187
+ };
4188
+ }
4189
+ if (CONFIG.allowedRecipients.length > 0 && !CONFIG.allowedRecipients.includes(input.recipient.toLowerCase())) {
4190
+ return {
4191
+ ok: false,
4192
+ walletId: null,
4193
+ rule: null,
4194
+ error: "RECIPIENT_NOT_ALLOWED",
4195
+ message: `Recipient ${input.recipient} is not in Q402_ALLOWED_RECIPIENTS. A recurring rule would send to it on every fire \u2014 add it to the allowlist or unset the env var to disable the guard.`,
4196
+ dashboardUrl
4197
+ };
4198
+ }
4085
4199
  const explicitWalletId = typeof input.walletId === "string" && input.walletId.length > 0 ? input.walletId.toLowerCase() : CONFIG.walletId;
4086
4200
  try {
4087
4201
  const res = await fetch(`${base}/wallet/agentic/recurring-by-key`, {
package/package.json CHANGED
@@ -1,75 +1,73 @@
1
- {
2
- "name": "@quackai/q402-mcp",
3
- "version": "0.8.22",
4
- "description": "MCP server for Q402 — gasless USDC/USDT/RLUSD payments on 10 EVM chains + Chainlink CCIP USDC bridge on the eth/avax/arbitrum triangle, callable from Claude (Desktop / Code), OpenAI Codex CLI, and any other Model Context Protocol client.",
5
- "mcpName": "io.github.bitgett/q402-mcp",
6
- "keywords": [
7
- "mcp",
8
- "model-context-protocol",
9
- "claude",
10
- "claude-desktop",
11
- "claude-code",
12
- "codex",
13
- "openai-codex",
14
- "cline",
15
- "q402",
16
- "x402",
17
- "stablecoin",
18
- "usdc",
19
- "usdt",
20
- "rlusd",
21
- "ripple",
22
- "gasless",
23
- "eip-7702",
24
- "payments",
25
- "ai-agents"
26
- ],
27
- "type": "module",
28
- "main": "dist/index.js",
29
- "bin": {
30
- "q402-mcp": "dist/index.js"
31
- },
32
- "files": [
33
- "dist",
34
- "README.md",
35
- "LICENSE"
36
- ],
37
- "engines": {
38
- "node": ">=18.18"
39
- },
40
- "scripts": {
41
- "build": "tsup",
42
- "dev": "tsup --watch",
43
- "lint": "tsc --noEmit",
44
- "prepublishOnly": "npm run lint && npm run build",
45
- "start": "node dist/index.js"
46
- },
47
- "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.29.0",
49
- "ethers": "^6.16.0",
50
- "zod": "^3.23.8"
51
- },
52
- "devDependencies": {
53
- "@types/node": "^20.11.0",
54
- "tsup": "^8.3.0",
55
- "typescript": "^5.5.0"
56
- },
57
- "repository": {
58
- "type": "git",
59
- "url": "git+https://github.com/bitgett/q402-mcp.git"
60
- },
61
- "homepage": "https://q402.quackai.ai/claude",
62
- "bugs": {
63
- "url": "https://github.com/bitgett/q402-mcp/issues"
64
- },
65
- "license": "Apache-2.0",
66
- "author": "David Lee <davidlee@quackai.ai>",
67
- "publishConfig": {
68
- "access": "public"
69
- },
70
- "overrides": {
71
- "ws": "^8.20.1",
72
- "qs": "^6.15.2",
73
- "hono": "^4.12.21"
74
- }
75
- }
1
+ {
2
+ "name": "@quackai/q402-mcp",
3
+ "version": "0.8.23",
4
+ "description": "MCP server for Q402 — gasless USDC/USDT/RLUSD payments on 10 EVM chains + Chainlink CCIP USDC bridge on the eth/avax/arbitrum triangle, callable from Claude (Desktop / Code), OpenAI Codex CLI, and any other Model Context Protocol client.",
5
+ "mcpName": "io.github.bitgett/q402-mcp",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "claude",
10
+ "claude-desktop",
11
+ "claude-code",
12
+ "codex",
13
+ "openai-codex",
14
+ "cline",
15
+ "q402",
16
+ "x402",
17
+ "stablecoin",
18
+ "usdc",
19
+ "usdt",
20
+ "rlusd",
21
+ "ripple",
22
+ "gasless",
23
+ "eip-7702",
24
+ "payments",
25
+ "ai-agents"
26
+ ],
27
+ "type": "module",
28
+ "main": "dist/index.js",
29
+ "bin": {
30
+ "q402-mcp": "dist/index.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18.18"
39
+ },
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "dev": "tsup --watch",
43
+ "lint": "tsc --noEmit",
44
+ "prepublishOnly": "npm run lint && npm run build",
45
+ "start": "node dist/index.js"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "ethers": "^6.16.0",
50
+ "zod": "^3.23.8"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^20.11.0",
54
+ "tsup": "^8.5.1",
55
+ "typescript": "^5.5.0"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/bitgett/q402-mcp.git"
60
+ },
61
+ "homepage": "https://q402.quackai.ai/claude",
62
+ "bugs": {
63
+ "url": "https://github.com/bitgett/q402-mcp/issues"
64
+ },
65
+ "license": "Apache-2.0",
66
+ "author": "David Lee <davidlee@quackai.ai>",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "overrides": {
71
+ "esbuild": "^0.28.1"
72
+ }
73
+ }