Guide · SaaS Integration

MCP server Stripe integration

Stripe's API is one of the most common targets for MCP tool wrappers — agents need to create payment links, look up subscription status, issue refunds, and generate customer portal URLs without touching a UI. This guide covers the five things that make Stripe tools different from generic REST API wrappers: idempotency keys, PCI scope, Stripe-specific error types, rate limits, and the webhook-to-polling gap.

TL;DR

Use the official stripe Node.js SDK with a secret key stored in an environment variable. Wrap each mutating tool call with an idempotency key derived from the tool arguments to prevent duplicate charges. Keep card data out of tool arguments entirely — use Stripe.js on the client side to tokenize. Map Stripe's typed errors to isError: true tool responses with user-readable messages; reserve throw for unexpected failures. Poll Stripe for event-driven state changes rather than trying to receive webhooks from MCP tool calls. Wire AliveMCP to monitor your MCP server's uptime — a Stripe tool that silently fails looks like a working tool until a payment fails.

SDK setup and authentication

Install the Stripe SDK and initialize it with your secret key from an environment variable:

npm install stripe zod
import Stripe from "stripe";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

// Initialize once at module level — Stripe SDK is thread-safe and reusable
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
  typescript: true,
});

const server = new McpServer({ name: "payments-server", version: "1.0.0" });

The apiVersion pin is important: Stripe's API versions are not automatically forward-compatible, and the SDK generates TypeScript types for the specific version you pin. Upgrade the version intentionally and test each upgrade separately.

For multi-tenant MCP servers where different users have different Stripe accounts (e.g., a platform MCP server), initialize Stripe per-request using the user's Connect account ID:

// Per-request Stripe client for Connect platforms
function stripeForAccount(accountId: string) {
  return new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: "2024-12-18.acacia",
    stripeAccount: accountId,  // Routes to Connected Account
  });
}

Payment intent tools with idempotency keys

The most critical pattern for Stripe MCP tools: always supply an idempotency key on mutating operations. Without one, a retry (from network failure, MCP client timeout, or agent re-try on error) can create duplicate charges.

import crypto from "crypto";

server.tool(
  "create_payment_intent",
  "Create a Stripe PaymentIntent to charge a customer",
  {
    amount_cents: z.number().int().positive().describe("Amount in smallest currency unit (cents for USD)"),
    currency: z.string().length(3).toLowerCase().describe("ISO 4217 currency code (usd, eur, gbp)"),
    customer_id: z.string().startsWith("cus_").optional().describe("Stripe Customer ID to attach to"),
    description: z.string().max(1000).optional().describe("Internal description for the payment"),
    metadata: z.record(z.string()).optional().describe("Key-value metadata stored on the PaymentIntent"),
    idempotency_key: z.string().optional().describe("Caller-supplied idempotency key; generated if omitted"),
  },
  async ({ amount_cents, currency, customer_id, description, metadata, idempotency_key }) => {
    // Generate a deterministic idempotency key if the caller didn't supply one.
    // Hash the key business parameters so the same logical operation always
    // maps to the same key — preventing duplicates on LLM retry.
    const ikey = idempotency_key ?? crypto
      .createHash("sha256")
      .update(JSON.stringify({ amount_cents, currency, customer_id, description }))
      .digest("hex")
      .slice(0, 40);

    try {
      const intent = await stripe.paymentIntents.create(
        {
          amount: amount_cents,
          currency,
          customer: customer_id,
          description,
          metadata: metadata ?? {},
        },
        { idempotencyKey: ikey }
      );

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            id: intent.id,
            status: intent.status,
            client_secret: intent.client_secret,  // Frontend uses this to confirm
            amount: intent.amount,
            currency: intent.currency,
          }),
        }],
      };
    } catch (err) {
      return stripeErrorToToolResult(err);
    }
  }
);

The client_secret returned in the tool response is safe to pass to a frontend Stripe.js call — it scoped to confirming only this specific payment intent and expires after 24 hours.

Stripe error mapping

Stripe throws typed errors via the stripe.errors namespace. Map them to isError: true tool responses so the LLM gets a readable error it can act on, rather than an opaque exception:

import Stripe from "stripe";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

