Building a Stripe MCP Server for Finance and Revenue Ops Agents

by Hoshang Mehta

Finance and revenue ops teams are drowning in Stripe data. Every day, they're answering questions like "What's our MRR trend?", "Which customers are at risk of churning?", or "What's the impact of that pricing change?" The problem? Stripe's API is powerful, but it's not built for agents. Direct API access means agents can accidentally trigger refunds, expose sensitive payment data, or make expensive API calls that blow your rate limits.

I've watched teams connect agents directly to Stripe, only to discover agents making unauthorized API calls, accessing customer payment methods they shouldn't see, or generating thousands of API requests that hit rate limits and break integrations. The worst part? Stripe's API keys—even restricted keys—don't protect you from agent-specific risks.

An MCP server on top of Stripe gives you the control you need. It turns Stripe's API into a secure, governed data layer that agents can safely query. Instead of giving agents raw API access, you expose sandboxed endpoints through MCP tools that enforce access boundaries, optimize API usage, and provide audit trails.

This guide shows you how to build a Stripe MCP server from scratch. You'll learn how to use Stripe's API safely, create sandboxed data views, build MCP tools, and deploy a production-ready server that keeps your financial data secure.

Table of Contents


Why Stripe Needs an MCP Server

Stripe's API is built for applications. Its security model—API keys, webhooks, and permissions—works perfectly for apps. But agents break that model in ways that create serious risks.

Problem 1: Unauthorized API Calls

What happens: You give agents API keys, thinking they'll only read data. But agents can make write operations—refunds, cancellations, updates—that you never intended.

Example: A finance agent needs to check subscription status. You give it a restricted API key. The agent generates a query that accidentally triggers a subscription cancellation instead of just reading status.

Impact: Revenue loss, customer complaints, service disruption.

Solution: MCP server uses API keys only for specific, read-only operations. Agents never get direct API key access.

Problem 2: Rate Limit Exhaustion

What happens: Agents make hundreds of API calls, exhausting Stripe's rate limits.

Example: An agent framework spawns 50 concurrent agent instances, each making API calls to Stripe. Your rate limit (default: 100 requests per second) is exhausted. Customer-facing integrations fail.

Impact: Integration failures, customer complaints, revenue loss.

Solution: MCP server manages API calls efficiently, implements caching, and batches requests. One server, unlimited agents.

Problem 3: Sensitive Data Exposure

What happens: Agents can access payment methods, card numbers, and other sensitive financial data they shouldn't see.

Example: A revenue ops agent needs to check subscription status. With direct API access, it can also see payment methods, card numbers, and billing addresses—data it doesn't need and shouldn't access.

Impact: Compliance violations, security incidents, regulatory fines.

Solution: MCP server filters sensitive data. Agents only see what they need—subscription status, not payment methods.

Problem 4: No Audit Trail

What happens: You can't track which agents accessed which Stripe data, when, or why.

Example: A compliance auditor asks: "Which agents accessed customer payment data in the last 30 days?" You check Stripe logs, but they don't show agent context—just raw API calls.

Impact: Failed compliance audits, inability to investigate security incidents, regulatory fines.

Solution: MCP server logs every API call with agent context, user identity, and access patterns. Complete audit trail.

Problem 5: Cost Overruns

What happens: Agents make expensive API calls that increase your Stripe costs.

Example: An agent runs a query that makes 1,000 API calls to fetch customer data. Each call costs money. Your Stripe bill spikes.

Impact: Increased costs, budget overruns, financial surprises.

Solution: MCP server implements caching, batches requests, and optimizes API usage. Fewer calls, lower costs.


Understanding Stripe's API and Security Model

Before building an MCP server, you need to understand how Stripe's API works.

API Keys

Stripe uses API keys for authentication. There are two types:

Live keys: Used for production. Start with sk_live_ (secret) or pk_live_ (publishable).

Test keys: Used for testing. Start with sk_test_ (secret) or pk_test_ (publishable).

Problem for agents: API keys give full access to Stripe's API. Even restricted keys can access more than agents need.

Solution: MCP server uses API keys internally. Agents never see them.

API Permissions

Stripe API keys have permissions based on your account settings. You can restrict keys to specific operations, but restrictions are limited.

Problem for agents: Even restricted keys can access sensitive data or perform operations agents shouldn't do.

Solution: MCP server enforces additional access boundaries beyond Stripe's built-in restrictions.

Rate Limits

Stripe enforces rate limits:

  • Default: 100 requests per second per API key
  • Burst: Up to 100 requests in a single second
  • Daily: Varies by account tier

Problem: Agents can exhaust rate limits, breaking integrations.

Solution: MCP server implements rate limiting, caching, and request batching to stay within limits.

Webhooks

Stripe sends webhooks for events (payment succeeded, subscription canceled, etc.). Webhooks are one-way—Stripe → your server.

