azeth_subscribe_service
Set up recurring payments for x402-gated services by creating on-chain payment agreements. Automatically detects subscriptions for future service calls without manual agreement management.
Instructions
Subscribe to an x402-gated service by creating a payment agreement.
Use this when: You want to set up a subscription instead of paying per-request. The tool fetches the service URL, parses the 402 payment-agreement extension terms, and creates an on-chain payment agreement matching those terms.
Returns: The agreement ID, transaction hash, and subscription details.
Note: The service must advertise payment-agreement terms in its 402 response. After subscribing, subsequent calls to azeth_pay will automatically detect the agreement. No need to pass an agreementId — the server recognizes your wallet via SIWx authentication.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| chain | No | 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"). | |
| url | Yes | The HTTPS URL of the x402-gated service to subscribe to. | |
| intervalSeconds | No | Override the suggested interval (seconds, minimum 60). Defaults to the service suggestion. | |
| maxExecutions | No | Maximum number of payments. 0 or omit for unlimited. | |
| totalCap | No | Maximum total payout in human-readable token units (e.g., "100.00"). |
Implementation Reference
- src/tools/payments.ts:707-843 (handler)The handler logic for the azeth_subscribe_service tool.
async (args) => { let validated: ValidatedUrl; try { validated = await validateExternalUrl(args.url); } catch (err) { return handleError(err); } // Business-rule validation (moved from Zod to handler for consistent error format) if (args.intervalSeconds !== undefined && args.intervalSeconds < 60) { return error('INVALID_INPUT', 'intervalSeconds must be at least 60 (1 minute).', 'Common values: 86400 (daily), 604800 (weekly), 2592000 (monthly).'); } if (args.maxExecutions !== undefined && args.maxExecutions < 0) { return error('INVALID_INPUT', 'maxExecutions must be 0 or greater.', '0 means unlimited. Omit for unlimited.'); } // Contract requires at least one cap condition to prevent unlimited payments. // endTime is set automatically (30 days from now) so we only check user-provided caps. if (!args.maxExecutions && !args.totalCap) { 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.'); } let client; try { client = await createClient(args.chain); // Fetch the URL to get 402 response with agreement terms const response = await fetch(validated.url, { method: 'GET', signal: AbortSignal.timeout(15_000), }); if (response.status !== 402) { return error('INVALID_INPUT', `Service at ${args.url} did not return 402 — it may not require payment.`); } // Parse PAYMENT-REQUIRED header (v2) or X-Payment-Required (v1) const reqHeader = response.headers.get('PAYMENT-REQUIRED') ?? response.headers.get('X-Payment-Required'); if (!reqHeader) { return error('INVALID_INPUT', 'Service returned 402 but no payment requirement header.'); } let requirement: Record<string, unknown>; try { // x402v2 base64-encodes the PAYMENT-REQUIRED header; v1 sends raw JSON. let jsonStr: string; try { jsonStr = atob(reqHeader); if (!jsonStr.startsWith('{') && !jsonStr.startsWith('[')) throw new Error('not base64 JSON'); } catch { jsonStr = reqHeader; } requirement = JSON.parse(jsonStr); } catch { return error('INVALID_INPUT', 'Failed to parse payment requirement header.'); } // Look for payment-agreement extension in the requirement const extensions = requirement.extensions as Record<string, Record<string, unknown>> | undefined; const agreementExt = extensions?.['payment-agreement']; if (!agreementExt?.acceptsAgreements) { return error('INVALID_INPUT', 'Service does not advertise payment-agreement terms. Use azeth_pay for one-time payment.'); } const terms = agreementExt.terms as { payee: string; token: string; moduleAddress: string; minAmountPerInterval: string; suggestedInterval: number; }; if (!terms?.payee || !terms?.token) { return error('INVALID_INPUT', 'Service agreement terms are incomplete (missing payee or token).'); } // Audit #13 H-4 fix: Validate payee and token are valid Ethereum addresses if (!isAddress(terms.payee)) { return error('INVALID_INPUT', 'Invalid payee address from service.'); } if (!isAddress(terms.token)) { return error('INVALID_INPUT', 'Invalid token address from service.'); } // Parse amount const amount = BigInt(terms.minAmountPerInterval); const interval = args.intervalSeconds ?? terms.suggestedInterval; // Calculate totalCap if provided let totalCap: bigint | undefined; if (args.totalCap) { try { totalCap = parseUnits(args.totalCap, 6); } catch { return error('INVALID_INPUT', 'Invalid totalCap format — must be a valid decimal number.'); } } const result = await client.createPaymentAgreement({ payee: terms.payee as `0x${string}`, token: terms.token as `0x${string}`, amount, interval, maxExecutions: args.maxExecutions, totalCap, }); return success( { agreementId: result.agreementId.toString(), txHash: result.txHash, subscription: { payee: terms.payee, token: terms.token, amountPerInterval: terms.minAmountPerInterval, intervalSeconds: interval, maxExecutions: args.maxExecutions ?? 0, serviceUrl: args.url, }, }, { txHash: result.txHash }, ); } catch (err) { if (err instanceof Error && /AA24/.test(err.message)) { return guardianRequiredError( 'Subscription creation exceeds your standard spending limit.', { operation: 'subscribe_service' }, ); } return handleError(err); } finally { try { await client?.destroy(); } catch (e) { process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`); } } }, ); - src/tools/payments.ts:699-705 (schema)The input schema definition for the azeth_subscribe_service tool.
inputSchema: z.object({ 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").'), url: z.string().url().max(2048).describe('The HTTPS URL of the x402-gated service to subscribe to.'), intervalSeconds: z.coerce.number().int().optional().describe('Override the suggested interval (seconds, minimum 60). Defaults to the service suggestion.'), maxExecutions: z.coerce.number().int().optional().describe('Maximum number of payments. 0 or omit for unlimited.'), totalCap: z.string().max(32).optional().describe('Maximum total payout in human-readable token units (e.g., "100.00").'), }), - src/tools/payments.ts:684-706 (registration)The registration of the azeth_subscribe_service tool.
'azeth_subscribe_service', { description: [ 'Subscribe to an x402-gated service by creating a payment agreement.', '', 'Use this when: You want to set up a subscription instead of paying per-request.', 'The tool fetches the service URL, parses the 402 payment-agreement extension terms,', 'and creates an on-chain payment agreement matching those terms.', '', 'Returns: The agreement ID, transaction hash, and subscription details.', '', 'Note: The service must advertise payment-agreement terms in its 402 response.', 'After subscribing, subsequent calls to azeth_pay will automatically detect the agreement.', 'No need to pass an agreementId — the server recognizes your wallet via SIWx authentication.', ].join('\n'), inputSchema: z.object({ 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").'), url: z.string().url().max(2048).describe('The HTTPS URL of the x402-gated service to subscribe to.'), intervalSeconds: z.coerce.number().int().optional().describe('Override the suggested interval (seconds, minimum 60). Defaults to the service suggestion.'), maxExecutions: z.coerce.number().int().optional().describe('Maximum number of payments. 0 or omit for unlimited.'), totalCap: z.string().max(32).optional().describe('Maximum total payout in human-readable token units (e.g., "100.00").'), }), },