function stripeErrorToToolResult(err: unknown): CallToolResult {
  if (err instanceof Stripe.errors.StripeCardError) {
    // Card was declined — user-actionable, expose the decline code
    return {
      isError: true,
      content: [{ type: "text", text: `Card declined: ${err.message} (code: ${err.code})` }],
    };
  }
  if (err instanceof Stripe.errors.StripeInvalidRequestError) {
    // Bad parameters — LLM can fix these
    return {
      isError: true,
      content: [{ type: "text", text: `Invalid request: ${err.message} (param: ${err.param ?? "unknown"})` }],
    };
  }
  if (err instanceof Stripe.errors.StripeRateLimitError) {
    return {
      isError: true,
      content: [{ type: "text", text: "Stripe rate limit hit — retry after a few seconds" }],
    };
  }
  if (err instanceof Stripe.errors.StripeError) {
    // Generic Stripe error — don't expose raw message which may contain internal details
    return {
      isError: true,
      content: [{ type: "text", text: `Stripe error (${err.type}): ${err.message}` }],
    };
  }
  // Non-Stripe error — unexpected, throw so the SDK maps it as internal failure
  throw err;
}
Error classHTTP statusCauseLLM action
StripeCardError402Card declined, insufficient funds, expiredInform user; ask for different card
StripeInvalidRequestError400Bad parameter value or missing required fieldFix the tool argument and retry
StripeAuthenticationError401Invalid API keyEscalate — configuration error, can't fix at runtime
StripeRateLimitError429Too many requestsWait and retry
StripeConnectionErrorNetwork failure reaching StripeRetry with exponential backoff

Subscription management tools

Subscription tools follow the same idempotency pattern. Three tools cover most agent use cases: create, retrieve, and cancel:

server.tool(
  "get_subscription",
  "Retrieve a Stripe subscription's current status and next billing date",
  { subscription_id: z.string().startsWith("sub_") },
  async ({ subscription_id }) => {
    try {
      const sub = await stripe.subscriptions.retrieve(subscription_id, {
        expand: ["latest_invoice", "customer"],
      });

      const customer = sub.customer as Stripe.Customer;
      const invoice = sub.latest_invoice as Stripe.Invoice;

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            id: sub.id,
            status: sub.status,
            customer_email: customer.email,
            current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
            cancel_at_period_end: sub.cancel_at_period_end,
            plan_nickname: sub.items.data[0]?.price.nickname,
            amount_cents: sub.items.data[0]?.price.unit_amount,
            currency: sub.items.data[0]?.price.currency,
            latest_invoice_status: invoice?.status,
          }),
        }],
      };
    } catch (err) {
      return stripeErrorToToolResult(err);
    }
  }
);

server.tool(
  "cancel_subscription",
  "Cancel a Stripe subscription — immediately or at period end",
  {
    subscription_id: z.string().startsWith("sub_"),
    cancel_at_period_end: z.boolean().default(true).describe(
      "true = cancel at next billing date (no proration); false = cancel immediately"
    ),
  },
  async ({ subscription_id, cancel_at_period_end }) => {
    try {
      const sub = cancel_at_period_end
        ? await stripe.subscriptions.update(subscription_id, { cancel_at_period_end: true })
        : await stripe.subscriptions.cancel(subscription_id);

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            id: sub.id,
            status: sub.status,
            cancel_at_period_end: sub.cancel_at_period_end,
            canceled_at: sub.canceled_at
              ? new Date(sub.canceled_at * 1000).toISOString()
              : null,
          }),
        }],
      };
    } catch (err) {
      return stripeErrorToToolResult(err);
    }
  }
);

Customer portal link generation

The customer portal is the right way to let users manage their own payment methods and subscriptions from an LLM conversation — it handles card updates, cancellation, and plan changes without exposing card data to your MCP server:

server.tool(
  "create_customer_portal_session",
  "Generate a one-time Stripe customer portal URL for self-service subscription management",
  {
    customer_id: z.string().startsWith("cus_"),
    return_url: z.string().url().describe("URL to redirect back to after the portal session"),
  },
  async ({ customer_id, return_url }) => {
    try {
      const session = await stripe.billingPortal.sessions.create({
        customer: customer_id,
        return_url,
      });

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            url: session.url,
            expires: "Portal URLs are single-use and expire after the session ends",
          }),
        }],
      };
    } catch (err) {
      return stripeErrorToToolResult(err);
    }
  }
);

