ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-05-06Advanced

Antigravity × Stripe Custom MCP Server: Complete Implementation Guide — Autonomous AI-Driven Billing

Build a custom MCP server that wraps the Stripe API, letting Antigravity's AI agents autonomously handle subscriptions, Webhooks, and multi-tenant billing. A complete TypeScript implementation guide for production-grade SaaS billing.

mcp14stripe9billing3typescript26saas10integrations20

Every time I added a new pricing tier to a SaaS app, I rewrote Stripe billing logic from scratch. Code generation was already AI-driven, but subscription management still forced me to write every API call by hand.

The fix? Build a custom MCP (Model Context Protocol) server that wraps the Stripe API. Once it's in place, Antigravity's agent can treat Stripe operations as callable tools. A single instruction — "migrate this user to the Pro plan" — triggers the agent to handle Customer lookup, Subscription update, and Webhook registration automatically.

The Architecture: MCP × Stripe

An MCP server acts as a proxy layer between Antigravity's agent and a third-party API. For Stripe, the flow has three distinct layers.

The agent layer (Antigravity) receives natural-language instructions and decides which Stripe operation to call. The MCP server layer translates those instructions into type-safe Stripe SDK calls with validated inputs. The Stripe API layer executes the actual billing logic and returns structured results.

The core benefit is that agents don't need to understand Stripe's API surface. The MCP server handles argument validation and type coercion, so even if the agent generates a malformed request, the server catches it before it reaches Stripe.

The most important design decision is separating read-only tools from mutation tools. Antigravity agents can misinterpret instructions and trigger unintended side effects — like canceling a subscription when you only wanted to downgrade it. By splitting tools into read-only and mutation categories, and adding confirmation steps to mutations, you dramatically reduce the risk of unintended actions in production.

Setting Up the Development Environment

Install the required packages.

mkdir stripe-mcp-server && cd stripe-mcp-server
npm init -y
npm install @anthropic-ai/mcp-sdk stripe zod
npm install -D typescript ts-node @types/node

Create tsconfig.json.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Set your environment variables. In production, use Cloudflare Workers Secrets or AWS Secrets Manager — never write live keys to the filesystem.

# .env (local development only)
STRIPE_SECRET_KEY=sk_test_YOUR_TEST_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
MCP_AUTH_TOKEN=your_long_random_auth_token
PORT=3000

Core MCP Server Implementation

Create src/server.ts. The startup key validation, read-only tools, and mutation tools are all defined here.

import { MCPServer, Tool, ToolResult } from '@anthropic-ai/mcp-sdk';
import Stripe from 'stripe';
 
// Startup validation: prevent test keys from leaking into production
function validateStripeKey(): void {
  const key = process.env.STRIPE_SECRET_KEY;
  if (!key) throw new Error('STRIPE_SECRET_KEY is not set');
  const isProduction = process.env.NODE_ENV === 'production';
  if (isProduction && key.startsWith('sk_test_')) {
    throw new Error('Test key detected in production environment. Aborting deploy.');
  }
  if (!key.startsWith('sk_live_') && !key.startsWith('sk_test_')) {
    throw new Error('Invalid STRIPE_SECRET_KEY format (must start with sk_live_ or sk_test_)');
  }
}
 
validateStripeKey();
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});
 
// --- Read-only tools ---
const getCustomerTool: Tool = {
  name: 'get_stripe_customer',
  description: 'Retrieve customer information by email or customer ID (read-only).',
  inputSchema: {
    type: 'object',
    properties: {
      email: { type: 'string', description: "Customer's email address" },
      customerId: { type: 'string', description: 'Stripe customer ID (cus_xxx format)' },
    },
  },
};
 
const listSubscriptionsTool: Tool = {
  name: 'list_customer_subscriptions',
  description: "List a customer's subscriptions (read-only).",
  inputSchema: {
    type: 'object',
    required: ['customerId'],
    properties: {
      customerId: { type: 'string', description: 'Stripe customer ID' },
      status: {
        type: 'string',
        enum: ['active', 'past_due', 'canceled', 'trialing', 'all'],
        description: 'Filter by subscription status (defaults to active)',
      },
    },
  },
};
 