Problem for agents: Agents can't use webhooks directly. They need to query Stripe's API.

Solution: MCP server queries Stripe's API efficiently, using webhooks to update cached data when possible.

Data Structure

Stripe's API returns nested JSON objects. Common objects:

  • Customers: Customer information
  • Subscriptions: Subscription details
  • Invoices: Invoice data
  • Payment Intents: Payment information
  • Charges: Charge details

Problem for agents: Agents need specific data, not entire Stripe objects.

Solution: MCP server filters and transforms Stripe data, returning only what agents need.


Architecture: Three-Layer Pattern for Stripe

The Stripe MCP server uses a three-layer architecture:

Agent → MCP Tool → Data View → API Handler → Stripe API

Layer 1: API Key Isolation

Purpose: Use API keys safely, only within the MCP server.

What it does:

  • MCP server holds API keys
  • Agents never see API keys
  • All API calls use keys internally
  • Access boundaries enforced by data views

Why it matters: API keys provide the access agents need, but data views limit what they can actually query.

Layer 2: Sandboxed Data Views

Purpose: Create data views that filter and transform Stripe data.

What it does:

  • Filters sensitive data (payment methods, card numbers)
  • Transforms data into agent-friendly formats
  • Caches frequently accessed data
  • Batches API calls for efficiency

Why it matters: Data views define exactly what agents can access. No sensitive data, no unnecessary API calls.

Layer 3: MCP Tool Builder

Purpose: Turn data views into MCP tools that agents can use.

What it does:

  • Generates MCP tool definitions from data views
  • Validates inputs to prevent invalid API calls
  • Handles errors gracefully
  • Provides natural language interfaces

Why it matters: Agents use tools, not API calls directly. Tools make agent interactions natural and safe.


Layer 1: API Key Isolation

The MCP server uses Stripe API keys internally. Agents never see these keys.

Setting Up API Key Access

Step 1: Get Your API Keys

In Stripe Dashboard:

  1. Go to Developers → API keys
  2. Copy your Secret key (keep it secret!)
  3. Optionally, create a restricted key with limited permissions

Step 2: Store Keys Securely

Never commit API keys to code. Use environment variables:

# .env
STRIPE_SECRET_KEY=sk_live_your_secret_key
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

Step 3: Initialize Stripe Client

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
  maxNetworkRetries: 2,
  timeout: 20000,
})

Why this matters: API keys give full access to Stripe's API. But you'll use data views to limit what agents can actually query.

Rate Limiting

Stripe clients handle rate limiting automatically, but you should configure it for agent workloads.

Option 1: Use Stripe's Built-in Rate Limiting

Stripe's SDK handles rate limiting automatically. It retries requests when rate limits are hit.

Option 2: Implement Custom Rate Limiting

For more control, implement custom rate limiting:

import { LRUCache } from 'lru-cache'

const rateLimitCache = new LRUCache<string, number[]>({
  max: 1000,
  ttl: 1000, // 1 second
})

async function makeStripeRequest<T>(
  operation: () => Promise<T>,
  identifier: string = 'default'
): Promise<T> {
  const now = Date.now()
  const windowStart = now - 1000 // 1 second window

  const requests = rateLimitCache.get(identifier) || []
  const recentRequests = requests.filter(time => time > windowStart)

  if (recentRequests.length >= 90) { // Stay under 100/sec limit
    await new Promise(resolve => setTimeout(resolve, 1000 - (now - windowStart)))
  }

  recentRequests.push(now)
  rateLimitCache.set(identifier, recentRequests)

  return await operation()
}

Why this matters: Rate limiting prevents API exhaustion. One server handles all agent queries efficiently.


Layer 2: Sandboxed Data Views

Create data views that filter and transform Stripe data. Views use API keys internally but limit data exposure.

Creating Sandboxed Views

Step 1: Identify What Agents Need

Before creating views, identify what data agents actually need:

Example: A revenue ops agent needs:

  • ✅ Subscription status, MRR, churn rate
  • ✅ Customer plan, billing cycle, renewal date
  • ✅ Recent invoices, payment status
  • ❌ Payment methods, card numbers
  • ❌ Full customer objects with all metadata
  • ❌ Refund details, dispute information

Step 2: Create the View

Create a view that includes only what agents need:

// Customer Revenue View (Sandboxed)
interface CustomerRevenueView {
  customer_id: string
  email: string
  subscription_status: string
  plan_name: string
  mrr: number
  billing_cycle: string
  renewal_date: string
  last_payment_date: string
  payment_status: string
  // Excludes: payment_methods, card_numbers, full_customer_object, etc.
}

