Tollwicket

← Back to blog

Adding Stripe to an MCP server: the full integration guide

Step-by-step walkthrough for wiring Stripe Checkout, webhooks, and entitlements into an MCP server. Includes the pitfalls that will eat your first weekend.

If you want to charge for your MCP server using Stripe directly, this is the integration. We're going to do it the way Stripe themselves would recommend if they had an MCP example in their docs (they don't — yet).

This is the long version. If you just want the work done, Tollwicket compresses everything below into one Python decorator.

The architecture in one sentence

Your MCP server holds an authenticated user identity, looks up that user's Stripe subscription state in your own database, and gates tool calls accordingly. Stripe is the source of truth for who has paid; your database is the source of truth for who is who and what they've used.

That sentence hides four pieces of work:

  1. Setting up Products and Prices in Stripe.
  2. Building a Checkout flow.
  3. Listening to webhooks to keep your database in sync.
  4. Gating tool calls.

We'll do each.

Step 1: Products and Prices in Stripe

Open the Stripe dashboard, go to Products, create one Product per pricing tier. For each Product, create a recurring Price.

Example for a typical three-tier MCP server:

  • Hobby — $9/mo — Price ID price_1ABC...
  • Pro — $29/mo — Price ID price_1DEF...
  • Team — $99/mo — Price ID price_1GHI...

Save the Price IDs. They go in your code.

A common mistake: creating a single Product called "Subscription" with three Prices. Don't. Stripe Checkout's UI looks better when each tier is its own Product, and your analytics will be cleaner. One Product per tier.

Step 2: Building Checkout

You need a route on your server that creates a Stripe Checkout Session and returns the URL. Roughly:

import stripe
from fastapi import FastAPI, Request

stripe.api_key = "sk_test_..."

app = FastAPI()

PRICE_IDS = {
    "hobby": "price_1ABC...",
    "pro": "price_1DEF...",
    "team": "price_1GHI...",
}

@app.post("/checkout/{plan}")
async def create_checkout(plan: str, request: Request):
    user_id = await get_user_id(request)  # however you do auth
    session = stripe.checkout.Session.create(
        mode="subscription",
        line_items=[{"price": PRICE_IDS[plan], "quantity": 1}],
        success_url="https://yourtool.com/upgrade/success?session_id={CHECKOUT_SESSION_ID}",
        cancel_url="https://yourtool.com/upgrade/cancel",
        client_reference_id=user_id,
        customer_email=await get_user_email(user_id),
    )
    return {"url": session.url}

The key detail is client_reference_id=user_id. This is how you link the Stripe Checkout Session back to your user when the webhook fires. Forget this and you will spend an evening writing a reconciliation script.

Step 3: Webhooks

Stripe fires webhooks when payment events happen. You need a route that receives them, verifies the signature, and updates your database.

The events you care about for a subscription MCP server:

  • checkout.session.completed — user finished paying. Create or update their subscription record. Store the Stripe customer ID and subscription ID on your user row.
  • customer.subscription.updated — plan changed, payment method updated, subscription state changed (active / past_due / canceled). Sync your local copy.
  • customer.subscription.deleted — subscription ended. Downgrade the user to the free tier.
  • invoice.payment_failed — card declined. Optionally email the user; Stripe will retry automatically and eventually fire subscription.updated with status past_due if it gives up.

Roughly:

@app.post("/stripe/webhook")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig = request.headers["stripe-signature"]
    try:
        event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
    except (ValueError, stripe.error.SignatureVerificationError):
        return Response(status_code=400)

    if event.type == "checkout.session.completed":
        session = event.data.object
        user_id = session.client_reference_id
        await db.set_subscription(
            user_id=user_id,
            stripe_customer_id=session.customer,
            stripe_subscription_id=session.subscription,
            status="active",
            plan=session.metadata.get("plan"),  # or look it up from the line items
        )
    elif event.type == "customer.subscription.updated":
        sub = event.data.object
        await db.update_subscription_status(
            stripe_subscription_id=sub.id,
            status=sub.status,
        )
    # ... etc.

    return {"received": True}

Verify signatures. Always verify signatures. A common attack pattern is forging webhook events to upgrade an attacker's account — Stripe's signature scheme is the only thing standing between you and that.

Step 4: Gating tool calls

Now that your database knows who has paid, you wrap your MCP tools to check it before doing the work.

from mcp.server.fastmcp import FastMCP

server = FastMCP("yourtool")

@server.tool()
async def expensive_lookup(query: str, *, ctx) -> str:
    user_id = await get_user_id_from_ctx(ctx)
    sub = await db.get_subscription(user_id)

    if not sub or sub.status != "active":
        raise PaywallError(
            "This tool requires an active subscription. "
            "Upgrade at https://yourtool.com/upgrade"
        )

    if sub.plan == "hobby" and await db.tool_calls_today(user_id) >= 100:
        raise PaywallError(
            "Daily quota reached on the Hobby plan. "
            "Upgrade at https://yourtool.com/upgrade/pro"
        )

    return await _do_the_lookup(query)

The raise PaywallError(...) message is the UX. The LLM will see this string and surface it to the user. Spend time on it. Include a working upgrade URL. Make the path forward obvious.

The pitfalls

Test mode and live mode have separate everything. Test mode webhook secret, test mode Price IDs, test mode customer IDs. When you switch to live, expect to spend an afternoon swapping them in and discovering a hardcoded test ID you missed.

Webhooks are not strictly ordered. Stripe may deliver subscription.updated before checkout.session.completed if their internal pipeline reorders them. Make your handlers idempotent and tolerant of out-of-order events.

subscription.status has more values than you expect. Beyond active and canceled, you'll see incomplete, incomplete_expired, past_due, unpaid, trialing, and paused. Decide which of those count as "entitled" — typically only active and trialing.

Restricted keys, not secret keys. Generate a Stripe restricted key scoped to just Customers / Products / Prices / Checkout / Webhooks. Don't use your full secret key in production. If your server gets popped, a full secret key gives attackers your payouts. A restricted key gives them nothing they can drain.

Customer Portal saves you a lot of UI. Stripe's hosted Customer Portal handles cancellations, payment method updates, invoice downloads. Use it. Don't build that UI yourself.

How long does this take?

If you've done a Stripe integration before, this is a two- to three-day exercise. If you haven't, plan on a week, plus another week of bugs after you start taking real money.

The shortcut

If you want to skip steps 1–4 and ship in an afternoon, Tollwicket does the same job with a one-line decorator:

from mcp_billing import billed_tool

@billed_tool(plan="pro", daily_quota_free=100)
async def expensive_lookup(query: str) -> str:
    return await _do_the_lookup(query)

You still bring your own Stripe account; payments still flow to you, not us; your customer's card statement still says your business name. We just take care of the Products, Prices, Checkout, webhooks, and quota gating.

Either way, you'll end up with a working paid MCP server. The question is just whether you want to spend a week on the plumbing.

Related reading

Ship a paid MCP tool this weekend.

Drop one Python decorator. Tollwicket handles auth, quotas, and Stripe Checkout — on your own Stripe account. Free until you cross $500/mo of customer revenue.