@quackai/q402-mcp 0.5.6 → 0.5.7

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 (3) hide show
  1. package/README.md +3 -6
  2. package/dist/index.js +75 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -136,7 +136,7 @@ By default the MCP server operates in **sandbox mode**: `q402_pay` returns a ran
136
136
  To enable real on-chain transactions, the resolved API key must be live (`q402_live_*`), `Q402_PRIVATE_KEY` must be set, and `Q402_ENABLE_REAL_PAYMENTS=1`:
137
137
 
138
138
  ```bash
139
- # Two-key model — set whichever applies. Both is best.
139
+ # Two-key model — set whichever applies (or both for auto-routing).
140
140
  # Auto-routing (same for q402_pay AND q402_batch_pay):
141
141
  # chain="bnb" + Q402_TRIAL_API_KEY set → Trial (free sponsored)
142
142
  # anything else → Multichain (paid 9-chain)
@@ -146,10 +146,6 @@ To enable real on-chain transactions, the resolved API key must be live (`q402_l
146
146
  Q402_TRIAL_API_KEY=q402_live_... # BNB-only sponsored Trial key (from /event)
147
147
  Q402_MULTICHAIN_API_KEY=q402_live_... # paid 9-chain key (per-chain Gas Tank)
148
148
 
149
- # Legacy fallback. Used for both scopes when the two above are unset —
150
- # single-env setups (only Q402_API_KEY set) keep working unchanged.
151
- Q402_API_KEY=q402_live_...
152
-
153
149
  Q402_PRIVATE_KEY=0xabc... # signer for the payer EOA
154
150
  Q402_ENABLE_REAL_PAYMENTS=1 # explicit opt-in
155
151
  ```
@@ -179,13 +175,14 @@ Combined with the `confirm: true` argument the tool requires, this means the mod
179
175
  |---|---|---|
180
176
  | `Q402_TRIAL_API_KEY` | live-pay (BNB) | BNB-only sponsored Trial key. Free at https://q402.quackai.ai/event. Auto-routed for `chain="bnb"` in both `q402_pay` and `q402_batch_pay` (≤5 recipients) when set. 6+ recipient BNB batches return `status="ambiguous"` so the agent can ask the user how to split. |
181
177
  | `Q402_MULTICHAIN_API_KEY` | live-pay (9-chain) | Paid 9-chain key. Get one at https://q402.quackai.ai/payment. Auto-routed for non-BNB chains AND for BNB when no Trial key is set. Cap: 20 recipients per batch. |
182
- | `Q402_API_KEY` | legacy fallback | Single-env legacy path. Used for both scopes when the two above are unset. Keep set if you only have one key. |
183
178
  | `Q402_PRIVATE_KEY` | live-pay | Signer for the payer EOA. **Never share. Never paste in chat.** |
184
179
  | `Q402_ENABLE_REAL_PAYMENTS` | live-pay | Set to `1` to opt in. Any other value (or unset) → sandbox. |
185
180
  | `Q402_MAX_AMOUNT_PER_CALL` | optional | USD-equivalent cap. Defaults to `5`. |
186
181
  | `Q402_ALLOWED_RECIPIENTS` | optional | Comma-separated lowercase addresses. Defaults to no allowlist. |
187
182
  | `Q402_RELAY_BASE_URL` | optional | Defaults to `https://q402.quackai.ai/api`. Override for self-hosted Q402. |
188
183
 
184
+ > Older integrations may still set `Q402_API_KEY` as a single-env fallback — that still works silently for back-compat. New setups should use the two-key model above; `q402_doctor` only guides users to those two.
185
+
189
186
  ---
190
187
 
191
188
  ## Supported chains
