@azeth/mcp-server 0.2.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 +21 -0
- package/README.md +141 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/account.d.ts +4 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +640 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/agreements.d.ts +4 -0
- package/dist/tools/agreements.d.ts.map +1 -0
- package/dist/tools/agreements.js +865 -0
- package/dist/tools/agreements.js.map +1 -0
- package/dist/tools/guardian-approval.d.ts +4 -0
- package/dist/tools/guardian-approval.d.ts.map +1 -0
- package/dist/tools/guardian-approval.js +319 -0
- package/dist/tools/guardian-approval.js.map +1 -0
- package/dist/tools/guardian.d.ts +4 -0
- package/dist/tools/guardian.d.ts.map +1 -0
- package/dist/tools/guardian.js +267 -0
- package/dist/tools/guardian.js.map +1 -0
- package/dist/tools/messaging.d.ts +4 -0
- package/dist/tools/messaging.d.ts.map +1 -0
- package/dist/tools/messaging.js +353 -0
- package/dist/tools/messaging.js.map +1 -0
- package/dist/tools/payments.d.ts +14 -0
- package/dist/tools/payments.d.ts.map +1 -0
- package/dist/tools/payments.js +723 -0
- package/dist/tools/payments.js.map +1 -0
- package/dist/tools/registry.d.ts +4 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +608 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/reputation.d.ts +4 -0
- package/dist/tools/reputation.d.ts.map +1 -0
- package/dist/tools/reputation.js +433 -0
- package/dist/tools/reputation.js.map +1 -0
- package/dist/tools/transfer.d.ts +4 -0
- package/dist/tools/transfer.d.ts.map +1 -0
- package/dist/tools/transfer.js +181 -0
- package/dist/tools/transfer.js.map +1 -0
- package/dist/utils/client.d.ts +25 -0
- package/dist/utils/client.d.ts.map +1 -0
- package/dist/utils/client.js +100 -0
- package/dist/utils/client.js.map +1 -0
- package/dist/utils/error-selectors.d.ts +23 -0
- package/dist/utils/error-selectors.d.ts.map +1 -0
- package/dist/utils/error-selectors.js +159 -0
- package/dist/utils/error-selectors.js.map +1 -0
- package/dist/utils/rate-limit.d.ts +17 -0
- package/dist/utils/rate-limit.d.ts.map +1 -0
- package/dist/utils/rate-limit.js +75 -0
- package/dist/utils/rate-limit.js.map +1 -0
- package/dist/utils/resolve.d.ts +38 -0
- package/dist/utils/resolve.d.ts.map +1 -0
- package/dist/utils/resolve.js +308 -0
- package/dist/utils/resolve.js.map +1 -0
- package/dist/utils/response.d.ts +42 -0
- package/dist/utils/response.d.ts.map +1 -0
- package/dist/utils/response.js +257 -0
- package/dist/utils/response.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import dns from 'node:dns/promises';
|
|
4
|
+
import { isAddress, parseUnits } from 'viem';
|
|
5
|
+
import { AzethError, AZETH_CONTRACTS, TOKENS, formatTokenAmount } from '@azeth/common';
|
|
6
|
+
import { createClient, resolveChain, validateAddress } from '../utils/client.js';
|
|
7
|
+
import { resolveAddress } from '../utils/resolve.js';
|
|
8
|
+
import { success, error, handleError, guardianRequiredError } from '../utils/response.js';
|
|
9
|
+
/** Maximum response body size returned to the MCP caller (100 KB) */
|
|
10
|
+
const MAX_RESPONSE_SIZE = 100_000;
|
|
11
|
+
/** Check if an IPv4 address is in a private/reserved range */
|
|
12
|
+
function isPrivateIPv4(ip) {
|
|
13
|
+
const parts = ip.split('.').map(Number);
|
|
14
|
+
if (parts.length !== 4 || parts.some(p => isNaN(p)))
|
|
15
|
+
return false;
|
|
16
|
+
const [a, b] = parts;
|
|
17
|
+
return (a === 127 || // loopback
|
|
18
|
+
a === 10 || // 10.0.0.0/8
|
|
19
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
|
|
20
|
+
(a === 192 && b === 168) || // 192.168.0.0/16
|
|
21
|
+
(a === 169 && b === 254) || // link-local
|
|
22
|
+
a === 0 // 0.0.0.0/8
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
/** Check if an IPv6 address is in a private/reserved range */
|
|
26
|
+
function isPrivateIPv6(ip) {
|
|
27
|
+
const lower = ip.toLowerCase();
|
|
28
|
+
return (lower === '::1' ||
|
|
29
|
+
lower.startsWith('fc') ||
|
|
30
|
+
lower.startsWith('fd') ||
|
|
31
|
+
lower.startsWith('fe80') ||
|
|
32
|
+
lower.startsWith('::ffff:127.') ||
|
|
33
|
+
lower.startsWith('::ffff:10.') ||
|
|
34
|
+
lower.startsWith('::ffff:172.16.') || lower.startsWith('::ffff:172.17.') ||
|
|
35
|
+
lower.startsWith('::ffff:172.18.') || lower.startsWith('::ffff:172.19.') ||
|
|
36
|
+
lower.startsWith('::ffff:172.20.') || lower.startsWith('::ffff:172.21.') ||
|
|
37
|
+
lower.startsWith('::ffff:172.22.') || lower.startsWith('::ffff:172.23.') ||
|
|
38
|
+
lower.startsWith('::ffff:172.24.') || lower.startsWith('::ffff:172.25.') ||
|
|
39
|
+
lower.startsWith('::ffff:172.26.') || lower.startsWith('::ffff:172.27.') ||
|
|
40
|
+
lower.startsWith('::ffff:172.28.') || lower.startsWith('::ffff:172.29.') ||
|
|
41
|
+
lower.startsWith('::ffff:172.30.') || lower.startsWith('::ffff:172.31.') ||
|
|
42
|
+
lower.startsWith('::ffff:192.168.') ||
|
|
43
|
+
lower.startsWith('::ffff:169.254.') ||
|
|
44
|
+
lower.startsWith('::ffff:0.') ||
|
|
45
|
+
lower === '::' ||
|
|
46
|
+
lower === '::ffff:0.0.0.0');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Safely truncate a string without splitting surrogate pairs.
|
|
50
|
+
* Appends '... [truncated]' when truncation occurs.
|
|
51
|
+
*/
|
|
52
|
+
function safeTruncate(str, maxLength) {
|
|
53
|
+
if (str.length <= maxLength)
|
|
54
|
+
return str;
|
|
55
|
+
let end = maxLength;
|
|
56
|
+
const code = str.charCodeAt(end - 1);
|
|
57
|
+
if (code >= 0xD800 && code <= 0xDBFF) {
|
|
58
|
+
end--; // Don't split a surrogate pair
|
|
59
|
+
}
|
|
60
|
+
return str.slice(0, end) + '... [truncated]';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate that a URL is external (HTTPS, not pointing to internal/private addresses).
|
|
64
|
+
* F-9: Resolves hostname via DNS to catch rebinding bypasses.
|
|
65
|
+
* HIGH-7 fix: Returns pinned IPs for the caller to use when making the actual request.
|
|
66
|
+
*/
|
|
67
|
+
async function validateExternalUrl(urlStr) {
|
|
68
|
+
const url = new URL(urlStr);
|
|
69
|
+
if (url.protocol !== 'https:') {
|
|
70
|
+
throw new AzethError('URL must use HTTPS', 'INVALID_INPUT');
|
|
71
|
+
}
|
|
72
|
+
const hostname = url.hostname.toLowerCase();
|
|
73
|
+
// String-based blocklist for obvious patterns (fast path)
|
|
74
|
+
const blockedPatterns = [
|
|
75
|
+
'localhost', '127.0.0.1', '0.0.0.0', '::1',
|
|
76
|
+
'169.254.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
|
|
77
|
+
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
|
|
78
|
+
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.',
|
|
79
|
+
'192.168.', 'fc00:', 'fd00:', 'fe80:',
|
|
80
|
+
'::ffff:127.', '::ffff:10.', '::ffff:172.16.', '::ffff:192.168.',
|
|
81
|
+
'::ffff:169.254.',
|
|
82
|
+
];
|
|
83
|
+
for (const pattern of blockedPatterns) {
|
|
84
|
+
if (hostname === pattern || hostname.startsWith(pattern)) {
|
|
85
|
+
throw new AzethError('URL points to an internal or private network address. Only public HTTPS URLs are allowed.', 'INVALID_INPUT');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// F-9: DNS resolution check to catch rebinding/alternative encoding bypasses
|
|
89
|
+
// HIGH-7 fix: Pin the resolved IPv4 addresses so they can be used for the actual
|
|
90
|
+
// fetch connection, preventing TOCTOU DNS rebinding attacks.
|
|
91
|
+
let pinnedIPv4 = [];
|
|
92
|
+
try {
|
|
93
|
+
const addresses = await dns.resolve4(hostname);
|
|
94
|
+
for (const addr of addresses) {
|
|
95
|
+
if (isPrivateIPv4(addr)) {
|
|
96
|
+
throw new AzethError('URL resolves to a private or reserved IP address. Only public HTTPS URLs are allowed.', 'INVALID_INPUT');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
pinnedIPv4 = addresses;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err instanceof AzethError)
|
|
103
|
+
throw err;
|
|
104
|
+
// C-3: DNS resolution failure must REJECT — cannot verify URL safety
|
|
105
|
+
// Differentiate "hostname doesn't exist" (input error) from "DNS unreachable" (network error)
|
|
106
|
+
const dnsErr = err;
|
|
107
|
+
if (dnsErr.code === 'ENOTFOUND' || dnsErr.code === 'ENOENT') {
|
|
108
|
+
throw new AzethError('Hostname not found — verify the URL is correct', 'INVALID_INPUT', { hostname: url.hostname });
|
|
109
|
+
}
|
|
110
|
+
throw new AzethError('DNS resolution failed — cannot verify URL safety', 'NETWORK_ERROR', { hostname: url.hostname, cause: 'dns' });
|
|
111
|
+
}
|
|
112
|
+
// C-1: Also check IPv6 (AAAA) records for IPv6-mapped private addresses.
|
|
113
|
+
// Unlike A records, AAAA failure is acceptable (host may be IPv4-only),
|
|
114
|
+
// but if AAAA records EXIST they must all be public.
|
|
115
|
+
try {
|
|
116
|
+
const ipv6Addresses = await dns.resolve6(hostname);
|
|
117
|
+
for (const addr of ipv6Addresses) {
|
|
118
|
+
if (isPrivateIPv6(addr)) {
|
|
119
|
+
throw new AzethError('URL resolves to a private or reserved IPv6 address. Only public HTTPS URLs are allowed.', 'INVALID_INPUT');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err instanceof AzethError)
|
|
125
|
+
throw err;
|
|
126
|
+
// AAAA resolution failure (ENODATA/ENOTFOUND) is acceptable — IPv4-only host.
|
|
127
|
+
// However, if resolution succeeded but returned an unexpected error, reject.
|
|
128
|
+
const dnsErr = err;
|
|
129
|
+
if (dnsErr.code && !['ENODATA', 'ENOTFOUND', 'ENOENT'].includes(dnsErr.code)) {
|
|
130
|
+
throw new AzethError('IPv6 DNS resolution failed unexpectedly — cannot verify URL safety', 'INVALID_INPUT', { hostname: url.hostname, dnsErrorCode: dnsErr.code });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { url: urlStr, pinnedIPv4 };
|
|
134
|
+
}
|
|
135
|
+
/** Register payment-related MCP tools: azeth_pay, azeth_smart_pay, azeth_create_payment_agreement */
|
|
136
|
+
export function registerPaymentTools(server) {
|
|
137
|
+
// ──────────────────────────────────────────────
|
|
138
|
+
// azeth_pay
|
|
139
|
+
// ──────────────────────────────────────────────
|
|
140
|
+
server.registerTool('azeth_pay', {
|
|
141
|
+
description: [
|
|
142
|
+
'Pay for an x402-gated HTTP service. Makes the request, handles 402 payment automatically, and returns the response.',
|
|
143
|
+
'',
|
|
144
|
+
'Use this when: You need to access a paid API or service that uses the x402 payment protocol (HTTP 402).',
|
|
145
|
+
'The tool automatically detects if you have an active payment agreement (subscription) with the service.',
|
|
146
|
+
'If an agreement exists, access is granted without additional payment. Otherwise, a fresh USDC payment is signed.',
|
|
147
|
+
'',
|
|
148
|
+
'Returns: Whether payment was made, the payment method used (x402/session/none), the HTTP status, and the response body.',
|
|
149
|
+
'',
|
|
150
|
+
'Note: Requires USDC balance to pay (unless an agreement grants access). Set maxAmount to cap spending.',
|
|
151
|
+
'Only HTTPS URLs to public endpoints are accepted. The payer account is determined by the AZETH_PRIVATE_KEY environment variable.',
|
|
152
|
+
'',
|
|
153
|
+
'Example: { "url": "https://api.example.com/data" } or { "url": "https://api.example.com/data", "maxAmount": "1.00" }',
|
|
154
|
+
].join('\n'),
|
|
155
|
+
inputSchema: z.object({
|
|
156
|
+
chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
|
|
157
|
+
url: z.string().url().max(2048).describe('The HTTPS URL of the x402-gated service to access. Must be a public endpoint.'),
|
|
158
|
+
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).optional().describe('HTTP method. Defaults to "GET".'),
|
|
159
|
+
body: z.string().max(100_000).optional().describe('Request body for POST/PUT/PATCH requests (JSON string, max 100KB).'),
|
|
160
|
+
maxAmount: z.string().max(32).optional().describe('Maximum USDC amount willing to pay (e.g., "5.00"). Rejects if service costs more.'),
|
|
161
|
+
}),
|
|
162
|
+
}, async (args) => {
|
|
163
|
+
let validated;
|
|
164
|
+
try {
|
|
165
|
+
validated = await validateExternalUrl(args.url);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return handleError(err);
|
|
169
|
+
}
|
|
170
|
+
let client;
|
|
171
|
+
try {
|
|
172
|
+
client = await createClient(args.chain);
|
|
173
|
+
let maxAmount;
|
|
174
|
+
if (args.maxAmount) {
|
|
175
|
+
try {
|
|
176
|
+
maxAmount = parseUnits(args.maxAmount, 6);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return error('INVALID_INPUT', 'Invalid maxAmount format — must be a valid decimal number (e.g., "10.50")');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// M-16 fix (Audit #8): Pass the validated URL (post-SSRF check) to fetch402
|
|
183
|
+
// instead of the original args.url. The validated.url has already been
|
|
184
|
+
// checked for SSRF and has the same value, but using it ensures the URL
|
|
185
|
+
// that was validated is the URL that is fetched.
|
|
186
|
+
const result = await client.fetch402(validated.url, {
|
|
187
|
+
method: args.method,
|
|
188
|
+
body: args.body,
|
|
189
|
+
maxAmount,
|
|
190
|
+
});
|
|
191
|
+
// F-5/H-1: Stream response body with size limit. Uses Uint8Array chunks
|
|
192
|
+
// to avoid O(n²) string concatenation on large responses.
|
|
193
|
+
const chunks = [];
|
|
194
|
+
let totalBytes = 0;
|
|
195
|
+
const reader = result.response.body?.getReader();
|
|
196
|
+
if (reader) {
|
|
197
|
+
try {
|
|
198
|
+
while (totalBytes < MAX_RESPONSE_SIZE) {
|
|
199
|
+
const { done, value } = await reader.read();
|
|
200
|
+
if (done)
|
|
201
|
+
break;
|
|
202
|
+
chunks.push(value);
|
|
203
|
+
totalBytes += value.byteLength;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
reader.cancel().catch(() => { }); // release the stream
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Concatenate chunks once and decode
|
|
211
|
+
const merged = new Uint8Array(Math.min(totalBytes, MAX_RESPONSE_SIZE));
|
|
212
|
+
let offset = 0;
|
|
213
|
+
for (const chunk of chunks) {
|
|
214
|
+
const remaining = merged.byteLength - offset;
|
|
215
|
+
if (remaining <= 0)
|
|
216
|
+
break;
|
|
217
|
+
const slice = chunk.byteLength <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
218
|
+
merged.set(slice, offset);
|
|
219
|
+
offset += slice.byteLength;
|
|
220
|
+
}
|
|
221
|
+
const responseBody = new TextDecoder().decode(merged);
|
|
222
|
+
// For non-JSON responses (e.g., HTML pages), strip tags and truncate aggressively
|
|
223
|
+
// to avoid flooding AI context with large HTML payloads.
|
|
224
|
+
let truncatedBody;
|
|
225
|
+
const trimmed = responseBody.trimStart();
|
|
226
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
227
|
+
// JSON — keep at full limit
|
|
228
|
+
truncatedBody = safeTruncate(responseBody, MAX_RESPONSE_SIZE);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Non-JSON (likely HTML) — strip tags, collapse whitespace, limit to 2KB
|
|
232
|
+
const stripped = responseBody.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
233
|
+
truncatedBody = safeTruncate(stripped, 2_000);
|
|
234
|
+
}
|
|
235
|
+
return success({
|
|
236
|
+
paid: result.paymentMade,
|
|
237
|
+
amount: result.amount?.toString(),
|
|
238
|
+
paymentMethod: result.paymentMethod,
|
|
239
|
+
statusCode: result.response.status,
|
|
240
|
+
body: truncatedBody,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (err instanceof Error && /AA24/.test(err.message)) {
|
|
245
|
+
return guardianRequiredError('Payment amount exceeds your standard spending limit.', { operation: 'payment' });
|
|
246
|
+
}
|
|
247
|
+
// Format raw USDC amounts in budget/guardian errors for readability
|
|
248
|
+
if (err instanceof AzethError && err.details) {
|
|
249
|
+
const formatted = { ...err.details };
|
|
250
|
+
let changed = false;
|
|
251
|
+
for (const [key, val] of Object.entries(formatted)) {
|
|
252
|
+
if (/amount/i.test(key) && typeof val === 'bigint') {
|
|
253
|
+
formatted[key] = formatTokenAmount(val, 6, 2) + ' USDC';
|
|
254
|
+
changed = true;
|
|
255
|
+
}
|
|
256
|
+
else if (/amount/i.test(key) && typeof val === 'string' && /^\d{7,}$/.test(val)) {
|
|
257
|
+
try {
|
|
258
|
+
formatted[key] = formatTokenAmount(BigInt(val), 6, 2) + ' USDC';
|
|
259
|
+
changed = true;
|
|
260
|
+
}
|
|
261
|
+
catch { /* keep original */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (changed) {
|
|
265
|
+
// Rewrite the message for BUDGET_EXCEEDED errors with formatted amounts
|
|
266
|
+
if (err.code === 'BUDGET_EXCEEDED' && formatted.required && formatted.max) {
|
|
267
|
+
const newMsg = `Payment of ${formatted.required} exceeds maximum of ${formatted.max}`;
|
|
268
|
+
return handleError(new AzethError(newMsg, err.code, formatted));
|
|
269
|
+
}
|
|
270
|
+
return handleError(new AzethError(err.message, err.code, formatted));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return handleError(err);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
try {
|
|
277
|
+
await client?.destroy();
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
// ──────────────────────────────────────────────
|
|
285
|
+
// azeth_smart_pay
|
|
286
|
+
// ──────────────────────────────────────────────
|
|
287
|
+
server.registerTool('azeth_smart_pay', {
|
|
288
|
+
description: [
|
|
289
|
+
'Discover the best service for a capability and pay for it automatically.',
|
|
290
|
+
'',
|
|
291
|
+
'Use this when: You need a service by CAPABILITY (e.g., "price-feed", "market-data", "translation")',
|
|
292
|
+
'and want Azeth to pick the highest-reputation provider, handle payment, and fall back to alternatives if needed.',
|
|
293
|
+
'',
|
|
294
|
+
'How it differs from azeth_pay:',
|
|
295
|
+
'- azeth_smart_pay: "I need price-feed data" → Azeth discovers the best service, pays it, returns the data.',
|
|
296
|
+
'- azeth_pay: "I need data from https://specific-service.com/api" → You know which service, Azeth pays it.',
|
|
297
|
+
'',
|
|
298
|
+
'Flow: Discovers services ranked by reputation → tries the best one → if it fails, tries the next.',
|
|
299
|
+
'Set autoFeedback: true to automatically submit a reputation opinion based on service quality after payment.',
|
|
300
|
+
'Note: autoFeedback defaults to false in MCP context (ephemeral client). Enable it if the MCP server has a bundler configured.',
|
|
301
|
+
'',
|
|
302
|
+
'Returns: The response data, which service was used, how many attempts were needed, and payment details.',
|
|
303
|
+
'',
|
|
304
|
+
'Example: { "capability": "price-feed" } or { "capability": "translation", "maxAmount": "0.50", "method": "POST", "body": "{\"text\": \"hello\"}" }',
|
|
305
|
+
].join('\n'),
|
|
306
|
+
inputSchema: z.object({
|
|
307
|
+
chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
|
|
308
|
+
capability: z.string().min(1).max(256).describe('Service capability to discover (e.g., "price-feed", "market-data", "translation", "compute").'),
|
|
309
|
+
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).optional().describe('HTTP method. Defaults to "GET".'),
|
|
310
|
+
body: z.string().max(100_000).optional().describe('Request body for POST/PUT/PATCH requests (JSON string, max 100KB).'),
|
|
311
|
+
maxAmount: z.string().max(32).optional().describe('Maximum USDC amount willing to pay per service (e.g., "1.00"). Rejects if service costs more.'),
|
|
312
|
+
minReputation: z.coerce.number().min(0).max(100).optional().describe('Minimum reputation score (0-100) to consider. Services below this are excluded.'),
|
|
313
|
+
autoFeedback: z.boolean().optional().describe('Automatically submit a reputation opinion after payment based on service quality. Defaults to false.'),
|
|
314
|
+
}),
|
|
315
|
+
}, async (args) => {
|
|
316
|
+
let client;
|
|
317
|
+
try {
|
|
318
|
+
client = await createClient(args.chain);
|
|
319
|
+
let maxAmount;
|
|
320
|
+
if (args.maxAmount) {
|
|
321
|
+
try {
|
|
322
|
+
maxAmount = parseUnits(args.maxAmount, 6);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return error('INVALID_INPUT', 'Invalid maxAmount format — must be a valid decimal number (e.g., "1.00")');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Disable autoFeedback in MCP context: the client is ephemeral (destroyed
|
|
329
|
+
// after this call) and may not have a bundler URL for UserOp submission.
|
|
330
|
+
// Feedback should be submitted by long-lived AzethKit instances instead.
|
|
331
|
+
const result = await client.smartFetch402(args.capability, {
|
|
332
|
+
method: args.method,
|
|
333
|
+
body: args.body,
|
|
334
|
+
maxAmount,
|
|
335
|
+
minReputation: args.minReputation,
|
|
336
|
+
autoFeedback: args.autoFeedback ?? false,
|
|
337
|
+
});
|
|
338
|
+
// Stream response body with size limit (same pattern as azeth_pay)
|
|
339
|
+
const chunks = [];
|
|
340
|
+
let totalBytes = 0;
|
|
341
|
+
const reader = result.response.body?.getReader();
|
|
342
|
+
if (reader) {
|
|
343
|
+
try {
|
|
344
|
+
while (totalBytes < MAX_RESPONSE_SIZE) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (done)
|
|
347
|
+
break;
|
|
348
|
+
chunks.push(value);
|
|
349
|
+
totalBytes += value.byteLength;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
reader.cancel().catch(() => { });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const merged = new Uint8Array(Math.min(totalBytes, MAX_RESPONSE_SIZE));
|
|
357
|
+
let offset = 0;
|
|
358
|
+
for (const chunk of chunks) {
|
|
359
|
+
const remaining = merged.byteLength - offset;
|
|
360
|
+
if (remaining <= 0)
|
|
361
|
+
break;
|
|
362
|
+
const slice = chunk.byteLength <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
363
|
+
merged.set(slice, offset);
|
|
364
|
+
offset += slice.byteLength;
|
|
365
|
+
}
|
|
366
|
+
const responseBody = new TextDecoder().decode(merged);
|
|
367
|
+
// For non-JSON responses (e.g., HTML pages), strip tags and truncate aggressively
|
|
368
|
+
let truncatedBody;
|
|
369
|
+
const trimmedSmart = responseBody.trimStart();
|
|
370
|
+
if (trimmedSmart.startsWith('{') || trimmedSmart.startsWith('[')) {
|
|
371
|
+
truncatedBody = safeTruncate(responseBody, MAX_RESPONSE_SIZE);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const stripped = responseBody.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
375
|
+
truncatedBody = safeTruncate(stripped, 2_000);
|
|
376
|
+
}
|
|
377
|
+
return success({
|
|
378
|
+
paid: result.paymentMade,
|
|
379
|
+
amount: result.amount?.toString(),
|
|
380
|
+
paymentMethod: result.paymentMethod,
|
|
381
|
+
statusCode: result.response.status,
|
|
382
|
+
body: truncatedBody,
|
|
383
|
+
service: {
|
|
384
|
+
name: result.service.name,
|
|
385
|
+
endpoint: result.service.endpoint,
|
|
386
|
+
tokenId: result.service.tokenId.toString(),
|
|
387
|
+
reputation: result.service.reputation,
|
|
388
|
+
},
|
|
389
|
+
attemptsCount: result.attemptsCount,
|
|
390
|
+
autoFeedback: args.autoFeedback ?? false,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
if (err instanceof Error && /AA24/.test(err.message)) {
|
|
395
|
+
return guardianRequiredError('Payment amount exceeds your standard spending limit.', { operation: 'smart_payment' });
|
|
396
|
+
}
|
|
397
|
+
// Format raw USDC amounts in guardian/payment errors for readability
|
|
398
|
+
if (err instanceof AzethError && err.details) {
|
|
399
|
+
const formatted = { ...err.details };
|
|
400
|
+
let changed = false;
|
|
401
|
+
for (const [key, val] of Object.entries(formatted)) {
|
|
402
|
+
if (/amount/i.test(key) && typeof val === 'bigint') {
|
|
403
|
+
formatted[key] = formatTokenAmount(val, 6, 2) + ' USDC';
|
|
404
|
+
changed = true;
|
|
405
|
+
}
|
|
406
|
+
else if (/amount/i.test(key) && typeof val === 'string' && /^\d{7,}$/.test(val)) {
|
|
407
|
+
try {
|
|
408
|
+
formatted[key] = formatTokenAmount(BigInt(val), 6, 2) + ' USDC';
|
|
409
|
+
changed = true;
|
|
410
|
+
}
|
|
411
|
+
catch { /* keep original */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (changed) {
|
|
415
|
+
return handleError(new AzethError(err.message, err.code, formatted));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return handleError(err);
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
try {
|
|
422
|
+
await client?.destroy();
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// ──────────────────────────────────────────────
|
|
430
|
+
// azeth_create_payment_agreement
|
|
431
|
+
// ──────────────────────────────────────────────
|
|
432
|
+
server.registerTool('azeth_create_payment_agreement', {
|
|
433
|
+
description: [
|
|
434
|
+
'Set up a recurring payment agreement to another participant. Payments execute on a fixed interval.',
|
|
435
|
+
'',
|
|
436
|
+
'Use this when: You need automated recurring payments (subscriptions, data feeds, scheduled transfers) between participants.',
|
|
437
|
+
'',
|
|
438
|
+
'Returns: The agreement ID and creation transaction hash.',
|
|
439
|
+
'',
|
|
440
|
+
'Note: This creates an on-chain agreement via the PaymentAgreementModule. The payee or anyone can call execute',
|
|
441
|
+
'once each interval has elapsed. Requires sufficient token balance for each execution.',
|
|
442
|
+
'The payer account is determined by the AZETH_PRIVATE_KEY environment variable.',
|
|
443
|
+
'',
|
|
444
|
+
'Example: { "payee": "Alice", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "amount": "1.00", "intervalSeconds": 86400 }',
|
|
445
|
+
].join('\n'),
|
|
446
|
+
inputSchema: z.object({
|
|
447
|
+
chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
|
|
448
|
+
payee: z.string().describe('Recipient: Ethereum address, participant name, "me", or "#N" (account index).'),
|
|
449
|
+
token: z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a valid Ethereum address (0x + 40 hex chars)').describe('Payment token address. Use an ERC-20 contract address (e.g., USDC) or 0x0000000000000000000000000000000000000000 for native ETH.'),
|
|
450
|
+
amount: z.string().describe('Payment amount per interval in human-readable units (e.g., "10.00" for 10 USDC).'),
|
|
451
|
+
intervalSeconds: z.coerce.number().int().describe('Time between payments in seconds (minimum 60). E.g., 86400 for daily, 604800 for weekly.'),
|
|
452
|
+
maxExecutions: z.coerce.number().int().optional().describe('Maximum number of payments. 0 or omit for unlimited.'),
|
|
453
|
+
decimals: z.coerce.number().int().min(0).max(18).optional().describe('Token decimals. Defaults to 6 (USDC). Use 18 for WETH or native ETH.'),
|
|
454
|
+
}),
|
|
455
|
+
}, async (args) => {
|
|
456
|
+
if (!validateAddress(args.token)) {
|
|
457
|
+
return error('INVALID_INPUT', `Invalid token address: "${args.token}".`, 'Must be 0x-prefixed followed by 40 hex characters.');
|
|
458
|
+
}
|
|
459
|
+
// Business-rule validation (moved from Zod to handler for consistent error format)
|
|
460
|
+
if (args.intervalSeconds < 60) {
|
|
461
|
+
return error('INVALID_INPUT', 'intervalSeconds must be at least 60 (1 minute).', 'Common values: 86400 (daily), 604800 (weekly), 2592000 (monthly).');
|
|
462
|
+
}
|
|
463
|
+
if (args.maxExecutions !== undefined && args.maxExecutions < 0) {
|
|
464
|
+
return error('INVALID_INPUT', 'maxExecutions must be 0 or greater.', '0 means unlimited. Omit for unlimited.');
|
|
465
|
+
}
|
|
466
|
+
// Native ETH: address(0) is valid — PaymentAgreementModule supports both ETH and ERC-20.
|
|
467
|
+
// For ETH, default to 18 decimals if not explicitly provided.
|
|
468
|
+
const isNativeETH = args.token === '0x0000000000000000000000000000000000000000';
|
|
469
|
+
let client;
|
|
470
|
+
try {
|
|
471
|
+
client = await createClient(args.chain);
|
|
472
|
+
// Resolve payee: address, name, "me", "#N"
|
|
473
|
+
let payeeResolved;
|
|
474
|
+
try {
|
|
475
|
+
payeeResolved = await resolveAddress(args.payee, client);
|
|
476
|
+
}
|
|
477
|
+
catch (resolveErr) {
|
|
478
|
+
return handleError(resolveErr);
|
|
479
|
+
}
|
|
480
|
+
const decimals = args.decimals ?? (isNativeETH ? 18 : 6);
|
|
481
|
+
let amount;
|
|
482
|
+
try {
|
|
483
|
+
amount = parseUnits(args.amount, decimals);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return error('INVALID_INPUT', 'Invalid amount format — must be a valid decimal number (e.g., "10.00")');
|
|
487
|
+
}
|
|
488
|
+
// Pre-flight: verify the token is whitelisted by the guardian module
|
|
489
|
+
try {
|
|
490
|
+
const chain = resolveChain(args.chain);
|
|
491
|
+
const guardianAddr = AZETH_CONTRACTS[chain].guardianModule;
|
|
492
|
+
const smartAccount = await client.resolveSmartAccount();
|
|
493
|
+
const { GuardianModuleAbi } = await import('@azeth/common/abis');
|
|
494
|
+
const isWhitelisted = await client.publicClient.readContract({
|
|
495
|
+
address: guardianAddr,
|
|
496
|
+
abi: GuardianModuleAbi,
|
|
497
|
+
functionName: 'isTokenWhitelisted',
|
|
498
|
+
args: [smartAccount, args.token],
|
|
499
|
+
});
|
|
500
|
+
if (!isWhitelisted) {
|
|
501
|
+
const tokenLabel = isNativeETH ? 'Native ETH (address(0))' : `Token ${args.token}`;
|
|
502
|
+
return error('INVALID_INPUT', `${tokenLabel} is not whitelisted by your guardian. Agreement would be unexecutable.`, 'Add it to the token whitelist via the guardian before creating an agreement.');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Non-fatal: if the whitelist check fails (RPC error, module not deployed),
|
|
507
|
+
// proceed and let the contract handle validation at execution time.
|
|
508
|
+
}
|
|
509
|
+
const result = await client.createPaymentAgreement({
|
|
510
|
+
payee: payeeResolved.address,
|
|
511
|
+
token: args.token,
|
|
512
|
+
amount,
|
|
513
|
+
interval: args.intervalSeconds,
|
|
514
|
+
maxExecutions: args.maxExecutions,
|
|
515
|
+
});
|
|
516
|
+
// Resolve token symbol for display
|
|
517
|
+
const chain = resolveChain(args.chain);
|
|
518
|
+
const tokens = TOKENS[chain];
|
|
519
|
+
const tokenLower = args.token.toLowerCase();
|
|
520
|
+
let tokenSymbol = 'TOKEN';
|
|
521
|
+
if (isNativeETH) {
|
|
522
|
+
tokenSymbol = 'ETH';
|
|
523
|
+
}
|
|
524
|
+
else if (tokenLower === tokens.USDC.toLowerCase()) {
|
|
525
|
+
tokenSymbol = 'USDC';
|
|
526
|
+
}
|
|
527
|
+
else if (tokenLower === tokens.WETH.toLowerCase()) {
|
|
528
|
+
tokenSymbol = 'WETH';
|
|
529
|
+
}
|
|
530
|
+
// Format interval for human readability
|
|
531
|
+
const secs = args.intervalSeconds;
|
|
532
|
+
let intervalHuman;
|
|
533
|
+
if (secs >= 86400 && secs % 86400 === 0) {
|
|
534
|
+
const days = secs / 86400;
|
|
535
|
+
intervalHuman = days === 1 ? 'every day' : `every ${days} days`;
|
|
536
|
+
}
|
|
537
|
+
else if (secs >= 3600 && secs % 3600 === 0) {
|
|
538
|
+
const hours = secs / 3600;
|
|
539
|
+
intervalHuman = hours === 1 ? 'every hour' : `every ${hours} hours`;
|
|
540
|
+
}
|
|
541
|
+
else if (secs >= 60 && secs % 60 === 0) {
|
|
542
|
+
const mins = secs / 60;
|
|
543
|
+
intervalHuman = mins === 1 ? 'every minute' : `every ${mins} minutes`;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
intervalHuman = `every ${secs} seconds`;
|
|
547
|
+
}
|
|
548
|
+
return success({
|
|
549
|
+
agreementId: result.agreementId.toString(),
|
|
550
|
+
txHash: result.txHash,
|
|
551
|
+
agreement: {
|
|
552
|
+
payee: payeeResolved.address,
|
|
553
|
+
...(payeeResolved.resolvedFrom ? { payeeName: payeeResolved.resolvedFrom } : {}),
|
|
554
|
+
token: args.token,
|
|
555
|
+
tokenSymbol,
|
|
556
|
+
amount: args.amount,
|
|
557
|
+
amountFormatted: `${args.amount} ${tokenSymbol}`,
|
|
558
|
+
intervalSeconds: args.intervalSeconds,
|
|
559
|
+
intervalHuman,
|
|
560
|
+
maxExecutions: args.maxExecutions ?? 0,
|
|
561
|
+
},
|
|
562
|
+
}, { txHash: result.txHash });
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
if (err instanceof Error && /AA24/.test(err.message)) {
|
|
566
|
+
return guardianRequiredError('Agreement creation exceeds your standard spending limit.', { operation: 'create_agreement' });
|
|
567
|
+
}
|
|
568
|
+
return handleError(err);
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
try {
|
|
572
|
+
await client?.destroy();
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
// ──────────────────────────────────────────────
|
|
580
|
+
// azeth_subscribe_service
|
|
581
|
+
// ──────────────────────────────────────────────
|
|
582
|
+
server.registerTool('azeth_subscribe_service', {
|
|
583
|
+
description: [
|
|
584
|
+
'Subscribe to an x402-gated service by creating a payment agreement.',
|
|
585
|
+
'',
|
|
586
|
+
'Use this when: You want to set up a subscription instead of paying per-request.',
|
|
587
|
+
'The tool fetches the service URL, parses the 402 payment-agreement extension terms,',
|
|
588
|
+
'and creates an on-chain payment agreement matching those terms.',
|
|
589
|
+
'',
|
|
590
|
+
'Returns: The agreement ID, transaction hash, and subscription details.',
|
|
591
|
+
'',
|
|
592
|
+
'Note: The service must advertise payment-agreement terms in its 402 response.',
|
|
593
|
+
'After subscribing, subsequent calls to azeth_pay will automatically detect the agreement.',
|
|
594
|
+
'No need to pass an agreementId — the server recognizes your wallet via SIWx authentication.',
|
|
595
|
+
].join('\n'),
|
|
596
|
+
inputSchema: z.object({
|
|
597
|
+
chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
|
|
598
|
+
url: z.string().url().max(2048).describe('The HTTPS URL of the x402-gated service to subscribe to.'),
|
|
599
|
+
intervalSeconds: z.coerce.number().int().optional().describe('Override the suggested interval (seconds, minimum 60). Defaults to the service suggestion.'),
|
|
600
|
+
maxExecutions: z.coerce.number().int().optional().describe('Maximum number of payments. 0 or omit for unlimited.'),
|
|
601
|
+
totalCap: z.string().max(32).optional().describe('Maximum total payout in human-readable token units (e.g., "100.00").'),
|
|
602
|
+
}),
|
|
603
|
+
}, async (args) => {
|
|
604
|
+
let validated;
|
|
605
|
+
try {
|
|
606
|
+
validated = await validateExternalUrl(args.url);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
return handleError(err);
|
|
610
|
+
}
|
|
611
|
+
// Business-rule validation (moved from Zod to handler for consistent error format)
|
|
612
|
+
if (args.intervalSeconds !== undefined && args.intervalSeconds < 60) {
|
|
613
|
+
return error('INVALID_INPUT', 'intervalSeconds must be at least 60 (1 minute).', 'Common values: 86400 (daily), 604800 (weekly), 2592000 (monthly).');
|
|
614
|
+
}
|
|
615
|
+
if (args.maxExecutions !== undefined && args.maxExecutions < 0) {
|
|
616
|
+
return error('INVALID_INPUT', 'maxExecutions must be 0 or greater.', '0 means unlimited. Omit for unlimited.');
|
|
617
|
+
}
|
|
618
|
+
// Contract requires at least one cap condition to prevent unlimited payments.
|
|
619
|
+
// endTime is set automatically (30 days from now) so we only check user-provided caps.
|
|
620
|
+
if (!args.maxExecutions && !args.totalCap) {
|
|
621
|
+
return error('INVALID_INPUT', 'At least one limit is required: maxExecutions or totalCap.', 'The contract requires a cap condition to prevent unlimited payments. E.g., maxExecutions: 30 for monthly billing.');
|
|
622
|
+
}
|
|
623
|
+
let client;
|
|
624
|
+
try {
|
|
625
|
+
client = await createClient(args.chain);
|
|
626
|
+
// Fetch the URL to get 402 response with agreement terms
|
|
627
|
+
const response = await fetch(validated.url, {
|
|
628
|
+
method: 'GET',
|
|
629
|
+
signal: AbortSignal.timeout(15_000),
|
|
630
|
+
});
|
|
631
|
+
if (response.status !== 402) {
|
|
632
|
+
return error('INVALID_INPUT', `Service at ${args.url} did not return 402 — it may not require payment.`);
|
|
633
|
+
}
|
|
634
|
+
// Parse PAYMENT-REQUIRED header (v2) or X-Payment-Required (v1)
|
|
635
|
+
const reqHeader = response.headers.get('PAYMENT-REQUIRED') ?? response.headers.get('X-Payment-Required');
|
|
636
|
+
if (!reqHeader) {
|
|
637
|
+
return error('INVALID_INPUT', 'Service returned 402 but no payment requirement header.');
|
|
638
|
+
}
|
|
639
|
+
let requirement;
|
|
640
|
+
try {
|
|
641
|
+
// x402v2 base64-encodes the PAYMENT-REQUIRED header; v1 sends raw JSON.
|
|
642
|
+
let jsonStr;
|
|
643
|
+
try {
|
|
644
|
+
jsonStr = atob(reqHeader);
|
|
645
|
+
if (!jsonStr.startsWith('{') && !jsonStr.startsWith('['))
|
|
646
|
+
throw new Error('not base64 JSON');
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
jsonStr = reqHeader;
|
|
650
|
+
}
|
|
651
|
+
requirement = JSON.parse(jsonStr);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return error('INVALID_INPUT', 'Failed to parse payment requirement header.');
|
|
655
|
+
}
|
|
656
|
+
// Look for payment-agreement extension in the requirement
|
|
657
|
+
const extensions = requirement.extensions;
|
|
658
|
+
const agreementExt = extensions?.['payment-agreement'];
|
|
659
|
+
if (!agreementExt?.acceptsAgreements) {
|
|
660
|
+
return error('INVALID_INPUT', 'Service does not advertise payment-agreement terms. Use azeth_pay for one-time payment.');
|
|
661
|
+
}
|
|
662
|
+
const terms = agreementExt.terms;
|
|
663
|
+
if (!terms?.payee || !terms?.token) {
|
|
664
|
+
return error('INVALID_INPUT', 'Service agreement terms are incomplete (missing payee or token).');
|
|
665
|
+
}
|
|
666
|
+
// Audit #13 H-4 fix: Validate payee and token are valid Ethereum addresses
|
|
667
|
+
if (!isAddress(terms.payee)) {
|
|
668
|
+
return error('INVALID_INPUT', 'Invalid payee address from service.');
|
|
669
|
+
}
|
|
670
|
+
if (!isAddress(terms.token)) {
|
|
671
|
+
return error('INVALID_INPUT', 'Invalid token address from service.');
|
|
672
|
+
}
|
|
673
|
+
// Parse amount
|
|
674
|
+
const amount = BigInt(terms.minAmountPerInterval);
|
|
675
|
+
const interval = args.intervalSeconds ?? terms.suggestedInterval;
|
|
676
|
+
// Calculate totalCap if provided
|
|
677
|
+
let totalCap;
|
|
678
|
+
if (args.totalCap) {
|
|
679
|
+
try {
|
|
680
|
+
totalCap = parseUnits(args.totalCap, 6);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return error('INVALID_INPUT', 'Invalid totalCap format — must be a valid decimal number.');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const result = await client.createPaymentAgreement({
|
|
687
|
+
payee: terms.payee,
|
|
688
|
+
token: terms.token,
|
|
689
|
+
amount,
|
|
690
|
+
interval,
|
|
691
|
+
maxExecutions: args.maxExecutions,
|
|
692
|
+
totalCap,
|
|
693
|
+
});
|
|
694
|
+
return success({
|
|
695
|
+
agreementId: result.agreementId.toString(),
|
|
696
|
+
txHash: result.txHash,
|
|
697
|
+
subscription: {
|
|
698
|
+
payee: terms.payee,
|
|
699
|
+
token: terms.token,
|
|
700
|
+
amountPerInterval: terms.minAmountPerInterval,
|
|
701
|
+
intervalSeconds: interval,
|
|
702
|
+
maxExecutions: args.maxExecutions ?? 0,
|
|
703
|
+
serviceUrl: args.url,
|
|
704
|
+
},
|
|
705
|
+
}, { txHash: result.txHash });
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (err instanceof Error && /AA24/.test(err.message)) {
|
|
709
|
+
return guardianRequiredError('Subscription creation exceeds your standard spending limit.', { operation: 'subscribe_service' });
|
|
710
|
+
}
|
|
711
|
+
return handleError(err);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
try {
|
|
715
|
+
await client?.destroy();
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
//# sourceMappingURL=payments.js.map
|