// --- Mutation tools ---
const createSubscriptionTool: Tool = {
  name: 'create_stripe_subscription',
  description: 'Create a new subscription for a customer. This action initiates billing.',
  inputSchema: {
    type: 'object',
    required: ['customerId', 'priceId'],
    properties: {
      customerId: { type: 'string', description: 'Stripe customer ID' },
      priceId: { type: 'string', description: 'Stripe price ID (price_xxx format)' },
      trialDays: { type: 'number', description: 'Number of trial days (omit for no trial)' },
    },
  },
};
 
const cancelSubscriptionTool: Tool = {
  name: 'cancel_stripe_subscription',
  description: 'Cancel a subscription. Set atPeriodEnd to true to cancel at the end of the billing period.',
  inputSchema: {
    type: 'object',
    required: ['subscriptionId'],
    properties: {
      subscriptionId: { type: 'string', description: 'Stripe subscription ID (sub_xxx format)' },
      atPeriodEnd: {
        type: 'boolean',
        description: 'true: cancel at period end (recommended) / false: cancel immediately',
      },
    },
  },
};
 
async function handleGetCustomer(input: {
  email?: string;
  customerId?: string;
}): Promise<ToolResult> {
  try {
    if (input.customerId) {
      const customer = await stripe.customers.retrieve(input.customerId);
      if (customer.deleted) {
        return { content: [{ type: 'text', text: 'This customer has been deleted.' }] };
      }
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            id: customer.id,
            email: customer.email,
            name: customer.name,
            created: new Date(customer.created * 1000).toISOString(),
            metadata: customer.metadata,
          }, null, 2),
        }],
      };
    }
 
    if (input.email) {
      const customers = await stripe.customers.list({ email: input.email, limit: 5 });
      if (customers.data.length === 0) {
        return {
          content: [{ type: 'text', text: `No customer found with email "${input.email}".` }],
        };
      }
      return {
        content: [{
          type: 'text',
          text: JSON.stringify(
            customers.data.map(c => ({
              id: c.id,
              email: c.email,
              name: c.name,
              created: new Date(c.created * 1000).toISOString(),
            })),
            null, 2
          ),
        }],
      };
    }
 
    return {
      content: [{ type: 'text', text: 'Provide either email or customerId.' }],
      isError: true,
    };
  } catch (error) {
    const stripeError = error as Stripe.errors.StripeError;
    return {
      content: [{ type: 'text', text: `Stripe API error [${stripeError.code}]: ${stripeError.message}` }],
      isError: true,
    };
  }
}
 
async function handleCreateSubscription(input: {
  customerId: string;
  priceId: string;
  trialDays?: number;
}): Promise<ToolResult> {
  try {
    const params: Stripe.SubscriptionCreateParams = {
      customer: input.customerId,
      items: [{ price: input.priceId }],
      // SCA compliance: create as incomplete, confirm payment on the frontend
      payment_behavior: 'default_incomplete',
      payment_settings: { save_default_payment_method: 'on_subscription' },
      expand: ['latest_invoice.payment_intent'],
    };
 
    if (input.trialDays && input.trialDays > 0) {
      params.trial_period_days = input.trialDays;
    }
 
    const subscription = await stripe.subscriptions.create(params);
    const latestInvoice = subscription.latest_invoice as Stripe.Invoice & {
      payment_intent?: Stripe.PaymentIntent;
    };
 
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          subscriptionId: subscription.id,
          status: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
          // Pass this client_secret to Stripe.js on the frontend to confirm payment
          clientSecret: latestInvoice?.payment_intent?.client_secret ?? null,
        }, null, 2),
      }],
    };
  } catch (error) {
    const stripeError = error as Stripe.errors.StripeError;
    if (stripeError.code === 'resource_missing') {
      return {
        content: [{
          type: 'text',
          text: `Resource not found. Verify both customerId and priceId. Details: ${stripeError.message}`,
        }],
        isError: true,
      };
    }
    return {
      content: [{ type: 'text', text: `Subscription creation error: ${stripeError.message}` }],
      isError: true,
    };
  }
}
 
async function handleCancelSubscription(input: {
  subscriptionId: string;
  atPeriodEnd?: boolean;
}): Promise<ToolResult> {
  try {
    const subscription = await stripe.subscriptions.update(input.subscriptionId, {
      cancel_at_period_end: input.atPeriodEnd ?? true,
    });
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          subscriptionId: subscription.id,
          status: subscription.status,
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
          cancelAt: subscription.cancel_at
            ? new Date(subscription.cancel_at * 1000).toISOString()
            : null,
        }, null, 2),
      }],
    };
  } catch (error) {
    const stripeError = error as Stripe.errors.StripeError;
    return {
      content: [{ type: 'text', text: `Cancellation error: ${stripeError.message}` }],
      isError: true,
    };
  }
}
 