async function getCustomerRevenueView(customerId: string): Promise<CustomerRevenueView> {
  // Fetch customer and subscription
  const [customer, subscriptions] = await Promise.all([
    stripe.customers.retrieve(customerId),
    stripe.subscriptions.list({ customer: customerId, limit: 1 }),
  ])

  const subscription = subscriptions.data[0]
  const latestInvoice = subscription
    ? await stripe.invoices.retrieve(subscription.latest_invoice as string)
    : null

  // Transform and filter data
  return {
    customer_id: customer.id,
    email: customer.email || '',
    subscription_status: subscription?.status || 'none',
    plan_name: subscription?.items.data[0]?.price.nickname || 'none',
    mrr: subscription
      ? (subscription.items.data[0]?.price.unit_amount || 0) / 100
      : 0,
    billing_cycle: subscription?.billing_cycle_anchor
      ? new Date(subscription.billing_cycle_anchor * 1000).toISOString()
      : '',
    renewal_date: subscription?.current_period_end
      ? new Date(subscription.current_period_end * 1000).toISOString()
      : '',
    last_payment_date: latestInvoice?.paid
      ? new Date(latestInvoice.status_transitions.paid_at! * 1000).toISOString()
      : '',
    payment_status: latestInvoice?.status || 'none',
    // Excludes: customer.payment_methods, customer.sources, etc.
  }
}

Step 3: Add Caching

Add caching to reduce API calls:

import { LRUCache } from 'lru-cache'

const customerCache = new LRUCache<string, CustomerRevenueView>({
  max: 1000,
  ttl: 60000, // 1 minute
})

async function getCustomerRevenueView(customerId: string): Promise<CustomerRevenueView> {
  // Check cache first
  const cached = customerCache.get(customerId)
  if (cached) {
    return cached
  }

  // Fetch from Stripe
  const view = await fetchCustomerRevenueView(customerId)

  // Cache result
  customerCache.set(customerId, view)

  return view
}

Why this matters: Views define exactly what agents can access. Underlying Stripe data remains protected.

Advanced View Patterns

Pattern 1: Aggregated Revenue Views

Views that aggregate revenue data:

interface RevenueMetricsView {
  total_mrr: number
  active_subscriptions: number
  churned_subscriptions: number
  new_mrr: number
  expansion_mrr: number
  contraction_mrr: number
  churn_rate: number
}

async function getRevenueMetricsView(
  startDate: Date,
  endDate: Date
): Promise<RevenueMetricsView> {
  // Fetch subscriptions
  const subscriptions = await stripe.subscriptions.list({
    created: { gte: Math.floor(startDate.getTime() / 1000) },
    limit: 100,
  })

  // Aggregate data
  let totalMRR = 0
  let activeSubscriptions = 0
  let churnedSubscriptions = 0
  let newMRR = 0
  let expansionMRR = 0
  let contractionMRR = 0

  for (const subscription of subscriptions.data) {
    const mrr = (subscription.items.data[0]?.price.unit_amount || 0) / 100

    if (subscription.status === 'active') {
      totalMRR += mrr
      activeSubscriptions++
    } else if (subscription.status === 'canceled') {
      churnedSubscriptions++
    }

    // Calculate new/expansion/contraction (simplified)
    if (subscription.created >= Math.floor(startDate.getTime() / 1000)) {
      newMRR += mrr
    }
  }

  const churnRate = activeSubscriptions > 0
    ? (churnedSubscriptions / (activeSubscriptions + churnedSubscriptions)) * 100
    : 0

  return {
    total_mrr: totalMRR,
    active_subscriptions: activeSubscriptions,
    churned_subscriptions: churnedSubscriptions,
    new_mrr: newMRR,
    expansion_mrr: expansionMRR,
    contraction_mrr: contractionMRR,
    churn_rate: churnRate,
  }
}

Pattern 2: Customer Health Views

Views that combine multiple Stripe objects:

interface CustomerHealthView {
  customer_id: string
  email: string
  subscription_status: string
  mrr: number
  days_since_last_payment: number
  failed_payment_count: number
  risk_score: 'low' | 'medium' | 'high'
}

async function getCustomerHealthView(customerId: string): Promise<CustomerHealthView> {
  const [customer, subscriptions, paymentIntents] = await Promise.all([
    stripe.customers.retrieve(customerId),
    stripe.subscriptions.list({ customer: customerId, limit: 1 }),
    stripe.paymentIntents.list({ customer: customerId, limit: 10 }),
  ])

  const subscription = subscriptions.data[0]
  const failedPayments = paymentIntents.data.filter(pi => pi.status === 'requires_payment_method')

  const lastPayment = paymentIntents.data.find(pi => pi.status === 'succeeded')
  const daysSinceLastPayment = lastPayment
    ? Math.floor((Date.now() - lastPayment.created * 1000) / (1000 * 60 * 60 * 24))
    : 999

  // Calculate risk score
  let riskScore: 'low' | 'medium' | 'high' = 'low'
  if (failedPayments.length > 2 || daysSinceLastPayment > 30) {
    riskScore = 'high'
  } else if (failedPayments.length > 0 || daysSinceLastPayment > 14) {
    riskScore = 'medium'
  }

  return {
    customer_id: customer.id,
    email: customer.email || '',
    subscription_status: subscription?.status || 'none',
    mrr: subscription
      ? (subscription.items.data[0]?.price.unit_amount || 0) / 100
      : 0,
    days_since_last_payment: daysSinceLastPayment,
    failed_payment_count: failedPayments.length,
    risk_score: riskScore,
  }
}

