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
- Understanding Stripe's API and Security Model
- Architecture: Three-Layer Pattern for Stripe
- Layer 1: API Key Isolation
- Layer 2: Sandboxed Data Views
- Layer 3: MCP Tool Builder
- Step-by-Step Implementation
- Real-World Examples
- Stripe-Specific Best Practices
- Common Mistakes to Avoid
- Where Pylar Fits In
- Frequently Asked Questions
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:
- Go to Developers → API keys
- Copy your
Secret key(keep it secret!) - 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:
- Create a Stripe account (or use existing)
- Get your API keys from Developers → API keys
- 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:
- Identify what data agents need
- Create view function that filters and transforms Stripe data
- Add caching for performance
- 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:
- Initialize Node.js project
- Install MCP SDK and Stripe SDK
- Create tool handlers
- Implement MCP server
- 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:
- Deploy server to hosting platform (Vercel, Railway, Fly.io)
- Configure environment variables
- Test server endpoint
- 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:
- Get MCP server URL
- Add to agent framework configuration
- Test agent can use tools
- 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:
- Set up API call logging
- Track API usage and costs
- Monitor rate limits
- 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.
Related Posts
How to Build MCP Tools Without Coding
You don't need to code to build MCP tools. This tactical guide shows three ways to create them—from manual coding to Pylar's natural language approach—and why the simplest method takes under 2 minutes.
Agent Cost Optimization: A Data Engineer's Guide
Agent costs can spiral out of control fast. This practical guide for data engineers shows where costs come from, how to measure them, and strategies to optimize costs by 50-70% without breaking functionality.
How to Build a Safe Agent Layer on Top of Postgres
Learn how to build a safe agent layer on top of Postgres. Three-layer architecture: read replica isolation, sandboxed views, and tool abstraction. Step-by-step implementation guide.