Portal configuration (which features are enabled) is set in the Stripe Dashboard under Billing → Customer portal, not in the API call. The portal URL is single-use — each call creates a new session.

PCI scope reduction: keep card data out of MCP tools

MCP tools handle text arguments that the LLM sees, which means any card number, CVV, or expiry passed as a tool argument is visible in the LLM's context. This immediately puts your MCP server in PCI scope.

The correct pattern: card data never enters your MCP server. Tokenization happens client-side via Stripe.js or the Stripe mobile SDK, and the MCP tool receives only the resulting PaymentMethod ID or SetupIntent client secret:

// ✗ WRONG — never do this
server.tool("charge_card", "...", {
  card_number: z.string(),  // card data visible to LLM
  cvv: z.string(),
  expiry: z.string(),
}, ...);

// ✓ CORRECT — tool receives Stripe token, not raw card data
server.tool("attach_payment_method", "Attach a tokenized payment method to a customer", {
  customer_id: z.string().startsWith("cus_"),
  payment_method_id: z.string().startsWith("pm_").describe(
    "PaymentMethod ID from Stripe.js — created client-side, never send raw card numbers"
  ),
}, async ({ customer_id, payment_method_id }) => {
  const pm = await stripe.paymentMethods.attach(payment_method_id, { customer: customer_id });
  return { content: [{ type: "text", text: `Attached ${pm.card?.brand} card ending in ${pm.card?.last4}` }] };
});

Tools that only read data (retrieve subscription, list invoices, get customer) don't touch card data and carry no PCI risk. Tokenization-only flows (where Stripe.js runs in a browser or mobile app) qualify for SAQ-A PCI compliance — the simplest tier.

Webhook-to-polling pattern for event-driven state

Stripe webhooks push events (payment succeeded, subscription renewed, dispute opened) via HTTP POST to a URL you control. MCP tool calls are pull-based — an LLM calls a tool and waits for a response. These models don't compose directly.

The clean solution: store webhook events in your database as they arrive, then expose polling tools that query that state:

// Webhook handler (Express/Hono — outside your MCP server)
app.post("/stripe/webhook", async (req, res) => {
  const sig = req.headers["stripe-signature"] as string;
  try {
    const event = stripe.webhooks.constructEvent(
      req.body,  // raw body — do NOT use parsed JSON
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
    // Store the event for polling
    await db.run(
      "INSERT OR IGNORE INTO stripe_events (id, type, data, created_at) VALUES (?,?,?,?)",
      [event.id, event.type, JSON.stringify(event.data.object), Date.now()]
    );
    res.sendStatus(200);
  } catch (err) {
    res.status(400).send(`Webhook error: ${(err as Error).message}`);
  }
});

// MCP polling tool
server.tool(
  "get_payment_status",
  "Get the current status of a payment by PaymentIntent ID",
  { payment_intent_id: z.string().startsWith("pi_") },
  async ({ payment_intent_id }) => {
    // Query your local event store first (fast, no Stripe API call)
    const event = await db.get(
      "SELECT type, data FROM stripe_events WHERE json_extract(data,'$.id') = ? ORDER BY created_at DESC LIMIT 1",
      [payment_intent_id]
    );

    if (event) {
      const data = JSON.parse(event.data);
      return { content: [{ type: "text", text: JSON.stringify({ source: "webhook", status: data.status, event_type: event.type }) }] };
    }

    // Fallback: query Stripe directly
    const intent = await stripe.paymentIntents.retrieve(payment_intent_id);
    return { content: [{ type: "text", text: JSON.stringify({ source: "stripe_api", status: intent.status }) }] };
  }
);

Always verify webhook signatures with stripe.webhooks.constructEvent(). Pass the raw request body — not JSON-parsed — to the verifier, because signature validation covers the exact byte sequence.

Rate limits and retry strategy

Stripe's default rate limits (as of 2026) are 100 read requests and 100 write requests per second per account. Most MCP tool calls are well within these limits unless the agent is calling tools in a tight loop. The Stripe SDK handles retry automatically with the maxNetworkRetries option:

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
  maxNetworkRetries: 2,  // retry connection errors and 429s automatically
  timeout: 10000,        // 10s timeout per request
});