Pattern 3: Invoice Views

Views that filter invoice data:

interface InvoiceSummaryView {
  invoice_id: string
  customer_id: string
  amount: number
  status: string
  due_date: string
  paid_date?: string
  line_items: Array<{
    description: string
    amount: number
  }>
}

async function getInvoiceSummaryView(invoiceId: string): Promise<InvoiceSummaryView> {
  const invoice = await stripe.invoices.retrieve(invoiceId)

  return {
    invoice_id: invoice.id,
    customer_id: invoice.customer as string,
    amount: invoice.amount_paid / 100,
    status: invoice.status || 'draft',
    due_date: invoice.due_date
      ? new Date(invoice.due_date * 1000).toISOString()
      : '',
    paid_date: invoice.status_transitions.paid_at
      ? new Date(invoice.status_transitions.paid_at * 1000).toISOString()
      : undefined,
    line_items: invoice.lines.data.map(line => ({
      description: line.description || '',
      amount: (line.amount || 0) / 100,
    })),
    // Excludes: payment_intent, charge, payment_method details
  }
}

Layer 3: MCP Tool Builder

Turn data views into MCP tools that agents can use. Tools provide natural language interfaces and input validation.

What Are MCP Tools?

MCP (Model Context Protocol) tools are functions that agents can call. They abstract Stripe API calls behind natural language interfaces.

Tool structure:

{
  "name": "get_customer_revenue",
  "description": "Get customer revenue information for finance context. Returns subscription status, MRR, billing cycle, and payment status.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "customer_id": {
        "type": "string",
        "description": "Stripe customer ID"
      }
    },
    "required": ["customer_id"]
  }
}

Creating Tools from Views

Step 1: Define the Tool

Describe what the tool should do:

Tool: get_customer_revenue
Purpose: Get customer revenue information for finance context
Input: Stripe customer ID
Output: Subscription status, MRR, billing cycle, payment status
View: CustomerRevenueView

Step 2: Create Tool Handler

Create the tool handler that queries the view:

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

async function getCustomerRevenue(customerId: string) {
  // Validate input
  if (!customerId || !customerId.startsWith('cus_')) {
    throw new Error('Invalid customer ID format')
  }

  // Query view (uses cached data when possible)
  const view = await getCustomerRevenueView(customerId)

  return view
}

Step 3: Create MCP Tool Definition

Create the MCP tool definition:

const tools = [
  {
    name: 'get_customer_revenue',
    description: 'Get customer revenue information for finance context. Returns subscription status, MRR, billing cycle, and payment status.',
    inputSchema: {
      type: 'object',
      properties: {
        customer_id: {
          type: 'string',
          description: 'Stripe customer ID (starts with cus_)',
        },
      },
      required: ['customer_id'],
    },
  },
]

Step 4: Implement MCP Server

Implement the MCP server that handles tool calls:

import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const server = new Server(
  {
    name: 'stripe-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
)

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: tools.map(tool => ({
    name: tool.name,
    description: tool.description,
    inputSchema: tool.inputSchema,
  })),
}))

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params

  if (name === 'get_customer_revenue') {
    const customerId = args?.customer_id as string
    if (!customerId) {
      throw new Error('Customer ID is required')
    }

    const result = await getCustomerRevenue(customerId)
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(result, null, 2),
        },
      ],
    }
  }

  throw new Error(`Unknown tool: ${name}`)
})

// Start server
const transport = new StdioServerTransport()
await server.connect(transport)

Input Validation

Always validate inputs to prevent invalid API calls:

function validateCustomerId(customerId: string): boolean {
  return customerId.startsWith('cus_') && customerId.length > 5
}

function validateInvoiceId(invoiceId: string): boolean {
  return invoiceId.startsWith('in_') && invoiceId.length > 5
}

function validateSubscriptionId(subscriptionId: string): boolean {
  return subscriptionId.startsWith('sub_') && subscriptionId.length > 5
}

async function getCustomerRevenue(customerId: string) {
  // Validate input
  if (!validateCustomerId(customerId)) {
    throw new Error('Invalid customer ID format. Must start with cus_')
  }

  // Query view
  const view = await getCustomerRevenueView(customerId)

  return view
}