package/dist/index.js CHANGED
@@ -46,8 +46,15 @@ function loadQ402EnvFileFromPath(path) {
46
46
  if (eq < 0) continue;
47
47
  const k = t.slice(0, eq).trim();
48
48
  if (!k.startsWith("Q402_")) continue;
49
- const v = t.slice(eq + 1).trim().replace(/^['"](.*)['"]$/, "$1");
50
- out[k] = v;
49
+ let rawVal = t.slice(eq + 1).trim();
50
+ const quoted = /^(['"])(.*)\1\s*(?:#.*)?$/.exec(rawVal);
51
+ if (quoted) {
52
+ rawVal = quoted[2];
53
+ } else {
54
+ const hashIdx = rawVal.search(/\s#/);
55
+ if (hashIdx >= 0) rawVal = rawVal.slice(0, hashIdx).trimEnd();
56
+ }
57
+ out[k] = rawVal;
51
58
  }
52
59
  return out;
53
60
  }
@@ -66,6 +73,9 @@ var Q402_ENV_FILE_KEYS = Object.freeze(
66
73
  Object.keys(FILE_ENV).filter((k) => process.env[k] === void 0)
67
74
  )
68
75
  );
76
+ var Q402_ENV_FILE_KEYS_ALL = Object.freeze(
77
+ new Set(Object.keys(FILE_ENV))
78
+ );
69
79
  var DEFAULT_RELAY_BASE = "https://q402.quackai.ai/api";
70
80
  var DEFAULT_MAX_AMOUNT = 5;
71
81
  function classifyApiKey(k) {
@@ -146,16 +156,19 @@ function resolveApiKey(chain, scope = "auto") {
146
156
  }
147
157
  return { apiKey: key, scope: "multichain", fromLegacyFallback: !CONFIG.multichainApiKey };
148
158
  }
159
+ var PRIVATE_KEY_RE = /^0x[a-fA-F0-9]{64}$/;
149
160
  function isLiveModeFor(resolved) {
150
161
  if (!resolved.apiKey) return false;
151
162
  if (!CONFIG.realPaymentsRequested) return false;
152
163
  if (!CONFIG.privateKey) return false;
164
+ if (!PRIVATE_KEY_RE.test(CONFIG.privateKey)) return false;
153
165
  return resolved.apiKey.startsWith("q402_live_");
154
166
  }
167
+ var isValidPrivateKey = (s) => typeof s === "string" && PRIVATE_KEY_RE.test(s);
155
168
 
156
169
  // src/version.ts
157
170
  var PACKAGE_NAME = "@quackai/q402-mcp";
158
- var PACKAGE_VERSION = "0.5.6";
171
+ var PACKAGE_VERSION = "0.5.7";
159
172
 
160
173
  // src/tools/quote.ts
161
174
  import { z } from "zod";
@@ -1116,7 +1129,7 @@ async function runBalance() {
1116
1129
  apiKeyMasked: null,
1117
1130
  scopes: [],
1118
1131
  dashboardUrl: "https://q402.quackai.ai/dashboard",
1119
- setupHint: "No API key configured. Call q402_doctor for guided setup \u2014 it will offer to create ~/.q402/mcp.env with placeholders that the user can fill in. (Manual path: set Q402_TRIAL_API_KEY for BNB-only sponsored (free at https://q402.quackai.ai/event) and/or Q402_MULTICHAIN_API_KEY for paid 9-chain (https://q402.quackai.ai/payment). Q402_API_KEY is the legacy single-env fallback.)"
1132
+ setupHint: "No API key configured. Call q402_doctor for guided setup \u2014 it will offer to create ~/.q402/mcp.env with placeholders that the user can fill in. (Manual path: set Q402_TRIAL_API_KEY for BNB-only sponsored free trial (https://q402.quackai.ai/event) or Q402_MULTICHAIN_API_KEY for the paid 9-chain plan (https://q402.quackai.ai/payment).)"
1120
1133
  };
1121
1134
  }
1122
1135
  const scopes = await Promise.all(
@@ -1524,24 +1537,25 @@ var ENV_FILE_TEMPLATE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u250
1524
1537
  # After editing, restart your MCP client (Codex / Claude / Cursor / Cline).
1525
1538
  # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1526
1539
 
1527
- # \u2500\u2500\u2500 API key \u2014 pick ONE (uncomment the one you have) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1540
+ # \u2500\u2500\u2500 API key \u2014 uncomment ONE (or both for auto-routing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1528
1541
  # Free Trial: BNB Chain only, 2,000 sponsored TX
1529
1542
  # Get one at: https://q402.quackai.ai/event
1530
- Q402_TRIAL_API_KEY=q402_live_...
1543
+ # Q402_TRIAL_API_KEY=q402_live_...
1531
1544
 
1532
1545
  # Paid Multichain: all 9 chains, per-chain Gas Tank
1533
1546
  # Get one at: https://q402.quackai.ai/payment
1534
1547
  # Q402_MULTICHAIN_API_KEY=q402_live_...
1535
1548
 
1536
1549
  # \u2500\u2500\u2500 Your wallet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1537
- # Hex EVM private key. Signs payments LOCALLY on your machine.
1538
- # Never leaves your device, never sent to any server.
1539
- Q402_PRIVATE_KEY=0x...
1550
+ # Hex EVM private key (0x + 64 hex chars). Signs payments LOCALLY on
1551
+ # your machine \u2014 never leaves your device, never sent to any server.
1552
+ # Q402_PRIVATE_KEY=0x...
1540
1553
 
1541
1554
  # \u2500\u2500\u2500 Live mode flag \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1542
- # Must be exactly "1" to allow real on-chain transactions.
1543
- # Anything else = test response (fake hash, no funds move).
1544
- Q402_ENABLE_REAL_PAYMENTS=1
1555
+ # Default 0 = sandbox (test responses, no funds move). Flip to 1 only
1556
+ # AFTER you've pasted real values into the lines above \u2014 otherwise the
1557
+ # server will refuse the placeholders.
1558
+ Q402_ENABLE_REAL_PAYMENTS=0
1545
1559
 
1546
1560
  # \u2500\u2500\u2500 Q402 relay endpoint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1547
1561
  # Default canonical Q402 deployment. Only change for self-hosted.
@@ -1582,8 +1596,18 @@ async function verifyOneKey(scope, envVar, apiKey) {
1582
1596
  const resp = await fetch(url, {
1583
1597
  method: "POST",
1584
1598
  headers: { "Content-Type": "application/json" },
1585
- body: JSON.stringify({ apiKey })
1599
+ body: JSON.stringify({ apiKey }),
1600
+ signal: AbortSignal.timeout(1e4)
1586
1601
  });
1602
+ if (resp.status === 429) {
1603
+ return {
1604
+ scope,
1605
+ envVar,
1606
+ apiKeyMasked: mask2(apiKey),
1607
+ valid: false,
1608
+ error: "rate limited by relay \u2014 wait 60s and re-run q402_doctor"
1609
+ };
1610
+ }
1587
1611
  if (!resp.ok) {
1588
1612
  return { scope, envVar, apiKeyMasked: mask2(apiKey), valid: false, error: `HTTP ${resp.status}` };
1589
1613
  }
@@ -1593,6 +1617,10 @@ async function verifyOneKey(scope, envVar, apiKey) {
1593
1617
  envVar,
1594
1618
  apiKeyMasked: mask2(apiKey),
1595
1619
  valid: body.valid ?? false,
1620
+ // Propagate the relay's specific reason ("API key has been rotated",
1621
+ // "Subscription expired", "Trial expired") so the user gets the
1622
+ // exact failure mode instead of a generic "verified as invalid".
1623
+ error: body.valid === false ? body.error : void 0,
1596
1624
  plan: body.plan,
1597
1625
  remainingCredits: body.remainingCredits,
1598
1626
  isTrial: body.isTrial,
@@ -1616,11 +1644,16 @@ async function verifyOneKey(scope, envVar, apiKey) {
1616
1644
  }
1617
1645
  }
1618
1646
  async function pingRelay() {
1619
- const url = `${CONFIG.relayBaseUrl}/health`;
1647
+ const url = `${CONFIG.relayBaseUrl}/keys/verify`;
1620
1648
  const t0 = Date.now();
1621
1649
  try {
1622
- const resp = await fetch(url, { method: "GET" });
1623
- const reachable = resp.status < 500;
1650
+ const resp = await fetch(url, {
1651
+ method: "POST",
1652
+ headers: { "Content-Type": "application/json" },
1653
+ body: "{}",
1654
+ signal: AbortSignal.timeout(1e4)
1655
+ });
1656
+ const reachable = resp.status >= 200 && resp.status < 500;
1624
1657
  return { url, reachable, latencyMs: Date.now() - t0 };
1625
1658
  } catch (e) {
1626
1659
  return {
@@ -1634,7 +1667,7 @@ async function pingRelay() {
1634
1667
  async function fetchDelegation(address) {
1635
1668
  const url = `${CONFIG.relayBaseUrl}/wallet/delegation-status?address=${address}`;
1636
1669
  try {
1637
- const resp = await fetch(url);
1670
+ const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
1638
1671
  if (!resp.ok) {
1639
1672
  return CHAIN_KEYS.map((chain) => ({ chain, delegated: false, error: `HTTP ${resp.status}` }));
1640
1673
  }
@@ -1673,17 +1706,19 @@ async function runDoctor() {
1673
1706
  "Must be '1' to allow real TX. Anything else = test response (fake hash)."
1674
1707
  )
1675
1708
  };
1676
- let legacyDetected;
1677
- if (CONFIG.legacyApiKey) {
1678
- legacyDetected = "Q402_API_KEY is set \u2014 works as a fallback for both scopes, but the newer two-key model (Q402_TRIAL_API_KEY + Q402_MULTICHAIN_API_KEY) gives you auto-routing between free Trial (BNB) and paid Multichain. Rename in ~/.q402/mcp.env when convenient.";
1679
- }
1680
1709
  const missing = [];
1681
1710
  if (!CONFIG.trialApiKey && !CONFIG.multichainApiKey && !CONFIG.legacyApiKey) {
1682
1711
  missing.push(
1683
1712
  "An API key (Q402_TRIAL_API_KEY for free BNB OR Q402_MULTICHAIN_API_KEY for paid 9-chain)"
1684
1713
  );
1685
1714
  }
1686
- if (!CONFIG.privateKey) missing.push("Q402_PRIVATE_KEY");
1715
+ if (!CONFIG.privateKey) {
1716
+ missing.push("Q402_PRIVATE_KEY");
1717
+ } else if (!isValidPrivateKey(CONFIG.privateKey)) {
1718
+ missing.push(
1719
+ "Q402_PRIVATE_KEY is set but malformed (expected 0x + 64 hex chars). Looks like the placeholder '0x...' is still in ~/.q402/mcp.env \u2014 paste a real key in your editor."
1720
+ );
1721
+ }
1687
1722
  if (!CONFIG.realPaymentsRequested) missing.push("Q402_ENABLE_REAL_PAYMENTS=1");
1688
1723
  const recommendedActions = [];
1689
1724
  if (!envFile.exists) {
@@ -1699,6 +1734,13 @@ async function runDoctor() {
1699
1734
  });
1700
1735
  }
1701
1736
  const warnings = [];
1737
+ for (const name of Q402_ENV_FILE_KEYS_ALL) {
1738
+ if (process.env[name] !== void 0 && Q402_ENV_FILE_KEYS_ALL.has(name) && !Q402_ENV_FILE_KEYS.has(name)) {
1739
+ warnings.push(
1740
+ `${name} is set in both your shell (process.env) AND ~/.q402/mcp.env \u2014 the shell value wins. Editing the file will have NO effect until you \`unset ${name}\` in your shell (or update the shell value to match).`
1741
+ );
1742
+ }
1743
+ }
1702
1744
  if (phase !== "live-check") {
1703
1745
  return {
1704
1746
  package: PACKAGE_NAME,
@@ -1708,7 +1750,6 @@ async function runDoctor() {
1708
1750
  envFile,
1709
1751
  envState,
1710
1752
  missing,
1711
- legacyDetected,
1712
1753
  warnings,
1713
1754
  recommendedActions,
1714
1755
  greeting: phase === "first-install" ? `Q402 MCP is installed (v${PACKAGE_VERSION}).` : `Q402 MCP is installed (v${PACKAGE_VERSION}) \u2014 partially configured.`,
@@ -1717,10 +1758,14 @@ async function runDoctor() {
1717
1758
  };
1718
1759
  }
1719
1760
  let walletAddress;
1761
+ let walletError;
1720
1762
  try {
1721
1763
  walletAddress = new Wallet4(CONFIG.privateKey).address;
1722
- } catch {
1723
- warnings.push("Q402_PRIVATE_KEY is set but does not parse as a 32-byte hex key. Live calls will fail.");
1764
+ } catch (e) {
1765
+ walletError = e instanceof Error ? e.message : String(e);
1766
+ warnings.push(
1767
+ `Q402_PRIVATE_KEY is set but does not parse as a 32-byte hex key: ${walletError}. Open ~/.q402/mcp.env in your editor and paste a real key (0x + 64 hex chars). Live calls will fail until this is fixed.`
1768
+ );
1724
1769
  }
1725
1770
  const verifyTargets = [];
1726
1771
  if (CONFIG.trialApiKey) verifyTargets.push({ scope: "trial", envVar: "Q402_TRIAL_API_KEY", key: CONFIG.trialApiKey });
@@ -1730,7 +1775,7 @@ async function runDoctor() {
1730
1775
  }
1731
1776
  const [keys, delegation, relay] = await Promise.all([
1732
1777
  Promise.all(verifyTargets.map((t) => verifyOneKey(t.scope, t.envVar, t.key))),
1733
- walletAddress ? fetchDelegation(walletAddress) : Promise.resolve([]),
1778
+ walletAddress ? fetchDelegation(walletAddress) : Promise.resolve(void 0),
1734
1779
  pingRelay()
1735
1780
  ]);
1736
1781
  for (const k of keys) if (k.slotWarning) warnings.push(k.slotWarning);
@@ -1744,8 +1789,10 @@ async function runDoctor() {
1744
1789
  `${k.envVar} has only ${k.remainingCredits} credits left \u2014 top up before you run out.`
1745
1790
  );
1746
1791
  }
1747
- if (!k.valid && !k.error) {
1748
- warnings.push(`${k.envVar} verified as invalid by the relay \u2014 check the key value in ~/.q402/mcp.env.`);
1792
+ if (!k.valid) {
1793
+ warnings.push(
1794
+ k.error ? `${k.envVar}: ${k.error}.` : `${k.envVar} verified as invalid by the relay \u2014 check the key value in ~/.q402/mcp.env.`
1795
+ );
1749
1796
  }
1750
1797
  }
1751
1798
  if (relay && !relay.reachable) {
@@ -1762,7 +1809,6 @@ async function runDoctor() {
1762
1809
  envFile,
1763
1810
  envState,
1764
1811
  missing,
1765
- legacyDetected,
1766
1812
  wallet: walletAddress ? { address: walletAddress } : void 0,
1767
1813
  keys,
1768
1814
  delegation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quackai/q402-mcp",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "MCP server for Q402 — gasless USDC, USDT, and RLUSD payments across 9 EVM chains, callable from Claude (Desktop / Code), OpenAI Codex CLI, and any other Model Context Protocol client.",
5
5
  "mcpName": "io.github.bitgett/q402-mcp",
6
6
  "keywords": [