@quackai/q402-mcp 0.1.0
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/LICENSE +201 -0
- package/README.md +124 -0
- package/dist/index.js +627 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of tracking or otherwise improving the Work,
|
|
59
|
+
but excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for describing the origin of the Work and
|
|
141
|
+
reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Support. While redistributing the Work or
|
|
166
|
+
Derivative Works thereof, You may choose to offer, and charge a
|
|
167
|
+
fee for, acceptance of support, warranty, indemnity, or other
|
|
168
|
+
liability obligations and/or rights consistent with this License.
|
|
169
|
+
However, in accepting such obligations, You may act only on Your
|
|
170
|
+
own behalf and on Your sole responsibility, not on behalf of any
|
|
171
|
+
other Contributor, and only if You agree to indemnify, defend,
|
|
172
|
+
and hold each Contributor harmless for any liability incurred by,
|
|
173
|
+
or claims asserted against, such Contributor by reason of your
|
|
174
|
+
accepting any such warranty or support.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Quack AI Labs
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
200
|
+
implied. See the License for the specific language governing
|
|
201
|
+
permissions and limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# @quackai/q402-mcp
|
|
2
|
+
|
|
3
|
+
> MCP server for Q402 — gasless USDC and USDT payments across 7 EVM chains, callable directly from Claude Desktop and any other Model Context Protocol client.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@quackai/q402-mcp)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
Claude can now reason about stablecoin payments end to end — quote a transfer across 7 chains, pick the cheapest route, and (optionally) settle the transaction over [Q402](https://q402.quackai.ai)'s EIP-7702 relayer infrastructure. The recipient receives the full amount; the sender pays $0 in gas.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick start (Claude Desktop)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add q402 -- npx @quackai/q402-mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or, if you prefer editing the config file directly, add this entry to your `claude_desktop_config.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"q402": {
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": ["-y", "@quackai/q402-mcp"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Restart Claude Desktop and ask:
|
|
32
|
+
|
|
33
|
+
> *"Compare gas costs to send 50 USDC to vitalik.eth across all 7 Q402 chains."*
|
|
34
|
+
|
|
35
|
+
You'll get a ranked breakdown immediately — no API key, no signup, no funds at risk.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Tools exposed
|
|
40
|
+
|
|
41
|
+
| Tool | Auth | Purpose |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `q402_quote` | none | Compare gas cost and supported tokens across chains. Read-only. |
|
|
44
|
+
| `q402_balance` | API key | Verify the API key, show tier (live vs sandbox), and remaining quota. |
|
|
45
|
+
| `q402_pay` | API key + private key + flag | Send a gasless payment. **Sandbox by default** — see [Sandbox vs live mode](#sandbox-vs-live-mode). |
|
|
46
|
+
|
|
47
|
+
`q402_pay` follows a "confirm in chat first" contract: the tool description instructs the model to never call it without explicit user approval of the recipient address, amount, chain, and token.
|
|
48
|
+
|
|
49
|
+
> Per-chain gas tank balances and full transaction history live in the [dashboard](https://q402.quackai.ai/dashboard) — those endpoints require a wallet signature, not a bare API key, so the MCP server points the agent there instead of exposing them.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Sandbox vs live mode
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
To enable real on-chain transactions, **all three** environment variables must be set:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
Q402_API_KEY=q402_live_... # live-tier key from /dashboard
|
|
61
|
+
Q402_PRIVATE_KEY=0xabc... # signer for the payer EOA
|
|
62
|
+
Q402_ENABLE_REAL_PAYMENTS=1 # explicit opt-in
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Anything missing → automatic sandbox fallback with a hint pointing at what to set.
|
|
66
|
+
|
|
67
|
+
### Hard caps
|
|
68
|
+
|
|
69
|
+
Two additional guards run before every payment regardless of mode:
|
|
70
|
+
|
|
71
|
+
| Env var | Default | Effect |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `Q402_MAX_AMOUNT_PER_CALL` | `5` | Reject any single call where `amount > N` USD-equivalent. |
|
|
74
|
+
| `Q402_ALLOWED_RECIPIENTS` | (empty = off) | Comma-separated address allowlist. When set, all other recipients are rejected. |
|
|
75
|
+
|
|
76
|
+
Combined with the `confirm: true` argument the tool requires, this means the model needs (a) explicit user OK in chat, (b) amount ≤ cap, (c) recipient on allowlist if one exists, (d) all three live-mode env vars set, before a single wei moves.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Configuration reference
|
|
81
|
+
|
|
82
|
+
| Env var | Required for | Notes |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `Q402_API_KEY` | balance/history/live-pay | Issue at https://q402.quackai.ai/dashboard. `q402_test_*` keys keep sandbox on. |
|
|
85
|
+
| `Q402_PRIVATE_KEY` | live-pay | Signer for the payer EOA. **Never share. Never paste in chat.** |
|
|
86
|
+
| `Q402_ENABLE_REAL_PAYMENTS` | live-pay | Set to `1` to opt in. Any other value (or unset) → sandbox. |
|
|
87
|
+
| `Q402_MAX_AMOUNT_PER_CALL` | optional | USD-equivalent cap. Defaults to `5`. |
|
|
88
|
+
| `Q402_ALLOWED_RECIPIENTS` | optional | Comma-separated lowercase addresses. Defaults to no allowlist. |
|
|
89
|
+
| `Q402_RELAY_BASE_URL` | optional | Defaults to `https://q402.quackai.ai/api`. Override for self-hosted Q402. |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Supported chains
|
|
94
|
+
|
|
95
|
+
| Chain | Chain ID | Token(s) | Notes |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| BNB Chain | 56 | USDC, USDT | |
|
|
98
|
+
| Ethereum | 1 | USDC, USDT | L1 — gas is volatile, quote is a snapshot. |
|
|
99
|
+
| Avalanche C-Chain | 43114 | USDC, USDT | |
|
|
100
|
+
| X Layer | 196 | USDC, USDT | |
|
|
101
|
+
| Stable | 988 | USDT0 (USDC and USDT both alias) | Gas paid in USDT0. |
|
|
102
|
+
| Mantle | 5000 | USDC, USDT0 | LayerZero OFT USDT0 since 2025-11-27. |
|
|
103
|
+
| Injective EVM | 1776 | USDT only | Native USDC via Circle CCTP announced for Q2 2026. |
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Why this exists
|
|
108
|
+
|
|
109
|
+
x402 standardised "402 Payment Required" semantics for AI agents but the official Coinbase facilitator only covers a few chains and assumes ERC-3009 token support — which excludes BNB USDT, Mantle USDT0, Injective USDT, and the chains where most stablecoin volume actually lives.
|
|
110
|
+
|
|
111
|
+
Q402 implements the same payer experience (single signature, $0 gas, instant settlement) on all 7 of those chains using EIP-7702 delegated execution, which works with any ERC-20. This MCP server makes that infrastructure addressable from Claude itself.
|
|
112
|
+
|
|
113
|
+
If you want to dig into how the wire protocol differs from x402, see [Q402 docs](https://q402.quackai.ai/docs).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Repository
|
|
118
|
+
|
|
119
|
+
Source code: https://github.com/bitgett/q402-mcp
|
|
120
|
+
Issues / requests: https://github.com/bitgett/q402-mcp/issues
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
Apache-2.0 — see [LICENSE](./LICENSE).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import { isAddress } from "ethers";
|
|
13
|
+
var DEFAULT_RELAY_BASE = "https://q402.quackai.ai/api";
|
|
14
|
+
var DEFAULT_MAX_AMOUNT = 5;
|
|
15
|
+
function classifyApiKey(k) {
|
|
16
|
+
if (!k) return "missing";
|
|
17
|
+
if (k.startsWith("q402_live_")) return "live";
|
|
18
|
+
if (k.startsWith("q402_test_")) return "test";
|
|
19
|
+
return "missing";
|
|
20
|
+
}
|
|
21
|
+
function parseAllowedRecipients(raw) {
|
|
22
|
+
if (!raw) return [];
|
|
23
|
+
return raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0 && isAddress(s));
|
|
24
|
+
}
|
|
25
|
+
function parseMaxAmount(raw) {
|
|
26
|
+
if (!raw) return DEFAULT_MAX_AMOUNT;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_MAX_AMOUNT;
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
function loadConfig() {
|
|
32
|
+
const apiKey = process.env.Q402_API_KEY ?? null;
|
|
33
|
+
const apiKeyKind = classifyApiKey(apiKey);
|
|
34
|
+
const privateKey = process.env.Q402_PRIVATE_KEY ?? null;
|
|
35
|
+
const realPaymentsRequested = process.env.Q402_ENABLE_REAL_PAYMENTS === "1";
|
|
36
|
+
const live = realPaymentsRequested && apiKeyKind === "live" && typeof privateKey === "string" && privateKey.length > 0;
|
|
37
|
+
return {
|
|
38
|
+
apiKey,
|
|
39
|
+
apiKeyKind,
|
|
40
|
+
privateKey,
|
|
41
|
+
realPaymentsRequested,
|
|
42
|
+
mode: live ? "live" : "sandbox",
|
|
43
|
+
relayBaseUrl: (process.env.Q402_RELAY_BASE_URL ?? DEFAULT_RELAY_BASE).replace(/\/$/, ""),
|
|
44
|
+
maxAmountPerCallUsd: parseMaxAmount(process.env.Q402_MAX_AMOUNT_PER_CALL),
|
|
45
|
+
allowedRecipients: parseAllowedRecipients(process.env.Q402_ALLOWED_RECIPIENTS)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
var CONFIG = loadConfig();
|
|
49
|
+
|
|
50
|
+
// src/tools/quote.ts
|
|
51
|
+
import { z } from "zod";
|
|
52
|
+
|
|
53
|
+
// src/chains.ts
|
|
54
|
+
var CHAIN_KEYS = [
|
|
55
|
+
"avax",
|
|
56
|
+
"bnb",
|
|
57
|
+
"eth",
|
|
58
|
+
"xlayer",
|
|
59
|
+
"stable",
|
|
60
|
+
"mantle",
|
|
61
|
+
"injective"
|
|
62
|
+
];
|
|
63
|
+
var CHAIN_CONFIG = {
|
|
64
|
+
avax: {
|
|
65
|
+
key: "avax",
|
|
66
|
+
name: "Avalanche C-Chain",
|
|
67
|
+
chainId: 43114,
|
|
68
|
+
domainName: "Q402 Avalanche",
|
|
69
|
+
implContract: "0x96a8C74d95A35D0c14Ec60364c78ba6De99E9A4c",
|
|
70
|
+
gasToken: "AVAX",
|
|
71
|
+
explorer: "https://snowtrace.io",
|
|
72
|
+
usdc: { address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6 },
|
|
73
|
+
usdt: { address: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", decimals: 6 },
|
|
74
|
+
approxGasCostUsd: 3e-3
|
|
75
|
+
},
|
|
76
|
+
bnb: {
|
|
77
|
+
key: "bnb",
|
|
78
|
+
name: "BNB Chain",
|
|
79
|
+
chainId: 56,
|
|
80
|
+
domainName: "Q402 BNB Chain",
|
|
81
|
+
implContract: "0x6cF4aD62C208b6494a55a1494D497713ba013dFa",
|
|
82
|
+
gasToken: "BNB",
|
|
83
|
+
explorer: "https://bscscan.com",
|
|
84
|
+
usdc: { address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", decimals: 18 },
|
|
85
|
+
usdt: { address: "0x55d398326f99059fF775485246999027B3197955", decimals: 18 },
|
|
86
|
+
approxGasCostUsd: 1e-3
|
|
87
|
+
},
|
|
88
|
+
eth: {
|
|
89
|
+
key: "eth",
|
|
90
|
+
name: "Ethereum Mainnet",
|
|
91
|
+
chainId: 1,
|
|
92
|
+
domainName: "Q402 Ethereum",
|
|
93
|
+
implContract: "0x8E67a64989CFcb0C40556b13ea302709CCFD6AaD",
|
|
94
|
+
gasToken: "ETH",
|
|
95
|
+
explorer: "https://etherscan.io",
|
|
96
|
+
usdc: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
|
|
97
|
+
usdt: { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6 },
|
|
98
|
+
approxGasCostUsd: 1.2,
|
|
99
|
+
note: "L1 \u2014 gas is volatile; quote is a snapshot, expect 5\u201310x swings during congestion."
|
|
100
|
+
},
|
|
101
|
+
xlayer: {
|
|
102
|
+
key: "xlayer",
|
|
103
|
+
name: "X Layer",
|
|
104
|
+
chainId: 196,
|
|
105
|
+
domainName: "Q402 X Layer",
|
|
106
|
+
implContract: "0x8D854436ab0426F5BC6Cc70865C90576AD523E73",
|
|
107
|
+
gasToken: "OKB",
|
|
108
|
+
explorer: "https://www.oklink.com/xlayer",
|
|
109
|
+
usdc: { address: "0x74b7F16337b8972027F6196A17a631aC6dE26d22", decimals: 6 },
|
|
110
|
+
usdt: { address: "0x1E4a5963aBFD975d8c9021ce480b42188849D41D", decimals: 6 },
|
|
111
|
+
approxGasCostUsd: 2e-3
|
|
112
|
+
},
|
|
113
|
+
stable: {
|
|
114
|
+
key: "stable",
|
|
115
|
+
name: "Stable Chain",
|
|
116
|
+
chainId: 988,
|
|
117
|
+
domainName: "Q402 Stable",
|
|
118
|
+
implContract: "0x2fb2B2D110b6c5664e701666B3741240242bf350",
|
|
119
|
+
gasToken: "USDT0",
|
|
120
|
+
explorer: "https://stable-explorer.io",
|
|
121
|
+
// USDT0 (the only token on Stable) — both USDC and USDT API tokens resolve here.
|
|
122
|
+
usdc: { address: "0x779ded0c9e1022225f8e0630b35a9b54be713736", decimals: 18 },
|
|
123
|
+
usdt: { address: "0x779ded0c9e1022225f8e0630b35a9b54be713736", decimals: 18 },
|
|
124
|
+
approxGasCostUsd: 5e-4,
|
|
125
|
+
note: "Gas is paid in USDT0; both USDC and USDT API inputs alias to USDT0."
|
|
126
|
+
},
|
|
127
|
+
mantle: {
|
|
128
|
+
key: "mantle",
|
|
129
|
+
name: "Mantle",
|
|
130
|
+
chainId: 5e3,
|
|
131
|
+
domainName: "Q402 Mantle",
|
|
132
|
+
implContract: "0x2fb2B2D110b6c5664e701666B3741240242bf350",
|
|
133
|
+
gasToken: "MNT",
|
|
134
|
+
explorer: "https://mantlescan.xyz",
|
|
135
|
+
usdc: { address: "0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9", decimals: 6 },
|
|
136
|
+
// USDT0 (LayerZero OFT) — Mantle ecosystem default since the 2025-11-27 migration.
|
|
137
|
+
usdt: { address: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", decimals: 6 },
|
|
138
|
+
approxGasCostUsd: 2e-3
|
|
139
|
+
},
|
|
140
|
+
injective: {
|
|
141
|
+
key: "injective",
|
|
142
|
+
name: "Injective EVM",
|
|
143
|
+
chainId: 1776,
|
|
144
|
+
domainName: "Q402 Injective",
|
|
145
|
+
implContract: "0x2fb2B2D110b6c5664e701666B3741240242bf350",
|
|
146
|
+
gasToken: "INJ",
|
|
147
|
+
explorer: "https://blockscout.injective.network",
|
|
148
|
+
// Mirror entry for the registry shape — both API tokens point at the same USDT.
|
|
149
|
+
// q402_pay rejects token: "USDC" with chain: "injective" upfront via supportedTokens.
|
|
150
|
+
usdc: { address: "0x88f7F2b685F9692caf8c478f5BADF09eE9B1Cc13", decimals: 6 },
|
|
151
|
+
usdt: { address: "0x88f7F2b685F9692caf8c478f5BADF09eE9B1Cc13", decimals: 6 },
|
|
152
|
+
supportedTokens: ["USDT"],
|
|
153
|
+
approxGasCostUsd: 4e-3,
|
|
154
|
+
note: "USDT only until Circle CCTP native USDC ships (announced for Q2 2026)."
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function getChain(key) {
|
|
158
|
+
const cfg = CHAIN_CONFIG[key];
|
|
159
|
+
if (!cfg) throw new Error(`Unsupported chain: ${key}. Supported: ${CHAIN_KEYS.join(", ")}`);
|
|
160
|
+
return cfg;
|
|
161
|
+
}
|
|
162
|
+
function tokenFor(cfg, token) {
|
|
163
|
+
return token === "USDC" ? cfg.usdc : cfg.usdt;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/tools/quote.ts
|
|
167
|
+
var QuoteInputSchema = z.object({
|
|
168
|
+
amount: z.string().regex(/^\d+(\.\d+)?$/, 'amount must be a positive decimal string like "5.00"').describe('Human-readable decimal amount the user intends to send (e.g. "5", "50.00").'),
|
|
169
|
+
token: z.enum(["USDC", "USDT"]).optional().describe("Optional token filter. When omitted, both stablecoin options are reported."),
|
|
170
|
+
chain: z.enum(["avax", "bnb", "eth", "xlayer", "stable", "mantle", "injective"]).optional().describe(
|
|
171
|
+
"Optional chain filter. When omitted, all 7 chains are compared and ranked by gas cost."
|
|
172
|
+
)
|
|
173
|
+
});
|
|
174
|
+
function quoteForChain(cfg) {
|
|
175
|
+
const supported = cfg.supportedTokens ?? ["USDC", "USDT"];
|
|
176
|
+
return {
|
|
177
|
+
chain: cfg.key,
|
|
178
|
+
name: cfg.name,
|
|
179
|
+
chainId: cfg.chainId,
|
|
180
|
+
gasToken: cfg.gasToken,
|
|
181
|
+
approxGasCostUsd: cfg.approxGasCostUsd,
|
|
182
|
+
supportedTokens: supported,
|
|
183
|
+
gasTokenForReceiver: "0 (gasless)",
|
|
184
|
+
...cfg.note ? { note: cfg.note } : {}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function runQuote(input) {
|
|
188
|
+
const filterChain = input.chain;
|
|
189
|
+
const filterToken = input.token;
|
|
190
|
+
const candidates = (filterChain ? [filterChain] : CHAIN_KEYS).map((k) => CHAIN_CONFIG[k]).filter((cfg) => {
|
|
191
|
+
if (!filterToken) return true;
|
|
192
|
+
if (cfg.supportedTokens && !cfg.supportedTokens.includes(filterToken)) return false;
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
if (candidates.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`No chain in the registry supports token ${filterToken}. Try omitting the token filter to see all options.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const quotes = candidates.map(quoteForChain).sort((a, b) => a.approxGasCostUsd - b.approxGasCostUsd);
|
|
201
|
+
const cheapest = quotes[0];
|
|
202
|
+
return {
|
|
203
|
+
amount: input.amount,
|
|
204
|
+
recommendedChain: cheapest.chain,
|
|
205
|
+
quotes,
|
|
206
|
+
disclaimer: "Gas cost is order-of-magnitude only. Real cost depends on network congestion at relay time. Q402 always charges $0 to the payer's wallet \u2014 gas is paid from the developer's pre-funded gas tank."
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
var QUOTE_TOOL = {
|
|
210
|
+
name: "q402_quote",
|
|
211
|
+
description: "Compare gas costs and supported tokens across the 7 chains Q402 relays for. Read-only \u2014 no API key needed, no funds move. Use this before q402_pay so the user can pick the cheapest chain.",
|
|
212
|
+
// Plain JSON schema mirroring the Zod schema above; MCP servers receive parameters as JSON.
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
amount: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: 'Human-readable decimal amount, e.g. "5" or "50.00".'
|
|
219
|
+
},
|
|
220
|
+
token: {
|
|
221
|
+
type: "string",
|
|
222
|
+
enum: ["USDC", "USDT"],
|
|
223
|
+
description: "Optional token filter."
|
|
224
|
+
},
|
|
225
|
+
chain: {
|
|
226
|
+
type: "string",
|
|
227
|
+
enum: CHAIN_KEYS,
|
|
228
|
+
description: "Optional chain filter; omit to compare all 7."
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
required: ["amount"],
|
|
232
|
+
additionalProperties: false
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/tools/pay.ts
|
|
237
|
+
import { isAddress as isAddress2 } from "ethers";
|
|
238
|
+
import { z as z2 } from "zod";
|
|
239
|
+
|
|
240
|
+
// src/client.ts
|
|
241
|
+
import {
|
|
242
|
+
JsonRpcProvider,
|
|
243
|
+
Wallet,
|
|
244
|
+
hexlify,
|
|
245
|
+
parseUnits,
|
|
246
|
+
randomBytes,
|
|
247
|
+
toBigInt
|
|
248
|
+
} from "ethers";
|
|
249
|
+
var DEFAULT_RPC = {
|
|
250
|
+
1: "https://ethereum.publicnode.com",
|
|
251
|
+
56: "https://bsc-dataseed1.binance.org/",
|
|
252
|
+
43114: "https://api.avax.network/ext/bc/C/rpc",
|
|
253
|
+
196: "https://rpc.xlayer.tech",
|
|
254
|
+
988: "https://rpc.stable.xyz",
|
|
255
|
+
5e3: "https://rpc.mantle.xyz",
|
|
256
|
+
1776: "https://sentry.evm-rpc.injective.network/"
|
|
257
|
+
};
|
|
258
|
+
var TRANSFER_AUTH_TYPES = {
|
|
259
|
+
TransferAuthorization: [
|
|
260
|
+
{ name: "owner", type: "address" },
|
|
261
|
+
{ name: "facilitator", type: "address" },
|
|
262
|
+
{ name: "token", type: "address" },
|
|
263
|
+
{ name: "recipient", type: "address" },
|
|
264
|
+
{ name: "amount", type: "uint256" },
|
|
265
|
+
{ name: "nonce", type: "uint256" },
|
|
266
|
+
{ name: "deadline", type: "uint256" }
|
|
267
|
+
]
|
|
268
|
+
};
|
|
269
|
+
var AUTHORIZATION_TYPES = {
|
|
270
|
+
Authorization: [
|
|
271
|
+
{ name: "address", type: "address" },
|
|
272
|
+
{ name: "nonce", type: "uint256" }
|
|
273
|
+
]
|
|
274
|
+
};
|
|
275
|
+
function toRawAmount(amount, decimals) {
|
|
276
|
+
if (typeof amount !== "string" || amount.trim() === "") {
|
|
277
|
+
throw new Error('amount must be a non-empty decimal string (e.g. "5.00")');
|
|
278
|
+
}
|
|
279
|
+
if (!/^\d+(\.\d+)?$/.test(amount)) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`invalid amount "${amount}" \u2014 use a positive decimal string (no sign, no scientific notation, no whitespace)`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
let raw;
|
|
285
|
+
try {
|
|
286
|
+
raw = parseUnits(amount, decimals);
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error(`amount "${amount}" exceeds ${decimals} decimal places`);
|
|
289
|
+
}
|
|
290
|
+
if (raw <= 0n) {
|
|
291
|
+
throw new Error(`amount must be greater than zero (got "${amount}")`);
|
|
292
|
+
}
|
|
293
|
+
return raw.toString();
|
|
294
|
+
}
|
|
295
|
+
async function signAuthorization(wallet, args) {
|
|
296
|
+
const domain = { name: "EIP7702Authorization", version: "1", chainId: args.chainId };
|
|
297
|
+
const sig = await wallet.signTypedData(domain, AUTHORIZATION_TYPES, {
|
|
298
|
+
address: args.address,
|
|
299
|
+
nonce: args.nonce
|
|
300
|
+
});
|
|
301
|
+
const r = sig.slice(0, 66);
|
|
302
|
+
const s = "0x" + sig.slice(66, 130);
|
|
303
|
+
const v = parseInt(sig.slice(130, 132), 16);
|
|
304
|
+
const yParity = v === 27 ? 0 : 1;
|
|
305
|
+
return { chainId: args.chainId, address: args.address, nonce: args.nonce, yParity, r, s };
|
|
306
|
+
}
|
|
307
|
+
var Q402NodeClient = class _Q402NodeClient {
|
|
308
|
+
opts;
|
|
309
|
+
constructor(opts) {
|
|
310
|
+
this.opts = opts;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Build a TX-shaped explorer URL from the chain's explorer base.
|
|
314
|
+
*/
|
|
315
|
+
static explorerUrl(chain, txHash) {
|
|
316
|
+
if (!txHash) return null;
|
|
317
|
+
return `${chain.explorer.replace(/\/$/, "")}/tx/${txHash}`;
|
|
318
|
+
}
|
|
319
|
+
async fetchFacilitator() {
|
|
320
|
+
const url = `${this.opts.relayBaseUrl.replace(/\/$/, "")}/relay/info`;
|
|
321
|
+
const resp = await fetch(url);
|
|
322
|
+
if (!resp.ok) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`failed to fetch relay facilitator info from ${url} (${resp.status})`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const data = await resp.json();
|
|
328
|
+
if (!data.facilitator || typeof data.facilitator !== "string") {
|
|
329
|
+
throw new Error("relay/info did not return a facilitator address");
|
|
330
|
+
}
|
|
331
|
+
return data.facilitator;
|
|
332
|
+
}
|
|
333
|
+
async pay(input) {
|
|
334
|
+
const { chain, relayBaseUrl, apiKey, privateKey } = this.opts;
|
|
335
|
+
const tokenCfg = tokenFor(chain, input.token);
|
|
336
|
+
if (chain.supportedTokens && !chain.supportedTokens.includes(input.token)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`token ${input.token} is not supported on chain ${chain.key}. Supported: ${chain.supportedTokens.join(", ")}.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const amountRaw = toRawAmount(input.amount, tokenCfg.decimals);
|
|
342
|
+
const deadline = Math.floor(Date.now() / 1e3) + 600;
|
|
343
|
+
const rpcUrl = this.opts.rpcUrl ?? DEFAULT_RPC[chain.chainId];
|
|
344
|
+
if (!rpcUrl) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`no RPC URL configured for chain ${chain.key} (chainId ${chain.chainId})`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const provider = new JsonRpcProvider(rpcUrl);
|
|
350
|
+
const wallet = new Wallet(privateKey, provider);
|
|
351
|
+
const owner = await wallet.getAddress();
|
|
352
|
+
const facilitator = await this.fetchFacilitator();
|
|
353
|
+
const paymentNonce = toBigInt(randomBytes(32));
|
|
354
|
+
const witnessSig = await wallet.signTypedData(
|
|
355
|
+
{
|
|
356
|
+
name: chain.domainName,
|
|
357
|
+
version: "1",
|
|
358
|
+
chainId: chain.chainId,
|
|
359
|
+
verifyingContract: owner
|
|
360
|
+
// EIP-7702: address(this) resolves to the EOA
|
|
361
|
+
},
|
|
362
|
+
TRANSFER_AUTH_TYPES,
|
|
363
|
+
{
|
|
364
|
+
owner,
|
|
365
|
+
facilitator,
|
|
366
|
+
token: tokenCfg.address,
|
|
367
|
+
recipient: input.to,
|
|
368
|
+
amount: BigInt(amountRaw),
|
|
369
|
+
nonce: paymentNonce,
|
|
370
|
+
deadline: BigInt(deadline)
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
const authNonce = await provider.getTransactionCount(owner);
|
|
374
|
+
const authorization = await signAuthorization(wallet, {
|
|
375
|
+
chainId: chain.chainId,
|
|
376
|
+
address: chain.implContract,
|
|
377
|
+
nonce: authNonce
|
|
378
|
+
});
|
|
379
|
+
const baseBody = {
|
|
380
|
+
apiKey,
|
|
381
|
+
chain: chain.key,
|
|
382
|
+
token: input.token,
|
|
383
|
+
from: owner,
|
|
384
|
+
to: input.to,
|
|
385
|
+
amount: amountRaw,
|
|
386
|
+
deadline,
|
|
387
|
+
witnessSig,
|
|
388
|
+
authorization,
|
|
389
|
+
facilitator
|
|
390
|
+
};
|
|
391
|
+
const body = chain.key === "xlayer" ? { ...baseBody, xlayerNonce: paymentNonce.toString() } : chain.key === "stable" ? { ...baseBody, stableNonce: paymentNonce.toString() } : { ...baseBody, nonce: paymentNonce.toString() };
|
|
392
|
+
const resp = await fetch(`${relayBaseUrl.replace(/\/$/, "")}/relay`, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: { "Content-Type": "application/json" },
|
|
395
|
+
body: JSON.stringify(body)
|
|
396
|
+
});
|
|
397
|
+
const data = await resp.json();
|
|
398
|
+
if (!resp.ok) {
|
|
399
|
+
throw new Error(data.error ?? `relay failed (HTTP ${resp.status})`);
|
|
400
|
+
}
|
|
401
|
+
data.mode = "live";
|
|
402
|
+
data.explorerUrl = _Q402NodeClient.explorerUrl(chain, data.txHash);
|
|
403
|
+
return data;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
function sandboxPay(chain, input) {
|
|
407
|
+
const tokenCfg = tokenFor(chain, input.token);
|
|
408
|
+
const tokenAmount = toRawAmount(input.amount, tokenCfg.decimals);
|
|
409
|
+
const fakeHash = "0x" + hexlify(randomBytes(32)).slice(2);
|
|
410
|
+
return {
|
|
411
|
+
success: true,
|
|
412
|
+
txHash: fakeHash,
|
|
413
|
+
tokenAmount,
|
|
414
|
+
token: input.token,
|
|
415
|
+
chain: chain.key,
|
|
416
|
+
method: "sandbox",
|
|
417
|
+
mode: "sandbox",
|
|
418
|
+
explorerUrl: null
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/tools/pay.ts
|
|
423
|
+
var PayInputSchema = z2.object({
|
|
424
|
+
chain: z2.enum(["avax", "bnb", "eth", "xlayer", "stable", "mantle", "injective"]),
|
|
425
|
+
to: z2.string().refine(isAddress2, "to must be a valid 0x-prefixed EVM address").describe("Recipient EVM address (0x + 40 hex)."),
|
|
426
|
+
amount: z2.string().regex(/^\d+(\.\d+)?$/, "amount must be a positive decimal string").describe('Human-readable decimal amount, e.g. "5.00".'),
|
|
427
|
+
token: z2.enum(["USDC", "USDT"]),
|
|
428
|
+
confirm: z2.literal(true).describe(
|
|
429
|
+
"MUST be true. Prove the user explicitly approved this exact recipient and amount in the conversation right before this tool was called. Setting this to true on behalf of the user without confirmation is a violation of the tool contract."
|
|
430
|
+
)
|
|
431
|
+
});
|
|
432
|
+
function maxAmountGuard(amount, cap) {
|
|
433
|
+
const numeric = Number(amount);
|
|
434
|
+
if (!Number.isFinite(numeric)) {
|
|
435
|
+
throw new Error(`unparseable amount "${amount}"`);
|
|
436
|
+
}
|
|
437
|
+
if (numeric > cap) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`amount $${amount} exceeds the per-call cap of $${cap}. Set Q402_MAX_AMOUNT_PER_CALL to a higher value if intentional.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function recipientGuard(to, allow) {
|
|
444
|
+
if (allow.length === 0) return;
|
|
445
|
+
if (!allow.includes(to.toLowerCase())) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`recipient ${to} is not in Q402_ALLOWED_RECIPIENTS. Either add this address to the allowlist or unset the env var to disable the guard.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function runPay(input) {
|
|
452
|
+
const chain = getChain(input.chain);
|
|
453
|
+
tokenFor(chain, input.token);
|
|
454
|
+
if (chain.supportedTokens && !chain.supportedTokens.includes(input.token)) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`token ${input.token} is not supported on chain ${chain.key}. Supported on this chain: ${chain.supportedTokens.join(", ")}.`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const guardsApplied = [];
|
|
460
|
+
maxAmountGuard(input.amount, CONFIG.maxAmountPerCallUsd);
|
|
461
|
+
guardsApplied.push(`max_amount<=${CONFIG.maxAmountPerCallUsd}`);
|
|
462
|
+
recipientGuard(input.to, CONFIG.allowedRecipients);
|
|
463
|
+
if (CONFIG.allowedRecipients.length > 0) {
|
|
464
|
+
guardsApplied.push(`recipient_allowlist[${CONFIG.allowedRecipients.length}]`);
|
|
465
|
+
}
|
|
466
|
+
if (CONFIG.mode === "sandbox") {
|
|
467
|
+
const result2 = sandboxPay(chain, {
|
|
468
|
+
to: input.to,
|
|
469
|
+
amount: input.amount,
|
|
470
|
+
token: input.token
|
|
471
|
+
});
|
|
472
|
+
guardsApplied.push("mode=sandbox");
|
|
473
|
+
const setupHint = describeSandboxReason();
|
|
474
|
+
return { result: result2, guardsApplied, setupHint };
|
|
475
|
+
}
|
|
476
|
+
const client = new Q402NodeClient({
|
|
477
|
+
apiKey: CONFIG.apiKey,
|
|
478
|
+
privateKey: CONFIG.privateKey,
|
|
479
|
+
chain,
|
|
480
|
+
relayBaseUrl: CONFIG.relayBaseUrl
|
|
481
|
+
});
|
|
482
|
+
const result = await client.pay({
|
|
483
|
+
to: input.to,
|
|
484
|
+
amount: input.amount,
|
|
485
|
+
token: input.token
|
|
486
|
+
});
|
|
487
|
+
guardsApplied.push("mode=live");
|
|
488
|
+
return { result, guardsApplied };
|
|
489
|
+
}
|
|
490
|
+
function describeSandboxReason() {
|
|
491
|
+
const missing = [];
|
|
492
|
+
if (CONFIG.apiKeyKind !== "live") missing.push("Q402_API_KEY (must start with q402_live_)");
|
|
493
|
+
if (!CONFIG.privateKey) missing.push("Q402_PRIVATE_KEY");
|
|
494
|
+
if (!CONFIG.realPaymentsRequested) missing.push("Q402_ENABLE_REAL_PAYMENTS=1");
|
|
495
|
+
if (missing.length === 0) return "Sandbox mode active (no env state change needed).";
|
|
496
|
+
return "Sandbox mode is active because the following env vars are missing or not yet set: " + missing.join(", ") + ". Get a live API key at https://q402.quackai.ai/dashboard.";
|
|
497
|
+
}
|
|
498
|
+
var PAY_TOOL = {
|
|
499
|
+
name: "q402_pay",
|
|
500
|
+
description: "Send a gasless USDC or USDT payment via Q402. SANDBOX BY DEFAULT \u2014 no funds move unless Q402_API_KEY (live tier), Q402_PRIVATE_KEY, and Q402_ENABLE_REAL_PAYMENTS=1 are all set. The recipient receives the full amount; the sender pays $0 in gas. ALWAYS get explicit user confirmation of the exact recipient address, amount, chain, and token in conversation immediately before calling this tool.",
|
|
501
|
+
inputSchema: {
|
|
502
|
+
type: "object",
|
|
503
|
+
properties: {
|
|
504
|
+
chain: {
|
|
505
|
+
type: "string",
|
|
506
|
+
enum: CHAIN_KEYS,
|
|
507
|
+
description: "Target chain."
|
|
508
|
+
},
|
|
509
|
+
to: {
|
|
510
|
+
type: "string",
|
|
511
|
+
description: "Recipient EVM address (0x + 40 hex)."
|
|
512
|
+
},
|
|
513
|
+
amount: {
|
|
514
|
+
type: "string",
|
|
515
|
+
description: 'Human-readable decimal amount, e.g. "5.00".'
|
|
516
|
+
},
|
|
517
|
+
token: {
|
|
518
|
+
type: "string",
|
|
519
|
+
enum: ["USDC", "USDT"],
|
|
520
|
+
description: "Stablecoin to send. On Injective only USDT is currently supported."
|
|
521
|
+
},
|
|
522
|
+
confirm: {
|
|
523
|
+
type: "boolean",
|
|
524
|
+
const: true,
|
|
525
|
+
description: "MUST be true and only set after the user has confirmed recipient + amount in chat."
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
required: ["chain", "to", "amount", "token", "confirm"],
|
|
529
|
+
additionalProperties: false
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// src/tools/balance.ts
|
|
534
|
+
import { z as z3 } from "zod";
|
|
535
|
+
var BalanceInputSchema = z3.object({});
|
|
536
|
+
function mask(key) {
|
|
537
|
+
if (!key || key.length < 12) return null;
|
|
538
|
+
return `${key.slice(0, 12)}\u2026${key.slice(-4)}`;
|
|
539
|
+
}
|
|
540
|
+
async function runBalance() {
|
|
541
|
+
if (CONFIG.apiKeyKind === "missing") {
|
|
542
|
+
return {
|
|
543
|
+
apiKeyKind: "missing",
|
|
544
|
+
apiKeyMasked: null,
|
|
545
|
+
dashboardUrl: "https://q402.quackai.ai/dashboard",
|
|
546
|
+
setupHint: "Set Q402_API_KEY to a key issued at https://q402.quackai.ai/dashboard. Test-tier keys (q402_test_*) work too \u2014 they show sandbox quota."
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const resp = await fetch(`${CONFIG.relayBaseUrl}/keys/verify`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({ apiKey: CONFIG.apiKey })
|
|
553
|
+
});
|
|
554
|
+
const verifyJson = resp.ok ? await resp.json() : { error: `HTTP ${resp.status}` };
|
|
555
|
+
return {
|
|
556
|
+
apiKeyKind: CONFIG.apiKeyKind,
|
|
557
|
+
apiKeyMasked: mask(CONFIG.apiKey),
|
|
558
|
+
verify: verifyJson,
|
|
559
|
+
dashboardUrl: "https://q402.quackai.ai/dashboard"
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
var BALANCE_TOOL = {
|
|
563
|
+
name: "q402_balance",
|
|
564
|
+
description: "Verify the configured API key and show its tier (live vs sandbox) and remaining subscription quota. Read-only. For per-chain gas tank balances, point the user at https://q402.quackai.ai/dashboard \u2014 that data needs a wallet signature, not a bare key.",
|
|
565
|
+
inputSchema: {
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: {},
|
|
568
|
+
additionalProperties: false
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// src/index.ts
|
|
573
|
+
var PACKAGE_NAME = "@quackai/q402-mcp";
|
|
574
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
575
|
+
function jsonText(value) {
|
|
576
|
+
return { type: "text", text: JSON.stringify(value, null, 2) };
|
|
577
|
+
}
|
|
578
|
+
async function main() {
|
|
579
|
+
const server = new Server(
|
|
580
|
+
{ name: PACKAGE_NAME, version: PACKAGE_VERSION },
|
|
581
|
+
{ capabilities: { tools: {} } }
|
|
582
|
+
);
|
|
583
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
584
|
+
tools: [QUOTE_TOOL, BALANCE_TOOL, PAY_TOOL]
|
|
585
|
+
}));
|
|
586
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
587
|
+
const { name, arguments: args } = req.params;
|
|
588
|
+
try {
|
|
589
|
+
switch (name) {
|
|
590
|
+
case "q402_quote": {
|
|
591
|
+
const parsed = QuoteInputSchema.parse(args ?? {});
|
|
592
|
+
return { content: [jsonText(runQuote(parsed))] };
|
|
593
|
+
}
|
|
594
|
+
case "q402_balance": {
|
|
595
|
+
BalanceInputSchema.parse(args ?? {});
|
|
596
|
+
return { content: [jsonText(await runBalance())] };
|
|
597
|
+
}
|
|
598
|
+
case "q402_pay": {
|
|
599
|
+
const parsed = PayInputSchema.parse(args ?? {});
|
|
600
|
+
return { content: [jsonText(await runPay(parsed))] };
|
|
601
|
+
}
|
|
602
|
+
default:
|
|
603
|
+
return {
|
|
604
|
+
isError: true,
|
|
605
|
+
content: [jsonText({ error: `unknown tool: ${name}` })]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
610
|
+
return {
|
|
611
|
+
isError: true,
|
|
612
|
+
content: [jsonText({ error: message, tool: name })]
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
const transport = new StdioServerTransport();
|
|
617
|
+
await server.connect(transport);
|
|
618
|
+
process.stderr.write(
|
|
619
|
+
`${PACKAGE_NAME} v${PACKAGE_VERSION} ready (mode=${CONFIG.mode}, cap=$${CONFIG.maxAmountPerCallUsd}, allowlist=${CONFIG.allowedRecipients.length})
|
|
620
|
+
`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
main().catch((err) => {
|
|
624
|
+
process.stderr.write(`fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
625
|
+
`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quackai/q402-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Q402 — gasless stablecoin payments across 7 EVM chains, callable directly from Claude Desktop and any MCP-compatible AI agent.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"claude",
|
|
9
|
+
"q402",
|
|
10
|
+
"x402",
|
|
11
|
+
"stablecoin",
|
|
12
|
+
"usdc",
|
|
13
|
+
"usdt",
|
|
14
|
+
"gasless",
|
|
15
|
+
"eip-7702",
|
|
16
|
+
"payments",
|
|
17
|
+
"ai-agents"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"bin": {
|
|
22
|
+
"q402-mcp": "dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"dev": "tsup --watch",
|
|
35
|
+
"prepublishOnly": "npm run build",
|
|
36
|
+
"start": "node dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
40
|
+
"ethers": "^6.16.0",
|
|
41
|
+
"zod": "^3.23.8"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.11.0",
|
|
45
|
+
"tsup": "^8.3.0",
|
|
46
|
+
"typescript": "^5.5.0"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/bitgett/q402-mcp.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://q402.quackai.ai/claude",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/bitgett/q402-mcp/issues"
|
|
55
|
+
},
|
|
56
|
+
"license": "Apache-2.0",
|
|
57
|
+
"author": "Quack AI Labs <hello@quackai.ai>",
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|