Why this matters: Input validation prevents invalid API calls and ensures queries are safe. Stripe API handles errors, but validation adds an extra layer of security.


Step-by-Step Implementation

Here's how to build a Stripe MCP server step by step:

Step 1: Set Up Stripe Account

Time: 15 minutes

Steps:

  1. Create a Stripe account (or use existing)
  2. Get your API keys from Developers → API keys
  3. Store keys in environment variables

Commands:

# Create .env file
echo "STRIPE_SECRET_KEY=sk_test_your_secret_key" > .env
echo "STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key" >> .env

Step 2: Create Sandboxed Views

Time: 2-3 hours per view

Steps:

  1. Identify what data agents need
  2. Create view function that filters and transforms Stripe data
  3. Add caching for performance
  4. Test the view

Example:

// src/views/customer-revenue.ts
import Stripe from 'stripe'
import { LRUCache } from 'lru-cache'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const cache = new LRUCache<string, CustomerRevenueView>({
  max: 1000,
  ttl: 60000, // 1 minute
})

export async function getCustomerRevenueView(
  customerId: string
): Promise<CustomerRevenueView> {
  const cached = cache.get(customerId)
  if (cached) return cached

  const [customer, subscriptions] = await Promise.all([
    stripe.customers.retrieve(customerId),
    stripe.subscriptions.list({ customer: customerId, limit: 1 }),
  ])

  const subscription = subscriptions.data[0]
  const view: CustomerRevenueView = {
    customer_id: customer.id,
    email: customer.email || '',
    subscription_status: subscription?.status || 'none',
    plan_name: subscription?.items.data[0]?.price.nickname || 'none',
    mrr: subscription
      ? (subscription.items.data[0]?.price.unit_amount || 0) / 100
      : 0,
    billing_cycle: subscription?.billing_cycle_anchor
      ? new Date(subscription.billing_cycle_anchor * 1000).toISOString()
      : '',
    renewal_date: subscription?.current_period_end
      ? new Date(subscription.current_period_end * 1000).toISOString()
      : '',
    last_payment_date: '',
    payment_status: 'none',
  }

  cache.set(customerId, view)
  return view
}

Step 3: Build MCP Server

Time: 2-3 hours

Steps:

  1. Initialize Node.js project
  2. Install MCP SDK and Stripe SDK
  3. Create tool handlers
  4. Implement MCP server
  5. Test with agent framework

Commands:

# Initialize project
npm init -y
npm install @modelcontextprotocol/sdk stripe lru-cache
npm install -D typescript @types/node tsx

# Create server file
touch src/server.ts

Example server:

// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { getCustomerRevenueView } from './views/customer-revenue.js'

const server = new Server(
  {
    name: 'stripe-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
)

// List tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'get_customer_revenue',
      description: 'Get customer revenue information for finance context',
      inputSchema: {
        type: 'object',
        properties: {
          customer_id: { type: 'string', description: 'Stripe customer ID' }
        },
        required: ['customer_id']
      }
    }
  ]
}))

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params

  if (name === 'get_customer_revenue') {
    const result = await getCustomerRevenueView(args?.customer_id as string)
    return {
      content: [{ type: 'text', text: JSON.stringify(result) }]
    }
  }

  throw new Error(`Unknown tool: ${name}`)
})

const transport = new StdioServerTransport()
await server.connect(transport)

Step 4: Deploy MCP Server

Time: 30 minutes

Steps:

  1. Deploy server to hosting platform (Vercel, Railway, Fly.io)
  2. Configure environment variables
  3. Test server endpoint
  4. Get server URL for agent frameworks

Example (Vercel):

# Install Vercel CLI
npm i -g vercel

# Deploy
vercel --prod

Step 5: Connect Agents

Time: 15 minutes per agent

Steps:

  1. Get MCP server URL
  2. Add to agent framework configuration
  3. Test agent can use tools
  4. Verify access boundaries work

Example (Claude Desktop):

{
  "mcpServers": {
    "stripe": {
      "url": "https://your-mcp-server.vercel.app",
      "apiKey": "your-api-key"
    }
  }
}

Step 6: Add Monitoring

Time: 1 hour

Steps:

  1. Set up API call logging
  2. Track API usage and costs
  3. Monitor rate limits
  4. Set up alerts

Example:

// Add logging to tool handlers
async function getCustomerRevenue(customerId: string) {
  const startTime = Date.now()
  
  const result = await getCustomerRevenueView(customerId)

  const duration = Date.now() - startTime

  // Log API call
  console.log({
    tool: 'get_customer_revenue',
    customer_id: customerId,
    duration,
    api_calls: 2, // customer + subscriptions
  })

  return result
}

Real-World Examples

Let me show you real-world implementations:

Example 1: Revenue Ops Agent MCP Server

Requirements:

  • Revenue ops agents answer MRR questions
  • Access subscription and revenue data
  • Cannot access payment methods
  • Cannot access full customer objects

Implementation:

1. Sandboxed View:

interface RevenueOpsView {
  customer_id: string
  email: string
  subscription_status: string
  mrr: number
  billing_cycle: string
  renewal_date: string
  churn_risk: 'low' | 'medium' | 'high'
}

async function getRevenueOpsView(customerId: string): Promise<RevenueOpsView> {
  const [customer, subscriptions] = await Promise.all([
    stripe.customers.retrieve(customerId),
    stripe.subscriptions.list({ customer: customerId, limit: 1 }),
  ])

  const subscription = subscriptions.data[0]
  
  // Calculate churn risk (simplified)
  let churnRisk: 'low' | 'medium' | 'high' = 'low'
  if (subscription?.status === 'past_due') {
    churnRisk = 'high'
  } else if (subscription?.cancel_at_period_end) {
    churnRisk = 'medium'
  }

  return {
    customer_id: customer.id,
    email: customer.email || '',
    subscription_status: subscription?.status || 'none',
    mrr: subscription
      ? (subscription.items.data[0]?.price.unit_amount || 0) / 100
      : 0,
    billing_cycle: subscription?.billing_cycle_anchor
      ? new Date(subscription.billing_cycle_anchor * 1000).toISOString()
      : '',
    renewal_date: subscription?.current_period_end
      ? new Date(subscription.current_period_end * 1000).toISOString()
      : '',
    churn_risk: churnRisk,
  }
}

2. MCP Tool:

{
  name: 'get_revenue_ops_data',
  description: 'Get revenue operations data for customer. Returns subscription status, MRR, billing cycle, and churn risk.',
  inputSchema: {
    type: 'object',
    properties: {
      customer_id: { type: 'string', description: 'Stripe customer ID' }
    },
    required: ['customer_id']
  }
}

3. Tool Handler:

async function getRevenueOpsData(customerId: string) {
  if (!validateCustomerId(customerId)) {
    throw new Error('Invalid customer ID format')
  }

  return await getRevenueOpsView(customerId)
}

Result: Revenue ops agents get complete revenue context without accessing payment methods or full customer objects.

Example 2: Finance Agent MCP Server

Requirements:

  • Finance agents generate financial reports
  • Access aggregated revenue data
  • Cannot access individual customer data
  • Cannot access payment details

Implementation:

1. Sandboxed View (Aggregated):

interface FinanceMetricsView {
  total_mrr: number
  active_subscriptions: number
  churned_subscriptions: number
  new_mrr: number
  churn_rate: number
  revenue_trend: 'up' | 'down' | 'stable'
}

async function getFinanceMetricsView(
  startDate: Date,
  endDate: Date
): Promise<FinanceMetricsView> {
  const subscriptions = await stripe.subscriptions.list({
    created: { gte: Math.floor(startDate.getTime() / 1000) },
    limit: 100,
  })

  let totalMRR = 0
  let activeSubscriptions = 0
  let churnedSubscriptions = 0
  let newMRR = 0

  for (const subscription of subscriptions.data) {
    const mrr = (subscription.items.data[0]?.price.unit_amount || 0) / 100

    if (subscription.status === 'active') {
      totalMRR += mrr
      activeSubscriptions++
    } else if (subscription.status === 'canceled') {
      churnedSubscriptions++
    }

    if (subscription.created >= Math.floor(startDate.getTime() / 1000)) {
      newMRR += mrr
    }
  }

  const churnRate = activeSubscriptions > 0
    ? (churnedSubscriptions / (activeSubscriptions + churnedSubscriptions)) * 100
    : 0

  return {
    total_mrr: totalMRR,
    active_subscriptions: activeSubscriptions,
    churned_subscriptions: churnedSubscriptions,
    new_mrr: newMRR,
    churn_rate: churnRate,
    revenue_trend: newMRR > totalMRR * 0.1 ? 'up' : 'stable',
  }
}

2. MCP Tool:

{
  name: 'get_finance_metrics',
  description: 'Get aggregated finance metrics (no PII). Returns MRR, subscription counts, churn rate, and revenue trends.',
  inputSchema: {
    type: 'object',
    properties: {
      start_date: { type: 'string', description: 'Start date (ISO format)' },
      end_date: { type: 'string', description: 'End date (ISO format)' }
    },
    required: ['start_date', 'end_date']
  }
}

3. Tool Handler:

async function getFinanceMetrics(startDate: string, endDate: string) {
  const start = new Date(startDate)
  const end = new Date(endDate)

  return await getFinanceMetricsView(start, end)
}

Result: Finance agents get insights without accessing individual customer data or payment details.

Example 3: Support Agent MCP Server