async function handleListSubscriptions(input: {
  customerId: string;
  status?: 'active' | 'past_due' | 'canceled' | 'trialing' | 'all';
}): Promise<ToolResult> {
  try {
    const params: Stripe.SubscriptionListParams = {
      customer: input.customerId,
      limit: 10,
      expand: ['data.items'],
    };
    if (input.status && input.status !== 'all') {
      params.status = input.status;
    }
    const subscriptions = await stripe.subscriptions.list(params);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify(
          subscriptions.data.map(sub => ({
            id: sub.id,
            status: sub.status,
            currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
            cancelAtPeriodEnd: sub.cancel_at_period_end,
            priceIds: sub.items.data.map(item => item.price.id),
          })),
          null, 2
        ),
      }],
    };
  } catch (error) {
    const stripeError = error as Stripe.errors.StripeError;
    return {
      content: [{ type: 'text', text: `List subscriptions error: ${stripeError.message}` }],
      isError: true,
    };
  }
}
 
const server = new MCPServer({
  name: 'stripe-billing-mcp',
  version: '1.0.0',
  tools: [
    getCustomerTool,
    listSubscriptionsTool,
    createSubscriptionTool,
    cancelSubscriptionTool,
  ],
  onToolCall: async (name, input) => {
    switch (name) {
      case 'get_stripe_customer':
        return handleGetCustomer(input as any);
      case 'list_customer_subscriptions':
        return handleListSubscriptions(input as any);
      case 'create_stripe_subscription':
        return handleCreateSubscription(input as any);
      case 'cancel_stripe_subscription':
        return handleCancelSubscription(input as any);
      default:
        return {
          content: [{ type: 'text', text: `Unknown tool: ${name}` }],
          isError: true,
        };
    }
  },
});
 
server.start({ port: parseInt(process.env.PORT ?? '3000') });
console.log(`✅ Stripe MCP Server running on port ${process.env.PORT ?? '3000'}`);

Note the payment_behavior: 'default_incomplete' setting. This is the correct approach for SCA (Strong Customer Authentication) compliance: create the subscription in an incomplete state, then pass the client_secret to Stripe.js on the frontend to confirm payment. Without this, EU customers will see payment failures.

Connecting to Antigravity

Add the MCP server to Antigravity's configuration file (.antigravity/settings.json).

{
  "mcpServers": {
    "stripe-billing": {
      "url": "http://localhost:3000/mcp",
      "description": "Stripe billing tools — customer lookup, subscription management",
      "headers": {
        "Authorization": "Bearer YOUR_MCP_AUTH_TOKEN"
      }
    }
  }
}

Once configured, you can instruct the agent naturally in chat.

"Find the customer for user@example.com and write code to create a Pro Monthly
subscription with a 14-day trial."

The agent combines get_stripe_customer and create_stripe_subscription automatically, generating the implementation without you having to reference the API docs at all.

Implementing Secure Webhook Handling

Stripe Webhooks deliver real-time billing events — payment failures, subscription updates, cancellations. Without proper signature verification, any malicious request could trigger your billing logic.

import express from 'express';
import Stripe from 'stripe';
 
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});
 
// CRITICAL: Define the webhook endpoint before express.json().
// Stripe signature verification requires the raw request body.
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'];
    if (!signature) {
      return res.status(400).json({ error: 'Missing stripe-signature header' });
    }
 
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (error) {
      console.error('Webhook signature verification failed:', (error as Error).message);
      return res.status(400).json({ error: 'Webhook signature verification failed' });
    }
 
    // Return 200 immediately. Processing inline risks Stripe's 30-second timeout and retries.
    res.status(200).json({ received: true });
 
    try {
      await processWebhookEvent(event);
    } catch (err) {
      // Log errors but don't change the response — we've already sent 200
      console.error(`Webhook processing error (${event.type}):`, err);
    }
  }
);
 
