@quackai/q402-mcp 0.3.10 → 0.3.12

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 +14 -3
  2. package/dist/index.js +115 -39
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,13 +42,24 @@ Restart Claude Desktop and ask:
42
42
 
43
43
  ### OpenAI Codex CLI
44
44
 
45
- One-liner using Codex's built-in registration command:
45
+ Three install paths pick the one that matches your workflow.
46
+
47
+ **(a) Codex plugin marketplace** (recommended — bundles the MCP config so users don't write TOML):
48
+
49
+ ```bash
50
+ codex plugin marketplace add bitgett/q402-mcp
51
+ codex /plugins # browse and install "q402"
52
+ ```
53
+
54
+ This repo carries a Codex plugin manifest at [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) and a marketplace catalog at [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json), so any signed-in Codex user can register it as a marketplace source and install with one click.
55
+
56
+ **(b) Single MCP server via `codex mcp add`** (no plugin wrapper — just register the stdio server):
46
57
 
47
58
  ```bash
48
59
  codex mcp add q402 -- npx -y @quackai/q402-mcp
49
60
  ```
50
61
 
51
- Or edit `~/.codex/config.toml` directly (`.codex/config.toml` for per-project scope):
62
+ **(c) Direct `~/.codex/config.toml` edit** (`.codex/config.toml` for per-project scope):
52
63
 
53
64
  ```toml
54
65
  [mcp_servers.q402]
@@ -89,7 +100,7 @@ The server has no client-specific code. If your client speaks stdio MCP, point i
89
100
  | `q402_quote` | none | Compare gas cost and supported tokens across chains. Read-only. |
90
101
  | `q402_balance` | API key | Verify the API key and report its plan tier + remaining quota credits (live vs sandbox). |
91
102
  | `q402_pay` | API key + private key + flag | Send a gasless payment to a single recipient. **Sandbox by default** — see [Sandbox vs live mode](#sandbox-vs-live-mode). |
92
- | `q402_batch_pay` | API key + private key + flag | Send a gasless payment to **multiple** recipients in one call on a single chain × token. Trial keys: 5 rows max. Paid keys: 20 rows max. Same sandbox gating as `q402_pay`. |
103
+ | `q402_batch_pay` | API key + private key + flag | Send a gasless payment to **multiple** recipients in one call on a single chain × token. Trial keys: 5 rows max. Paid keys: 20 rows max. **Supported chains: avax, bnb, eth, mantle, injective** (default EIP-7702 mode). xlayer + stable are NOT batchable — use `q402_pay` in a loop for those. Same sandbox gating as `q402_pay`. **Rate-limit note:** the inner `/api/relay` budget (30/min per key) is consumed per row, so a paid 20-row batch leaves ~10 inner slots for the next minute. |
93
104
  | `q402_receipt` | none | Look up a Trust Receipt by `rct_…` id and locally verify its ECDSA signature against the relayer EOA. Returns the public settlement record + a `verified` boolean. *receiptId-only today; tx-hash lookup reserved for a future release.* |
94
105
 
95
106
  `q402_pay` and `q402_batch_pay` follow a "confirm in chat first" contract: the tool description instructs the model to never call it without explicit user approval of the recipient address(es), amount(s), chain, and token. For batch calls the user must approve the **full batch**, not the individual rows.
package/dist/index.js CHANGED
@@ -445,28 +445,44 @@ var Q402NodeClient = class _Q402NodeClient {
445
445
  * first transfer must succeed (it installs / re-confirms the
446
446
  * delegation), after which the remaining transfers are surfaced in
447
447
  * the result array even if individual ones fail.
448
+ *
449
+ * Signature shape: `{ token, recipients }`. The previous revision took
450
+ * `PayInput[]` (with token on each row), which read as if rows could
451
+ * carry different tokens — but the request body only ships one token
452
+ * field, so the per-row token on rows 1..N was silently ignored. The
453
+ * new shape surfaces the constraint in the type so consumers can't
454
+ * accidentally build a "mixed-token batch" that quietly drops the
455
+ * second token. Same chain + same token across one batch, full stop.
448
456
  */
449
- async batchPay(inputs) {
450
- if (!Array.isArray(inputs) || inputs.length === 0) {
457
+ async batchPay(input) {
458
+ const { token, recipients: rows } = input;
459
+ if (!Array.isArray(rows) || rows.length === 0) {
451
460
  throw new Error("batchPay requires at least one recipient");
452
461
  }
462
+ if (typeof token !== "string") {
463
+ throw new Error("batchPay({ token, recipients }): token must be a string");
464
+ }
453
465
  const { chain, relayBaseUrl, apiKey, privateKey } = this.opts;
454
- for (let i = 0; i < inputs.length; i++) {
455
- const inp = inputs[i];
456
- const tokenCfg = tokenFor(chain, inp.token);
457
- if (chain.supportedTokens && !chain.supportedTokens.includes(inp.token)) {
458
- throw new Error(
459
- `recipient[${i}]: token ${inp.token} is not supported on chain ${chain.key}. Supported: ${chain.supportedTokens.join(", ")}.`
460
- );
461
- }
466
+ const tokenCfg = tokenFor(chain, token);
467
+ if (chain.supportedTokens && !chain.supportedTokens.includes(token)) {
468
+ throw new Error(
469
+ `token ${token} is not supported on chain ${chain.key}. Supported: ${chain.supportedTokens.join(", ")}.`
470
+ );
471
+ }
472
+ for (let i = 0; i < rows.length; i++) {
462
473
  try {
463
- toRawAmount(inp.amount, tokenCfg.decimals);
474
+ toRawAmount(rows[i].amount, tokenCfg.decimals);
464
475
  } catch (e) {
465
476
  throw new Error(
466
477
  `recipient[${i}]: ${e instanceof Error ? e.message : String(e)}`
467
478
  );
468
479
  }
469
480
  }
481
+ if (chain.key === "xlayer" || chain.key === "stable") {
482
+ throw new Error(
483
+ `batchPay does not yet support chain "${chain.key}". Supported batch chains: avax, bnb, eth, mantle, injective (default EIP-7702 mode). For "${chain.key}" use pay() in a client-side loop.`
484
+ );
485
+ }
470
486
  const rpcUrl = this.opts.rpcUrl ?? DEFAULT_RPC[chain.chainId];
471
487
  if (!rpcUrl) {
472
488
  throw new Error(
@@ -479,11 +495,10 @@ var Q402NodeClient = class _Q402NodeClient {
479
495
  const facilitator = await this.fetchFacilitator();
480
496
  const baseAuthNonce = await provider.getTransactionCount(owner);
481
497
  const deadline = Math.floor(Date.now() / 1e3) + 600;
482
- const recipients = [];
483
- for (let i = 0; i < inputs.length; i++) {
484
- const inp = inputs[i];
485
- const tokenCfg = tokenFor(chain, inp.token);
486
- const amountRaw = toRawAmount(inp.amount, tokenCfg.decimals);
498
+ const signedRows = [];
499
+ for (let i = 0; i < rows.length; i++) {
500
+ const row = rows[i];
501
+ const amountRaw = toRawAmount(row.amount, tokenCfg.decimals);
487
502
  const paymentNonce = toBigInt(randomBytes(32));
488
503
  const witnessSig = await wallet.signTypedData(
489
504
  {
@@ -497,7 +512,7 @@ var Q402NodeClient = class _Q402NodeClient {
497
512
  owner,
498
513
  facilitator,
499
514
  token: tokenCfg.address,
500
- recipient: inp.to,
515
+ recipient: row.to,
501
516
  amount: BigInt(amountRaw),
502
517
  nonce: paymentNonce,
503
518
  deadline: BigInt(deadline)
@@ -508,16 +523,15 @@ var Q402NodeClient = class _Q402NodeClient {
508
523
  address: chain.implContract,
509
524
  nonce: baseAuthNonce + i
510
525
  });
511
- const baseRow = {
526
+ signedRows.push({
512
527
  from: owner,
513
- to: inp.to,
528
+ to: row.to,
514
529
  amount: amountRaw,
530
+ nonce: paymentNonce.toString(),
515
531
  deadline,
516
532
  witnessSig,
517
533
  authorization
518
- };
519
- const row = chain.key === "xlayer" ? { ...baseRow, xlayerNonce: paymentNonce.toString() } : chain.key === "stable" ? { ...baseRow, stableNonce: paymentNonce.toString() } : { ...baseRow, nonce: paymentNonce.toString() };
520
- recipients.push(row);
534
+ });
521
535
  }
522
536
  const resp = await fetch(`${relayBaseUrl.replace(/\/$/, "")}/relay/batch`, {
523
537
  method: "POST",
@@ -525,15 +539,29 @@ var Q402NodeClient = class _Q402NodeClient {
525
539
  body: JSON.stringify({
526
540
  apiKey,
527
541
  chain: chain.key,
528
- token: inputs[0].token,
529
- // all recipients must share token; pre-validated above
542
+ token,
530
543
  facilitator,
531
- recipients
544
+ recipients: signedRows
532
545
  })
533
546
  });
534
547
  const data = await resp.json();
535
- if (!resp.ok) {
536
- throw new Error(data.error ?? `relay/batch failed (HTTP ${resp.status})`);
548
+ if (!resp.ok || data.ok === false) {
549
+ const err = new BatchPayError(
550
+ data.aborted ? `Batch aborted: recipient[0] failed (${data.results?.[0]?.error ?? "unknown"}). No transfers landed.` : data.totalFailed > 0 ? `Batch completed with ${data.totalFailed}/${data.results?.length ?? "?"} failed rows.` : data.error ?? `relay/batch failed (HTTP ${resp.status})`,
551
+ {
552
+ aborted: !!data.aborted,
553
+ // Preserve the server's scope/limit so the MCP tool surface can
554
+ // report the actual tier (trial vs paid) rather than guessing.
555
+ // Falls back to "paid" / row count only when the failure didn't
556
+ // come from the relay route (e.g. network-level error).
557
+ scope: data.scope ?? "paid",
558
+ limit: data.limit ?? signedRows.length,
559
+ totalSuccess: data.totalSuccess ?? 0,
560
+ totalFailed: data.totalFailed ?? signedRows.length,
561
+ results: data.results ?? []
562
+ }
563
+ );
564
+ throw err;
537
565
  }
538
566
  data.results = data.results.map((r) => ({
539
567
  ...r,
@@ -542,6 +570,24 @@ var Q402NodeClient = class _Q402NodeClient {
542
570
  return data;
543
571
  }
544
572
  };
573
+ var BatchPayError = class extends Error {
574
+ aborted;
575
+ scope;
576
+ limit;
577
+ totalSuccess;
578
+ totalFailed;
579
+ results;
580
+ constructor(message, details) {
581
+ super(message);
582
+ this.name = "BatchPayError";
583
+ this.aborted = details.aborted;
584
+ this.scope = details.scope;
585
+ this.limit = details.limit;
586
+ this.totalSuccess = details.totalSuccess;
587
+ this.totalFailed = details.totalFailed;
588
+ this.results = details.results;
589
+ }
590
+ };
545
591
  function sandboxPay(chain, input) {
546
592
  const tokenCfg = tokenFor(chain, input.token);
547
593
  const tokenAmount = toRawAmount(input.amount, tokenCfg.decimals);
@@ -678,7 +724,7 @@ var RECIPIENT_LIMIT_TRIAL = 5;
678
724
  var RECIPIENT_LIMIT_PAID = 20;
679
725
  var CLIENT_RECIPIENT_CAP = RECIPIENT_LIMIT_PAID;
680
726
  var BatchPayInputSchema = z3.object({
681
- chain: z3.enum(["avax", "bnb", "eth", "xlayer", "stable", "mantle", "injective"]),
727
+ chain: z3.enum(["avax", "bnb", "eth", "mantle", "injective"]),
682
728
  token: z3.enum(["USDC", "USDT", "RLUSD"]).describe(
683
729
  "Stablecoin symbol. USDC / USDT supported on most chains (Injective is USDT-only). RLUSD (Ripple USD, NY DFS regulated, decimals 18) is Ethereum-only. The same token applies to every recipient in the batch."
684
730
  ),
@@ -741,6 +787,7 @@ async function runBatchPay(input) {
741
787
  guardsApplied.push("mode=sandbox");
742
788
  return {
743
789
  mode: "sandbox",
790
+ status: "sandbox",
744
791
  result: { sandbox: sandboxResults, reason: describeSandboxReason2() },
745
792
  guardsApplied,
746
793
  setupHint: describeSandboxReason2()
@@ -752,13 +799,39 @@ async function runBatchPay(input) {
752
799
  chain,
753
800
  relayBaseUrl: CONFIG.relayBaseUrl
754
801
  });
755
- const result = await client.batchPay(
756
- input.recipients.map((r) => ({ to: r.to, amount: r.amount, token: input.token }))
757
- );
758
- guardsApplied.push("mode=live");
759
- guardsApplied.push(`scope=${result.scope} (server enforced)`);
760
- guardsApplied.push(`batch_size=${input.recipients.length}/${result.limit}`);
761
- return { mode: "live", result, guardsApplied };
802
+ try {
803
+ const result = await client.batchPay({
804
+ token: input.token,
805
+ recipients: input.recipients.map((r) => ({ to: r.to, amount: r.amount }))
806
+ });
807
+ guardsApplied.push("mode=live");
808
+ guardsApplied.push(`scope=${result.scope} (server enforced)`);
809
+ guardsApplied.push(`batch_size=${input.recipients.length}/${result.limit}`);
810
+ return { mode: "live", status: "success", result, guardsApplied };
811
+ } catch (err) {
812
+ if (err instanceof BatchPayError) {
813
+ guardsApplied.push("mode=live");
814
+ guardsApplied.push(`scope=${err.scope} (server enforced)`);
815
+ guardsApplied.push(`batch_${err.aborted ? "aborted" : "partial_failure"}`);
816
+ const status = err.aborted ? "aborted" : "partial_failure";
817
+ return {
818
+ mode: "live",
819
+ status,
820
+ result: {
821
+ ok: false,
822
+ scope: err.scope,
823
+ limit: err.limit,
824
+ totalSuccess: err.totalSuccess,
825
+ totalFailed: err.totalFailed,
826
+ aborted: err.aborted,
827
+ results: err.results
828
+ },
829
+ guardsApplied,
830
+ error: err.message
831
+ };
832
+ }
833
+ throw err;
834
+ }
762
835
  }
763
836
  function describeSandboxReason2() {
764
837
  const missing = [];
@@ -770,14 +843,17 @@ function describeSandboxReason2() {
770
843
  }
771
844
  var BATCH_PAY_TOOL = {
772
845
  name: "q402_batch_pay",
773
- description: `Send gasless payments to MULTIPLE recipients on a single chain \xD7 token in one call. Trial keys (q402_live_* with plan='trial'): max ${RECIPIENT_LIMIT_TRIAL} recipients per call, BNB Chain + USDC/USDT only. Paid keys: max ${RECIPIENT_LIMIT_PAID} recipients per call, full 7-chain support. SANDBOX BY DEFAULT \u2014 real on-chain TX only when Q402_API_KEY (live), Q402_PRIVATE_KEY, and Q402_ENABLE_REAL_PAYMENTS=1 are all set. Every recipient receives the full amount; the sender pays $0 in gas for the entire batch. ALWAYS get explicit user confirmation of the complete recipient + amount list, chain, and token in conversation immediately before calling this tool \u2014 the user must approve the full batch, not the individual rows.`,
846
+ description: `Send gasless payments to MULTIPLE recipients on a single chain \xD7 token in one call. Trial keys (q402_live_* with plan='trial'): max ${RECIPIENT_LIMIT_TRIAL} recipients per call, BNB Chain + USDC/USDT only. Paid keys: max ${RECIPIENT_LIMIT_PAID} recipients per call across 5 EIP-7702 default chains (avax, bnb, eth, mantle, injective). xlayer + stable are NOT batchable \u2014 use q402_pay in a loop. SANDBOX BY DEFAULT \u2014 real on-chain TX only when Q402_API_KEY (live), Q402_PRIVATE_KEY, and Q402_ENABLE_REAL_PAYMENTS=1 are all set. Every recipient receives the full amount; the sender pays $0 in gas for the entire batch. ALWAYS get explicit user confirmation of the complete recipient + amount list, chain, and token in conversation immediately before calling this tool \u2014 the user must approve the full batch, not the individual rows.`,
774
847
  inputSchema: {
775
848
  type: "object",
776
849
  properties: {
777
850
  chain: {
778
851
  type: "string",
779
- enum: CHAIN_KEYS,
780
- description: "Target chain. Applies to every recipient in the batch."
852
+ // Narrower than the full chain set — xlayer and stable are NOT batchable
853
+ // (chain-specific nonce field shapes). Use q402_pay in a loop for
854
+ // those chains.
855
+ enum: ["avax", "bnb", "eth", "mantle", "injective"],
856
+ description: "Target chain. Applies to every recipient in the batch. xlayer + stable are NOT supported here \u2014 use q402_pay in a loop."
781
857
  },
782
858
  token: {
783
859
  type: "string",
@@ -1028,7 +1104,7 @@ var RECEIPT_TOOL = {
1028
1104
 
1029
1105
  // src/index.ts
1030
1106
  var PACKAGE_NAME = "@quackai/q402-mcp";
1031
- var PACKAGE_VERSION = "0.3.10";
1107
+ var PACKAGE_VERSION = "0.3.12";
1032
1108
  function jsonText(value) {
1033
1109
  return { type: "text", text: JSON.stringify(value, null, 2) };
1034
1110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quackai/q402-mcp",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "MCP server for Q402 — gasless USDC, USDT, and RLUSD payments across 7 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": [