Requirements:

  • Support agents answer billing questions
  • Access subscription and invoice data
  • Cannot access payment methods
  • Cannot access refund details

Implementation:

1. Sandboxed View:

interface SupportBillingView {
  customer_id: string
  email: string
  subscription_status: string
  current_invoice: {
    invoice_id: string
    amount: number
    status: string
    due_date: string
  }
  recent_invoices: Array<{
    invoice_id: string
    amount: number
    status: string
    paid_date?: string
  }>
}

async function getSupportBillingView(customerId: string): Promise<SupportBillingView> {
  const [customer, subscriptions, invoices] = await Promise.all([
    stripe.customers.retrieve(customerId),
    stripe.subscriptions.list({ customer: customerId, limit: 1 }),
    stripe.invoices.list({ customer: customerId, limit: 5 }),
  ])

  const subscription = subscriptions.data[0]
  const currentInvoice = subscription?.latest_invoice
    ? await stripe.invoices.retrieve(subscription.latest_invoice as string)
    : null

  return {
    customer_id: customer.id,
    email: customer.email || '',
    subscription_status: subscription?.status || 'none',
    current_invoice: currentInvoice
      ? {
          invoice_id: currentInvoice.id,
          amount: currentInvoice.amount_due / 100,
          status: currentInvoice.status || 'draft',
          due_date: currentInvoice.due_date
            ? new Date(currentInvoice.due_date * 1000).toISOString()
            : '',
        }
      : {
          invoice_id: '',
          amount: 0,
          status: 'none',
          due_date: '',
        },
    recent_invoices: invoices.data.map(invoice => ({
      invoice_id: invoice.id,
      amount: invoice.amount_paid / 100,
      status: invoice.status || 'draft',
      paid_date: invoice.status_transitions.paid_at
        ? new Date(invoice.status_transitions.paid_at * 1000).toISOString()
        : undefined,
    })),
  }
}

2. MCP Tool:

{
  name: 'get_support_billing_data',
  description: 'Get billing data for support context. Returns subscription status, current invoice, and recent invoices.',
  inputSchema: {
    type: 'object',
    properties: {
      customer_id: { type: 'string', description: 'Stripe customer ID' }
    },
    required: ['customer_id']
  }
}

3. Tool Handler:

async function getSupportBillingData(customerId: string) {
  if (!validateCustomerId(customerId)) {
    throw new Error('Invalid customer ID format')
  }

  return await getSupportBillingView(customerId)
}

Result: Support agents get complete billing context without accessing payment methods or refund details.


Stripe-Specific Best Practices

Here are best practices specific to Stripe:

1. Use Caching Aggressively

Stripe API calls cost money and hit rate limits. Cache aggressively:

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 60000, // 1 minute
})

async function getCachedData<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  const cached = cache.get(key)
  if (cached) return cached as T

  const data = await fetcher()
  cache.set(key, data)
  return data
}

Why: Caching reduces API calls, saves money, and prevents rate limit exhaustion.

2. Never Expose API Keys

API keys should only exist in your MCP server, never in agent code or client-side applications.

Why: API keys give full access to Stripe's API. Exposing them gives unlimited access.

3. Use Webhooks to Update Cache

Stripe webhooks can update your cache when data changes:

// Webhook handler
app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['stripe-signature'],
    process.env.STRIPE_WEBHOOK_SECRET!
  )

  if (event.type === 'customer.subscription.updated') {
    const subscription = event.data.object
    // Invalidate cache
    cache.delete(`customer:${subscription.customer}`)
  }

  res.json({ received: true })
})

Why: Webhooks keep cache fresh without polling, reducing API calls.

4. Batch API Calls

When possible, batch multiple API calls:

async function getMultipleCustomers(customerIds: string[]) {
  // Batch retrieve (Stripe supports up to 100 IDs)
  const customers = await Promise.all(
    customerIds.map(id => stripe.customers.retrieve(id))
  )
  return customers
}

Why: Batching reduces API calls and improves performance.

5. Monitor API Usage

Track Stripe API usage to prevent cost overruns:

  • API calls: Track number of calls per day
  • Rate limits: Monitor rate limit usage
  • Costs: Track Stripe API costs
  • Errors: Track API errors and retries

Why: Stripe API costs scale with usage. Monitoring helps you optimize costs and prevent surprises.

6. Use Test Mode for Development

Always use test mode (sk_test_) for development:

const stripe = new Stripe(
  process.env.NODE_ENV === 'production'
    ? process.env.STRIPE_SECRET_KEY_LIVE!
    : process.env.STRIPE_SECRET_KEY_TEST!
)

Why: Test mode prevents accidental charges and data corruption in production.

7. Handle Rate Limits Gracefully

Stripe's SDK handles rate limits automatically, but you should handle them explicitly:

async function makeStripeRequestWithRetry<T>(
  operation: () => Promise<T>,
  maxRetries: number = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation()
    } catch (error: any) {
      if (error.type === 'StripeRateLimitError' && i < maxRetries - 1) {
        await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
        continue
      }
      throw error
    }
  }
  throw new Error('Max retries exceeded')
}

Why: Graceful rate limit handling prevents integration failures.


Common Mistakes to Avoid

Here are mistakes I've seen teams make:

Mistake 1: Exposing API Keys to Agents

What happens: Teams give agents API keys directly, thinking it's necessary for queries.

Why it's a problem: API keys give full access to Stripe's API, including write operations.

The fix: Use API keys only in your MCP server. Agents never see them.

Mistake 2: Not Using Caching

What happens: Every agent query makes fresh API calls to Stripe.

Why it's a problem: Expensive API calls, rate limit exhaustion, slow performance.

The fix: Implement aggressive caching. Cache frequently accessed data for 1-5 minutes.

Mistake 3: Exposing Sensitive Data

What happens: Agents can access payment methods, card numbers, and other sensitive data.

Why it's a problem: Compliance violations, security incidents, regulatory fines.

The fix: Filter sensitive data in views. Agents only see what they need.

Mistake 4: Not Validating Inputs

What happens: Tools accept any input without validation, creating invalid API calls.

Why it's a problem: Invalid API calls waste money and can cause errors.

The fix: Validate all inputs. Check formats, ranges, and types before making API calls.

Mistake 5: Ignoring Rate Limits

What happens: Agents make hundreds of API calls, exhausting rate limits.

Why it's a problem: Rate limit exhaustion breaks integrations and causes failures.

The fix: Implement rate limiting, caching, and request batching. Monitor API usage.

Mistake 6: Not Monitoring Costs

What happens: Teams deploy MCP server and don't monitor Stripe API costs.

Why it's a problem: Costs spike unexpectedly. Budget overruns.

The fix: Monitor API usage, costs, and rate limits from day one. Set up alerts for anomalies.

Mistake 7: Static Tool Definitions

What happens: Tools are created once and never updated.

Why it's a problem: As agents evolve, tools become outdated. Over-permissive or under-permissive access.

The fix: Review and update tools regularly. Remove unused tools, optimize existing tools, add new tools as needed.


Where Pylar Fits In

Pylar makes building a Stripe MCP server practical. Here's how:

Stripe Integration: Pylar connects directly to Stripe. You provide your Stripe API key, and Pylar handles API calls, rate limiting, caching, and error handling. No infrastructure to manage.

Sandboxed Views: Pylar's SQL-like interface lets you create sandboxed views that define exactly what agents can access. Views can filter sensitive data, transform Stripe objects, and enforce compliance requirements. Views are version-controlled and auditable.

MCP Tool Builder: Pylar automatically generates MCP tools from your views. Describe what you want in natural language, and Pylar creates the tool definition, parameter validation, and query logic. No backend engineering required.

API Optimization: Pylar optimizes Stripe API usage. Caching, request batching, and rate limit management are built in. You don't have to worry about API costs or rate limits.

Data Filtering: Pylar views filter sensitive data automatically. Payment methods, card numbers, and other sensitive fields are excluded by default. Agents only see what they need.

Access Control: Pylar enforces access control on every query. Before executing a query, Pylar validates role, context, and permissions. Queries that violate access boundaries are rejected.

Monitoring: Pylar Evals tracks API usage, costs, and access patterns. You can see exactly how agents are using your Stripe layer, which queries are most expensive, and where optimization opportunities exist.

Framework-Agnostic: Pylar tools work with any MCP-compatible framework. Whether you're using Claude Desktop, LangGraph, OpenAI, n8n, or any other framework, Pylar provides the same safe layer.

Pylar is the Stripe MCP server you don't have to build. Instead of building custom API management, view filtering, and tool generation, you build views and tools in Pylar. The MCP server is built in.


Frequently Asked Questions

Do I need a separate Stripe account for agents?

Can I use webhooks with agents?

How do I handle write operations?

What if I need real-time data?

How do I optimize API usage?

Can I use views with existing Stripe setup?

How do I test the MCP server?

What if I need to update a view?

How do I monitor the MCP server?

Can I use this pattern with other payment processors?


Building a Stripe MCP server isn't optional—it's essential. Without it, agents can make unauthorized API calls, expose sensitive payment data, and create security risks. Start with API key isolation, add sandboxed views, create tools, and monitor continuously.

The three-layer pattern gives you the foundation. Isolate agent queries with API key management, govern access through views, and provide agent-friendly interfaces through tools. With proper implementation, agents become secure, performant business tools rather than Stripe risks.

Building a Stripe MCP Server for Finance and Revenue Ops Agents