Skip to main content
Glama

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

TableJSON Schema
NameRequiredDescriptionDefault
chainNoTarget chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").
urlYesThe HTTPS URL of the x402-gated service to subscribe to.
intervalSecondsNoOverride the suggested interval (seconds, minimum 60). Defaults to the service suggestion.
maxExecutionsNoMaximum number of payments. 0 or omit for unlimited.
totalCapNoMaximum total payout in human-readable token units (e.g., "100.00").

Implementation Reference

  • 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`); }
        }
      },
    );
  • 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").'),
    }),
  • 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").'),
      }),
    },

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/azeth-protocol/mcp-azeth'

If you have feedback or need assistance with the MCP directory API, please join our Discord server