With maxNetworkRetries set, the SDK retries automatically on network failures and StripeConnectionErrors. It does not retry on card errors or invalid parameter errors — those require the agent to change its approach.

For idempotent mutations, the SDK's built-in retry is safe — it reuses the idempotency key on each retry attempt, so no duplicate charges can occur even if the first attempt succeeds but the response is dropped.

Refund tool

Refunds are idempotent via the payment_intent parameter — Stripe deduplicates refunds for the same intent automatically up to the original amount:

server.tool(
  "create_refund",
  "Refund a Stripe payment — full or partial",
  {
    payment_intent_id: z.string().startsWith("pi_"),
    amount_cents: z.number().int().positive().optional()
      .describe("Amount to refund in cents; omit for full refund"),
    reason: z.enum(["duplicate", "fraudulent", "requested_by_customer"]).optional(),
  },
  async ({ payment_intent_id, amount_cents, reason }) => {
    try {
      const refund = await stripe.refunds.create(
        { payment_intent: payment_intent_id, amount: amount_cents, reason },
        { idempotencyKey: `refund-${payment_intent_id}-${amount_cents ?? "full"}` }
      );

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            id: refund.id,
            status: refund.status,
            amount: refund.amount,
            currency: refund.currency,
          }),
        }],
      };
    } catch (err) {
      return stripeErrorToToolResult(err);
    }
  }
);

Monitoring Stripe-dependent MCP servers

A Stripe integration failure is silent in a way most bugs aren't: the MCP tool appears to work (it returns HTTP 200), but the tool response contains an isError: true payload with "Stripe rate limit hit" or "Invalid API key" — and the LLM may or may not surface this to the user in a useful way.

What you need: an external probe that calls your MCP server's initialize endpoint every minute to verify the server is up and responding to the protocol, separate from any Stripe connectivity checks. AliveMCP provides this — it probes your MCP endpoint from outside your infrastructure, tracks protocol response health, and pages you before users see failures. This is separate from Stripe's own status page; your MCP server can be down while Stripe is fully operational.

Frequently asked questions

Should I store Stripe customer IDs in my database or let the agent look them up each time?

Store them. Stripe customer IDs (cus_...) are stable identifiers — looking up a customer by email via the Stripe API on every tool call adds latency and burns rate limit budget. The correct pattern: create a Stripe customer when a user registers (or on first payment), store the cus_... ID in your user table, and pass it to MCP tools as context. The agent should never need to search for a customer by email — that's a sign the customer ID isn't being surfaced in the tool's input context.

How do I handle Stripe Connect in MCP tools?

For Connect platforms (where you route payments to connected accounts), initialize the Stripe client per-request with the connected account's ID in the stripeAccount header. If your MCP server serves a single platform account, the secret key alone is sufficient and all operations go through that account. For multi-tenant scenarios where each MCP session represents a different connected account, pass the account ID as a tool argument (or derive it from the authenticated session) and construct a per-request Stripe client. Never put connected account secret keys in tool arguments.

Can I use Stripe's test mode keys in development and live keys in production?

Yes — Stripe keys are environment-specific. Test keys start with sk_test_ and live keys with sk_live_. Keep them in environment variables (STRIPE_SECRET_KEY) and use different values per environment. The Stripe SDK will throw if you attempt to use a live key against test objects or vice versa. Webhook secrets are also environment-specific — you'll have separate webhook endpoints for test and production, each with their own signing secret.

What's the safest way to expose invoice history as a tool?

Use stripe.invoices.list({ customer: customerId, limit: 10 }) and return only the fields the agent needs: invoice ID, amount, status, billing period, and hosted invoice URL. Don't return the raw Stripe invoice object — it's large and contains fields (like payment method fingerprints) that shouldn't appear in LLM context. The hosted invoice URL is safe to surface to users — it's a unique Stripe-hosted PDF that doesn't require authentication.

How do I prevent an LLM from accidentally creating duplicate charges?

Three layers: (1) always supply idempotency keys derived from the semantic intent of the request (not timestamps, which change on retry); (2) add a confirmation step in your agent prompt — the LLM should confirm amount and recipient before calling create_payment_intent; (3) use Stripe's built-in duplicate detection — PaymentIntents with the same idempotency key within 24 hours return the original intent, not a new one. The idempotency key is your primary defense; agent-level confirmation is a UX layer, not a safety guarantee.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free