async function processWebhookEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription;
      const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
      await updateUserSubscriptionStatus({
        stripeCustomerId: customerId,
        subscriptionId: sub.id,
        status: sub.status,
        currentPeriodEnd: sub.current_period_end,
      });
      break;
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription;
      const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
      await revokeUserAccess(customerId);
      break;
    }
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      const customerId = typeof invoice.customer === 'string'
        ? invoice.customer
        : (invoice.customer?.id ?? '');
      await notifyPaymentFailure(customerId, invoice.amount_due);
      break;
    }
    default:
      console.log(`Unhandled webhook event: ${event.type}`);
  }
}
 
async function updateUserSubscriptionStatus(params: {
  stripeCustomerId: string;
  subscriptionId: string;
  status: Stripe.Subscription.Status;
  currentPeriodEnd: number;
}): Promise<void> {
  // Replace with your actual DB update (Supabase, Firebase, Drizzle ORM, etc.)
  console.log('Updating subscription status:', params);
}
 
async function revokeUserAccess(stripeCustomerId: string): Promise<void> {
  console.log('Revoking access for:', stripeCustomerId);
}
 
async function notifyPaymentFailure(customerId: string, amountDue: number): Promise<void> {
  console.log('Payment failure notification:', { customerId, amountDue });
}

Security Design: API Key Scoping and Request Auth

Three practices I apply to every production Stripe MCP server.

Scope your API keys. In the Stripe dashboard under Developers → API Keys → Create restricted key, create a key that only permits the operations your server actually needs — customer reads, subscription writes. No refund access, no product creation.

Add Bearer token auth to the MCP server itself.

import type { Request, Response, NextFunction } from 'express';
 
function requireAuth(req: Request, res: Response, next: NextFunction): void {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Unauthorized: Bearer token required' });
    return;
  }
  const token = authHeader.slice(7);
  if (token !== process.env.MCP_AUTH_TOKEN) {
    res.status(403).json({ error: 'Forbidden: Invalid token' });
    return;
  }
  next();
}
 
app.use('/mcp', requireAuth);

Rate-limit mutation endpoints. Use express-rate-limit to cap subscription creation at 20 requests per IP per hour. This prevents runaway agent loops or malicious requests from triggering unexpected charges.

Common Pitfalls and How to Avoid Them

Four problems I encountered — and wasted time on — that you don't have to.

Pitfall 1: Body parser ordering destroys webhook verification. If you apply express.json() globally before defining the webhook route, Stripe's raw body is already parsed and signature verification fails every time. The webhook handler must use express.raw({ type: 'application/json' }) independently, registered before the global JSON parser. I lost two hours to this.

Pitfall 2: Test and live key confusion in deployments. It's easy to forget to swap sk_test_ for sk_live_ when deploying. The validateStripeKey() function at the top of the implementation throws at startup if a test key is detected in a production environment, blocking bad deploys before they cause damage.

Pitfall 3: Double-processing from checkout.session.completed and invoice.payment_succeeded. When you create a subscription via Checkout Session, both events fire. If you update the database in handlers for both, you get duplicate writes. Consolidate all subscription state updates into customer.subscription.updated and use checkout.session.completed only to record session completion.

Pitfall 4: Local Webhook testing with a mismatched secret. Running stripe listen --forward-to localhost:3000/webhook issues a temporary webhook secret that differs from your production one. Keep these in separate environment files (.env.local for development, secrets manager for production) and never reuse the same value.

Deploying to Cloudflare Workers

Moving the implementation to Cloudflare Workers requires one critical change: stripe.webhooks.constructEvent (synchronous) doesn't work in the Workers runtime. Use constructEventAsync with Stripe.createSubtleCryptoProvider() instead.

// Cloudflare Workers webhook handler using Hono
import { Hono } from 'hono';
import Stripe from 'stripe';
 
type Bindings = {
  STRIPE_SECRET_KEY: string;
  STRIPE_WEBHOOK_SECRET: string;
};
 
const app = new Hono<{ Bindings: Bindings }>();
 
app.post('/webhook', async (c) => {
  const body = await c.req.text();
  const signature = c.req.header('stripe-signature');
 
  if (!signature) {
    return c.json({ error: 'Missing stripe-signature' }, 400);
  }
 
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, {
    apiVersion: '2024-12-18.acacia',
  });
 
  let event: Stripe.Event;
  try {
    // Workers uses Web Crypto API (SubtleCrypto) — the sync version won't work here
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      c.env.STRIPE_WEBHOOK_SECRET,
      undefined,
      Stripe.createSubtleCryptoProvider()
    );
  } catch (error) {
    return c.json({ error: 'Webhook signature verification failed' }, 400);
  }
 
  // waitUntil continues async processing after the response is sent
  c.executionCtx.waitUntil(processWebhookEvent(event, stripe));
 
  return c.json({ received: true });
});
 
