@quackai/q402-mcp 0.3.9 → 0.3.11

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 +59 -6
  2. package/dist/index.js +105 -39
  3. package/package.json +7 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @quackai/q402-mcp
2
2
 
3
- > MCP server for Q402 — gasless USDC, USDT, and RLUSD payments across 7 EVM chains, callable directly from Claude Desktop and any other Model Context Protocol client.
3
+ > 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.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@quackai/q402-mcp.svg)](https://www.npmjs.com/package/@quackai/q402-mcp)
6
6
  [![license](https://img.shields.io/npm/l/@quackai/q402-mcp.svg)](./LICENSE)
@@ -13,13 +13,17 @@ Claude can now reason about stablecoin payments end to end — quote a transfer
13
13
 
14
14
  ---
15
15
 
16
- ## Quick start (Claude Desktop)
16
+ ## Quick start
17
+
18
+ The server speaks stdio MCP, so any MCP-compatible client can use it. The two paths verified end-to-end today are **Claude (Desktop / Code)** and **OpenAI Codex CLI**.
19
+
20
+ ### Claude Desktop / Claude Code
17
21
 
18
22
  ```bash
19
23
  claude mcp add q402 -- npx -y @quackai/q402-mcp
20
24
  ```
21
25
 
22
- Or, if you prefer editing the config file directly, add this entry to your `claude_desktop_config.json`:
26
+ Or edit `claude_desktop_config.json` directly:
23
27
 
24
28
  ```json
25
29
  {
@@ -36,7 +40,56 @@ Restart Claude Desktop and ask:
36
40
 
37
41
  > *"Compare gas costs to send 50 USDC to vitalik.eth across all 7 Q402 chains."*
38
42
 
39
- You'll get a ranked breakdown immediately — no API key, no signup, no funds at risk.
43
+ ### OpenAI Codex CLI
44
+
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):
57
+
58
+ ```bash
59
+ codex mcp add q402 -- npx -y @quackai/q402-mcp
60
+ ```
61
+
62
+ **(c) Direct `~/.codex/config.toml` edit** (`.codex/config.toml` for per-project scope):
63
+
64
+ ```toml
65
+ [mcp_servers.q402]
66
+ command = "npx"
67
+ args = ["-y", "@quackai/q402-mcp"]
68
+ startup_timeout_sec = 20.0
69
+ ```
70
+
71
+ To enable real on-chain payments, pass the three live-mode env vars **explicitly** under `env` — Codex does **not** forward host env vars by default:
72
+
73
+ ```toml
74
+ [mcp_servers.q402]
75
+ command = "npx"
76
+ args = ["-y", "@quackai/q402-mcp"]
77
+ startup_timeout_sec = 20.0
78
+ env = {
79
+ Q402_API_KEY = "q402_live_...",
80
+ Q402_PRIVATE_KEY = "0xabc...",
81
+ Q402_ENABLE_REAL_PAYMENTS = "1",
82
+ Q402_MAX_AMOUNT_PER_CALL = "5",
83
+ }
84
+ ```
85
+
86
+ > If you'd rather not inline secrets in `config.toml`, use the `env_vars` allow-list form to forward specific names from your shell environment instead — see the [Codex config reference](https://developers.openai.com/codex/config-reference) for the full schema.
87
+
88
+ Then run `codex` and ask the same kind of question. The first call may take a few seconds while `npx` warms its cache; subsequent calls are instant.
89
+
90
+ ### Any other MCP client
91
+
92
+ The server has no client-specific code. If your client speaks stdio MCP, point it at `npx -y @quackai/q402-mcp` and the four tools listed below will appear.
40
93
 
41
94
  ---
42
95
 
@@ -47,7 +100,7 @@ You'll get a ranked breakdown immediately — no API key, no signup, no funds at
47
100
  | `q402_quote` | none | Compare gas cost and supported tokens across chains. Read-only. |
48
101
  | `q402_balance` | API key | Verify the API key and report its plan tier + remaining quota credits (live vs sandbox). |
49
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). |
50
- | `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`. |
51
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.* |
52
105
 
53
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.
@@ -63,7 +116,7 @@ You'll get a ranked breakdown immediately — no API key, no signup, no funds at
63
116
 
64
117
  ## Sandbox vs live mode
65
118
 
66
- By default the MCP server operates in **sandbox mode**: `q402_pay` returns a deterministic-looking fake transaction hash, no funds move, no gas-tank credit is consumed. That makes it safe to plug into any Claude Desktop install without worrying about an LLM hallucinating a payment.
119
+ By default the MCP server operates in **sandbox mode**: `q402_pay` returns a deterministic-looking fake transaction hash, no funds move, no gas-tank credit is consumed. That makes it safe to plug into any MCP client without worrying about an LLM hallucinating a payment.
67
120
 
68
121
  To enable real on-chain transactions, **all three** environment variables must be set:
69
122
 
package/dist/index.js CHANGED
@@ -445,28 +445,45 @@ 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.
453
+ * Codex audit P2: surface the constraint in the type so consumers
454
+ * can't accidentally build a "mixed-token batch" that quietly drops
455
+ * the second token. Same chain + same token across one batch, full
456
+ * stop.
448
457
  */
449
- async batchPay(inputs) {
450
- if (!Array.isArray(inputs) || inputs.length === 0) {
458
+ async batchPay(input) {
459
+ const { token, recipients: rows } = input;
460
+ if (!Array.isArray(rows) || rows.length === 0) {
451
461
  throw new Error("batchPay requires at least one recipient");
452
462
  }
463
+ if (typeof token !== "string") {
464
+ throw new Error("batchPay({ token, recipients }): token must be a string");
465
+ }
453
466
  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
- }
467
+ const tokenCfg = tokenFor(chain, token);
468
+ if (chain.supportedTokens && !chain.supportedTokens.includes(token)) {
469
+ throw new Error(
470
+ `token ${token} is not supported on chain ${chain.key}. Supported: ${chain.supportedTokens.join(", ")}.`
471
+ );
472
+ }
473
+ for (let i = 0; i < rows.length; i++) {
462
474
  try {
463
- toRawAmount(inp.amount, tokenCfg.decimals);
475
+ toRawAmount(rows[i].amount, tokenCfg.decimals);
464
476
  } catch (e) {
465
477
  throw new Error(
466
478
  `recipient[${i}]: ${e instanceof Error ? e.message : String(e)}`
467
479
  );
468
480
  }
469
481
  }
482
+ if (chain.key === "xlayer" || chain.key === "stable") {
483
+ throw new Error(
484
+ `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.`
485
+ );
486
+ }
470
487
  const rpcUrl = this.opts.rpcUrl ?? DEFAULT_RPC[chain.chainId];
471
488
  if (!rpcUrl) {
472
489
  throw new Error(
@@ -479,11 +496,10 @@ var Q402NodeClient = class _Q402NodeClient {
479
496
  const facilitator = await this.fetchFacilitator();
480
497
  const baseAuthNonce = await provider.getTransactionCount(owner);
481
498
  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);
499
+ const signedRows = [];
500
+ for (let i = 0; i < rows.length; i++) {
501
+ const row = rows[i];
502
+ const amountRaw = toRawAmount(row.amount, tokenCfg.decimals);
487
503
  const paymentNonce = toBigInt(randomBytes(32));
488
504
  const witnessSig = await wallet.signTypedData(
489
505
  {
@@ -497,7 +513,7 @@ var Q402NodeClient = class _Q402NodeClient {
497
513
  owner,
498
514
  facilitator,
499
515
  token: tokenCfg.address,
500
- recipient: inp.to,
516
+ recipient: row.to,
501
517
  amount: BigInt(amountRaw),
502
518
  nonce: paymentNonce,
503
519
  deadline: BigInt(deadline)
@@ -508,16 +524,15 @@ var Q402NodeClient = class _Q402NodeClient {
508
524
  address: chain.implContract,
509
525
  nonce: baseAuthNonce + i
510
526
  });
511
- const baseRow = {
527
+ signedRows.push({
512
528
  from: owner,
513
- to: inp.to,
529
+ to: row.to,
514
530
  amount: amountRaw,
531
+ nonce: paymentNonce.toString(),
515
532
  deadline,
516
533
  witnessSig,
517
534
  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);
535
+ });
521
536
  }
522
537
  const resp = await fetch(`${relayBaseUrl.replace(/\/$/, "")}/relay/batch`, {
523
538
  method: "POST",
@@ -525,15 +540,23 @@ var Q402NodeClient = class _Q402NodeClient {
525
540
  body: JSON.stringify({
526
541
  apiKey,
527
542
  chain: chain.key,
528
- token: inputs[0].token,
529
- // all recipients must share token; pre-validated above
543
+ token,
530
544
  facilitator,
531
- recipients
545
+ recipients: signedRows
532
546
  })
533
547
  });
534
548
  const data = await resp.json();
535
- if (!resp.ok) {
536
- throw new Error(data.error ?? `relay/batch failed (HTTP ${resp.status})`);
549
+ if (!resp.ok || data.ok === false) {
550
+ const err = new BatchPayError(
551
+ 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})`,
552
+ {
553
+ aborted: !!data.aborted,
554
+ totalSuccess: data.totalSuccess ?? 0,
555
+ totalFailed: data.totalFailed ?? signedRows.length,
556
+ results: data.results ?? []
557
+ }
558
+ );
559
+ throw err;
537
560
  }
538
561
  data.results = data.results.map((r) => ({
539
562
  ...r,
@@ -542,6 +565,20 @@ var Q402NodeClient = class _Q402NodeClient {
542
565
  return data;
543
566
  }
544
567
  };
568
+ var BatchPayError = class extends Error {
569
+ aborted;
570
+ totalSuccess;
571
+ totalFailed;
572
+ results;
573
+ constructor(message, details) {
574
+ super(message);
575
+ this.name = "BatchPayError";
576
+ this.aborted = details.aborted;
577
+ this.totalSuccess = details.totalSuccess;
578
+ this.totalFailed = details.totalFailed;
579
+ this.results = details.results;
580
+ }
581
+ };
545
582
  function sandboxPay(chain, input) {
546
583
  const tokenCfg = tokenFor(chain, input.token);
547
584
  const tokenAmount = toRawAmount(input.amount, tokenCfg.decimals);
@@ -678,7 +715,7 @@ var RECIPIENT_LIMIT_TRIAL = 5;
678
715
  var RECIPIENT_LIMIT_PAID = 20;
679
716
  var CLIENT_RECIPIENT_CAP = RECIPIENT_LIMIT_PAID;
680
717
  var BatchPayInputSchema = z3.object({
681
- chain: z3.enum(["avax", "bnb", "eth", "xlayer", "stable", "mantle", "injective"]),
718
+ chain: z3.enum(["avax", "bnb", "eth", "mantle", "injective"]),
682
719
  token: z3.enum(["USDC", "USDT", "RLUSD"]).describe(
683
720
  "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
721
  ),
@@ -741,6 +778,7 @@ async function runBatchPay(input) {
741
778
  guardsApplied.push("mode=sandbox");
742
779
  return {
743
780
  mode: "sandbox",
781
+ status: "sandbox",
744
782
  result: { sandbox: sandboxResults, reason: describeSandboxReason2() },
745
783
  guardsApplied,
746
784
  setupHint: describeSandboxReason2()
@@ -752,13 +790,38 @@ async function runBatchPay(input) {
752
790
  chain,
753
791
  relayBaseUrl: CONFIG.relayBaseUrl
754
792
  });
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 };
793
+ try {
794
+ const result = await client.batchPay({
795
+ token: input.token,
796
+ recipients: input.recipients.map((r) => ({ to: r.to, amount: r.amount }))
797
+ });
798
+ guardsApplied.push("mode=live");
799
+ guardsApplied.push(`scope=${result.scope} (server enforced)`);
800
+ guardsApplied.push(`batch_size=${input.recipients.length}/${result.limit}`);
801
+ return { mode: "live", status: "success", result, guardsApplied };
802
+ } catch (err) {
803
+ if (err instanceof BatchPayError) {
804
+ guardsApplied.push("mode=live");
805
+ guardsApplied.push(`batch_${err.aborted ? "aborted" : "partial_failure"}`);
806
+ const status = err.aborted ? "aborted" : "partial_failure";
807
+ return {
808
+ mode: "live",
809
+ status,
810
+ result: {
811
+ ok: false,
812
+ scope: "paid",
813
+ limit: input.recipients.length,
814
+ totalSuccess: err.totalSuccess,
815
+ totalFailed: err.totalFailed,
816
+ aborted: err.aborted,
817
+ results: err.results
818
+ },
819
+ guardsApplied,
820
+ error: err.message
821
+ };
822
+ }
823
+ throw err;
824
+ }
762
825
  }
763
826
  function describeSandboxReason2() {
764
827
  const missing = [];
@@ -770,14 +833,17 @@ function describeSandboxReason2() {
770
833
  }
771
834
  var BATCH_PAY_TOOL = {
772
835
  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.`,
836
+ 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
837
  inputSchema: {
775
838
  type: "object",
776
839
  properties: {
777
840
  chain: {
778
841
  type: "string",
779
- enum: CHAIN_KEYS,
780
- description: "Target chain. Applies to every recipient in the batch."
842
+ // Narrower than the full chain set — xlayer and stable are NOT batchable
843
+ // (chain-specific nonce field shapes). Use q402_pay in a loop for
844
+ // those chains.
845
+ enum: ["avax", "bnb", "eth", "mantle", "injective"],
846
+ description: "Target chain. Applies to every recipient in the batch. xlayer + stable are NOT supported here \u2014 use q402_pay in a loop."
781
847
  },
782
848
  token: {
783
849
  type: "string",
@@ -1028,7 +1094,7 @@ var RECEIPT_TOOL = {
1028
1094
 
1029
1095
  // src/index.ts
1030
1096
  var PACKAGE_NAME = "@quackai/q402-mcp";
1031
- var PACKAGE_VERSION = "0.3.9";
1097
+ var PACKAGE_VERSION = "0.3.11";
1032
1098
  function jsonText(value) {
1033
1099
  return { type: "text", text: JSON.stringify(value, null, 2) };
1034
1100
  }
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@quackai/q402-mcp",
3
- "version": "0.3.9",
4
- "description": "MCP server for Q402 — gasless USDC, USDT, and RLUSD payments across 7 EVM chains, callable directly from Claude Desktop and any other Model Context Protocol client.",
3
+ "version": "0.3.11",
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": [
7
7
  "mcp",
8
8
  "model-context-protocol",
9
9
  "claude",
10
+ "claude-desktop",
11
+ "claude-code",
12
+ "codex",
13
+ "openai-codex",
14
+ "cline",
10
15
  "q402",
11
16
  "x402",
12
17
  "stablecoin",