export default app;

Workers doesn't have Node.js's crypto module. Stripe.createSubtleCryptoProvider() tells the Stripe SDK to use the Web Crypto API instead. Omit this and every webhook returns a 400 with crypto.createHmac is not a function.

Extending to Multi-Tenant and Usage-Based Billing

For SaaS products serving multiple organizations, you need a mapping between your internal tenant IDs and Stripe Customer IDs. The MCP server exposes tenant-aware tools backed by that mapping.

async function handleCreateTenantSubscription(input: {
  tenantId: string;
  priceId: string;
  quantity: number;
  metadata?: Record<string, string>;
}): Promise<ToolResult> {
  const customerId = await getStripeCustomerIdForTenant(input.tenantId);
  if (!customerId) {
    return {
      content: [{
        type: 'text',
        text: `No Stripe Customer found for tenant "${input.tenantId}". Create the customer first.`,
      }],
      isError: true,
    };
  }
 
  try {
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: input.priceId, quantity: input.quantity }],
      metadata: {
        // Required for identifying the tenant in Webhook handlers
        tenantId: input.tenantId,
        ...input.metadata,
      },
      expand: ['latest_invoice.payment_intent'],
    });
 
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          subscriptionId: subscription.id,
          tenantId: input.tenantId,
          seats: input.quantity,
          status: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
        }, null, 2),
      }],
    };
  } catch (error) {
    const stripeError = error as Stripe.errors.StripeError;
    return {
      content: [{ type: 'text', text: `Tenant subscription error: ${stripeError.message}` }],
      isError: true,
    };
  }
}
 
// Resolve tenant ID to Stripe Customer ID from your database
async function getStripeCustomerIdForTenant(tenantId: string): Promise<string | null> {
  // Example with Supabase:
  // const { data } = await supabase
  //   .from('tenants')
  //   .select('stripe_customer_id')
  //   .eq('id', tenantId)
  //   .single()
  // return data?.stripe_customer_id ?? null
  return null; // Replace with your DB implementation
}

Always set metadata.tenantId. Without it, when customer.subscription.updated fires, you have no way to determine which tenant to update.

For usage-based billing, add a report_usage tool that calls stripe.billing.meterEvents.create(). The agent can then report usage — "record 150 API calls for tenant X" — without any manual intervention.

For a deeper look at building MCP servers from scratch in TypeScript, see the Antigravity Custom MCP Server Build Guide. If you're combining Stripe Connect with a marketplace platform, the Antigravity × Stripe Connect Marketplace Guide covers the multi-vendor payment architecture in detail.

Start Small, Build Confidence

The best first step is implementing just the get_stripe_customer tool and calling it from Antigravity in your test environment. Type "get the customer for user@example.com" in the chat and watch the agent hit the Stripe API on its own. That moment makes the whole architecture click.

Billing logic feels high-stakes, and rightly so. But starting with read-only tools, verifying they work, then incrementally adding mutation tools is the fastest path to trusting AI-driven billing in production.

Once you've built the MCP server once, it travels with you to every future project. Every SaaS you build with Antigravity shares the same billing foundation — and the agent gets better at using it each time you extend it.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Integrations2026-04-29
Building a Stripe Dunning Recovery Pipeline with Antigravity AI Agents — Stop Failed Payments from Becoming Churn
Wire Antigravity AI agents into Stripe Smart Retries to recover failed payments before they turn into churn. Webhooks, idempotency, AI-tailored email copy, Slack escalation, and winback offers — all in one production-ready pattern.
Integrations2026-04-10
Antigravity × MCP Integration Guide — Mastering 7,500+ Tools from Arcade.dev
Master the integration of Antigravity with MCP (Model Context Protocol) and leverage Arcade.dev's 7,500+ tools for AI agent development. Complete implementation guide with recipes.
Integrations2026-04-07
Antigravity × MCP Ecosystem 2026: Complete Practical Guide— Server Selection, Integration, and Custom Development
A complete guide to leveraging the Model Context Protocol (MCP) ecosystem with Antigravity. Covers selection criteria for 50+ MCP servers, combining multiple servers for complex workflows, custom MCP development, and production security.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →