「新しいプランを追加するたびに、Stripe の課金ロジックを毎回書き直している」——そう気づいたのは、3 本目のサブスクリプション SaaS を Antigravity で開発しているときでした。コード生成は AI が担ってくれるのに、課金処理の設計だけは毎回私が細かく指示を書かなければならなかったのです。
MCP(Model Context Protocol)サーバーを Stripe API のラッパーとして実装すれば、この問題が根本から解決します。Antigravity のエージェントが Stripe の各操作を「ツール」として呼び出せるようになり、「このユーザーを Pro プランに移行するコードを書いて」と一言指示するだけで、Customer 取得・Subscription 更新・Webhook 登録まで一連の処理を自動で組み立ててくれます。
ここでは実際に本番 SaaS に導入した Stripe MCP サーバーの設計と実装を、動作確認済みのコードとともに公開します。
MCP × Stripe の設計思想を整理する
MCP サーバーは、Antigravity のエージェントが外部 API を「ツール」として呼び出すためのプロキシ層です。Stripe との連携では、3 つの層で考えると設計がシンプルになります。
**エージェント層(Antigravity)**は自然言語の指示を受け取り、どの Stripe 操作が必要かを判断します。MCP サーバー層はエージェントの指示を型安全な Stripe SDK 呼び出しに変換します。Stripe API 層が実際の課金処理を実行し、結果を返します。
この設計の最大の利点は、エージェントが Stripe の API 仕様の細部を「知らなくていい」ことです。MCP サーバーが引数のバリデーションと型変換を担うため、エージェントが不正なリクエストを生成しても、サーバー側でエラーとして処理できます。
設計で特に重要なのが、「読み取り専用操作」と「副作用のある操作(ミューテーション)」の明確な分離です。Antigravity のエージェントは、指示の解釈次第で意図せずサブスクリプションを削除したり顧客情報を更新したりすることがあります。ツールを読み取り系とミューテーション系に分け、ミューテーション側には確認ステップを設けることで、本番環境での誤実行リスクを大幅に下げられます。
開発環境の構築
必要なパッケージをインストールします。
mkdir stripe-mcp-server && cd stripe-mcp-server
npm init -y
npm install @anthropic-ai/mcp-sdk stripe zod
npm install -D typescript ts-node @types/nodetsconfig.json を作成します。
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}環境変数を設定します。本番環境では Cloudflare Workers Secrets や AWS Secrets Manager を使い、ファイルシステムに直接書かないことが原則です。
# .env(ローカル開発用のみ)
STRIPE_SECRET_KEY=sk_test_YOUR_TEST_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
MCP_AUTH_TOKEN=your_long_random_auth_token
PORT=3000MCPサーバーのコア実装
src/server.ts を作成します。起動時のキー検証から読み取り・ミューテーションのツール定義まで、一通りの骨格を示します。
import { MCPServer, Tool, ToolResult } from '@anthropic-ai/mcp-sdk';
import Stripe from 'stripe';
// 起動時チェック:本番環境にテストキーが混入するのを防ぐ
function validateStripeKey(): void {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error('STRIPE_SECRET_KEY が設定されていません');
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction && key.startsWith('sk_test_')) {
throw new Error('本番環境にテストキーが設定されています。デプロイを中止してください。');
}
if (!key.startsWith('sk_live_') && !key.startsWith('sk_test_')) {
throw new Error('STRIPE_SECRET_KEY の形式が不正です(sk_live_ または sk_test_ で始まる必要があります)');
}
}
validateStripeKey();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
// --- 読み取り専用ツール ---
const getCustomerTool: Tool = {
name: 'get_stripe_customer',
description: '顧客情報をメールアドレスまたは顧客IDで取得します(読み取り専用)。',
inputSchema: {
type: 'object',
properties: {
email: { type: 'string', description: '顧客のメールアドレス' },
customerId: { type: 'string', description: 'Stripe の顧客ID(cus_xxx形式)' },
},
},
};
const listSubscriptionsTool: Tool = {
name: 'list_customer_subscriptions',
description: '顧客のサブスクリプション一覧を取得します(読み取り専用)。',
inputSchema: {
type: 'object',
required: ['customerId'],
properties: {
customerId: { type: 'string', description: 'Stripe の顧客ID' },
status: {
type: 'string',
enum: ['active', 'past_due', 'canceled', 'trialing', 'all'],
description: 'フィルターするステータス(省略時は active のみ)',
},
},
},
};
// --- ミューテーションツール ---
const createSubscriptionTool: Tool = {
name: 'create_stripe_subscription',
description: '顧客の新しいサブスクリプションを作成します。この操作は課金を開始します。',
inputSchema: {
type: 'object',
required: ['customerId', 'priceId'],
properties: {
customerId: { type: 'string', description: 'Stripe の顧客ID' },
priceId: { type: 'string', description: 'Stripe の価格ID(price_xxx形式)' },
trialDays: { type: 'number', description: 'トライアル日数(省略時はトライアルなし)' },
},
},
};
const cancelSubscriptionTool: Tool = {
name: 'cancel_stripe_subscription',
description: 'サブスクリプションをキャンセルします。at_period_end を true にすると期間終了時にキャンセルされます。',
inputSchema: {
type: 'object',
required: ['subscriptionId'],
properties: {
subscriptionId: { type: 'string', description: 'Stripe のサブスクリプションID(sub_xxx形式)' },
atPeriodEnd: {
type: 'boolean',
description: 'true: 期間終了時にキャンセル(推奨)/ false: 即時キャンセル',
},
},
},
};
async function handleGetCustomer(input: {
email?: string;
customerId?: string;
}): Promise<ToolResult> {
try {
if (input.customerId) {
const customer = await stripe.customers.retrieve(input.customerId);
if (customer.deleted) {
return { content: [{ type: 'text', text: 'この顧客は削除済みです。' }] };
}
return {
content: [{
type: 'text',
text: JSON.stringify({
id: customer.id,
email: customer.email,
name: customer.name,
created: new Date(customer.created * 1000).toISOString(),
metadata: customer.metadata,
}, null, 2),
}],
};
}
if (input.email) {
const customers = await stripe.customers.list({ email: input.email, limit: 5 });
if (customers.data.length === 0) {
return {
content: [{ type: 'text', text: `メールアドレス "${input.email}" の顧客は見つかりませんでした。` }],
};
}
return {
content: [{
type: 'text',
text: JSON.stringify(
customers.data.map(c => ({
id: c.id,
email: c.email,
name: c.name,
created: new Date(c.created * 1000).toISOString(),
})),
null, 2
),
}],
};
}
return {
content: [{ type: 'text', text: 'email または customerId のいずれかを指定してください。' }],
isError: true,
};
} catch (error) {
const stripeError = error as Stripe.errors.StripeError;
return {
content: [{ type: 'text', text: `Stripe API エラー [${stripeError.code}]: ${stripeError.message}` }],
isError: true,
};
}
}
async function handleCreateSubscription(input: {
customerId: string;
priceId: string;
trialDays?: number;
}): Promise<ToolResult> {
try {
const params: Stripe.SubscriptionCreateParams = {
customer: input.customerId,
items: [{ price: input.priceId }],
// SCA 対応:incomplete 状態で作成し、フロントエンドで支払いを確定させる
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
};
if (input.trialDays && input.trialDays > 0) {
params.trial_period_days = input.trialDays;
}
const subscription = await stripe.subscriptions.create(params);
const latestInvoice = subscription.latest_invoice as Stripe.Invoice & {
payment_intent?: Stripe.PaymentIntent;
};
return {
content: [{
type: 'text',
text: JSON.stringify({
subscriptionId: subscription.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
// フロントエンドで Stripe.js に渡す client_secret
clientSecret: latestInvoice?.payment_intent?.client_secret ?? null,
}, null, 2),
}],
};
} catch (error) {
const stripeError = error as Stripe.errors.StripeError;
if (stripeError.code === 'resource_missing') {
return {
content: [{
type: 'text',
text: `リソースが見つかりません。customerId と priceId の値を確認してください。詳細: ${stripeError.message}`,
}],
isError: true,
};
}
return {
content: [{ type: 'text', text: `サブスクリプション作成エラー: ${stripeError.message}` }],
isError: true,
};
}
}
async function handleCancelSubscription(input: {
subscriptionId: string;
atPeriodEnd?: boolean;
}): Promise<ToolResult> {
try {
const subscription = await stripe.subscriptions.update(input.subscriptionId, {
cancel_at_period_end: input.atPeriodEnd ?? true,
});
return {
content: [{
type: 'text',
text: JSON.stringify({
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
cancelAt: subscription.cancel_at
? new Date(subscription.cancel_at * 1000).toISOString()
: null,
}, null, 2),
}],
};
} catch (error) {
const stripeError = error as Stripe.errors.StripeError;
return {
content: [{ type: 'text', text: `キャンセルエラー: ${stripeError.message}` }],
isError: true,
};
}
}
async function handleListSubscriptions(input: {
customerId: string;
status?: 'active' | 'past_due' | 'canceled' | 'trialing' | 'all';
}): Promise<ToolResult> {
try {
const params: Stripe.SubscriptionListParams = {
customer: input.customerId,
limit: 10,
expand: ['data.items'],
};
if (input.status && input.status !== 'all') {
params.status = input.status;
}
const subscriptions = await stripe.subscriptions.list(params);
return {
content: [{
type: 'text',
text: JSON.stringify(
subscriptions.data.map(sub => ({
id: sub.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: sub.cancel_at_period_end,
priceIds: sub.items.data.map(item => item.price.id),
})),
null, 2
),
}],
};
} catch (error) {
const stripeError = error as Stripe.errors.StripeError;
return {
content: [{ type: 'text', text: `サブスクリプション一覧取得エラー: ${stripeError.message}` }],
isError: true,
};
}
}
const server = new MCPServer({
name: 'stripe-billing-mcp',
version: '1.0.0',
tools: [
getCustomerTool,
listSubscriptionsTool,
createSubscriptionTool,
cancelSubscriptionTool,
],
onToolCall: async (name, input) => {
switch (name) {
case 'get_stripe_customer':
return handleGetCustomer(input as any);
case 'list_customer_subscriptions':
return handleListSubscriptions(input as any);
case 'create_stripe_subscription':
return handleCreateSubscription(input as any);
case 'cancel_stripe_subscription':
return handleCancelSubscription(input as any);
default:
return {
content: [{ type: 'text', text: `未知のツール: ${name}` }],
isError: true,
};
}
},
});
server.start({ port: parseInt(process.env.PORT ?? '3000') });
console.log(`✅ Stripe MCP Server 起動(ポート: ${process.env.PORT ?? '3000'})`);payment_behavior: 'default_incomplete' が重要なポイントです。SCA(強力な顧客認証)に対応するため、サブスクリプションを incomplete 状態で作成し、フロントエンドで client_secret を Stripe.js に渡して支払いを確定させます。この設定を省略すると、EU ユーザーで決済が失敗するケースが出てきます。
Antigravity から接続する
Antigravity の設定ファイル(.antigravity/settings.json)に MCP サーバーを追加します。
{
"mcpServers": {
"stripe-billing": {
"url": "http://localhost:3000/mcp",
"description": "Stripe課金処理ツール — 顧客情報取得・サブスクリプション管理",
"headers": {
"Authorization": "Bearer YOUR_MCP_AUTH_TOKEN"
}
}
}
}設定後、Antigravity のチャットで次のように指示できます。
「user@example.com の顧客情報を取得して、price_Pro_Monthly プランに
14日間のトライアル付きでサブスクリプションを作成するコードを書いて」
エージェントは get_stripe_customer で顧客を検索し、create_stripe_subscription で作成する一連のコードを自動で組み立てます。API リファレンスを自分で調べながら書いていたときと比べると、体感で 5 倍以上のスピードで課金ロジックを実装できるようになります。
Webhook処理をセキュアに実装する
Stripe の Webhook は、課金イベント(支払い失敗・サブスクリプション更新など)をリアルタイムで受け取る仕組みです。署名検証を正しく実装しないと、外部からの偽リクエストを受け入れてしまいます。
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
// Webhook エンドポイント:express.json() より前に定義することが必須
// Stripe の署名検証には生のリクエストボディが必要なため
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
if (!signature) {
return res.status(400).json({ error: 'Missing stripe-signature header' });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook 署名検証失敗:', (error as Error).message);
return res.status(400).json({ error: 'Webhook signature verification failed' });
}
// 先に 200 を返す。重い処理をここで行うと Stripe の 30 秒タイムアウトで再送される
res.status(200).json({ received: true });
try {
await processWebhookEvent(event);
} catch (err) {
// 処理エラーはログに残すが、再送を引き起こさないため 200 は維持
console.error(`Webhook イベント処理エラー (${event.type}):`, err);
}
}
);
async function processWebhookEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
await updateUserSubscriptionStatus({
stripeCustomerId: customerId,
subscriptionId: sub.id,
status: sub.status,
currentPeriodEnd: sub.current_period_end,
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
await revokeUserAccess(customerId);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = typeof invoice.customer === 'string'
? invoice.customer
: (invoice.customer?.id ?? '');
await notifyPaymentFailure(customerId, invoice.amount_due);
break;
}
default:
console.log(`未処理の Webhook イベント: ${event.type}`);
}
}
// 以下は実際のDBスタック(Supabase / Firebase / Drizzle ORM等)に合わせて実装する
async function updateUserSubscriptionStatus(params: {
stripeCustomerId: string;
subscriptionId: string;
status: Stripe.Subscription.Status;
currentPeriodEnd: number;
}): Promise<void> {
console.log('サブスクリプション状態を更新:', params);
}
async function revokeUserAccess(stripeCustomerId: string): Promise<void> {
console.log('アクセス権限を取り消し:', stripeCustomerId);
}
async function notifyPaymentFailure(customerId: string, amountDue: number): Promise<void> {
console.log('支払い失敗通知:', { customerId, amountDue });
}セキュリティ設計 — APIキー管理とリクエスト認証
MCP サーバーに Stripe の本番 API キーを渡す場合、セキュリティ設計が重要です。実際に採用している設計を 3 点にまとめます。
まずAPI キーのスコープを最小化します。Stripe ダッシュボードの「開発者」→「API キー」→「制限付きキーを作成」から、サーバーが必要とする操作のみを許可した制限付きキーを発行します。顧客の読み取りとサブスクリプションの作成・更新だけを許可し、返金やプロダクト作成などの権限は付与しません。
次にMCP サーバー自体に Bearer トークン認証を設けます。
import type { Request, Response, NextFunction } from 'express';
function requireAuth(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized: Bearer token required' });
return;
}
const token = authHeader.slice(7);
if (token !== process.env.MCP_AUTH_TOKEN) {
res.status(403).json({ error: 'Forbidden: Invalid token' });
return;
}
next();
}
// すべての MCP エンドポイントに認証を適用
app.use('/mcp', requireAuth);最後に、ミューテーション操作にレート制限を設けます。express-rate-limit を使い、サブスクリプション作成エンドポイントを 1 IP あたり毎時 20 回以内に制限することで、エージェントのバグや悪意のあるリクエストによる意図しない課金を防げます。
よくある落とし穴と対処法
Stripe MCP サーバーを本番に投入するまでに実際にはまった問題を 4 つ挙げます。
落とし穴 1: Webhook のボディパーサーの順序問題
express.json() ミドルウェアをルート全体に適用してから Webhook エンドポイントを定義すると、生のボディが失われて署名検証が必ず失敗します。Webhook エンドポイントは express.raw({ type: 'application/json' }) を個別に適用し、express.json() より前に定義することが必須です。これに気づかず 2 時間溶かした経験があります。
落とし穴 2: テストキーと本番キーの混在
開発中は sk_test_ キーを使い、本番デプロイ時だけ sk_live_ に切り替えるつもりが、環境変数の設定漏れで本番環境にテストキーが入ってしまうことがあります。冒頭のコードに含めた validateStripeKey() 関数を起動時に実行することで、この問題を事前に検出できます。
落とし穴 3: invoice.payment_succeeded と checkout.session.completed の二重処理
Checkout Session 経由でサブスクリプションを作成すると、checkout.session.completed と invoice.payment_succeeded の両方が発火します。両方のイベントで DB を更新する処理を書くと二重実行になります。サブスクリプション状態の更新は customer.subscription.updated に一本化し、checkout.session.completed ではセッション完了の記録のみを行う設計にすることで回避できます。
落とし穴 4: ローカルの Webhook テストで署名検証が失敗する
stripe listen --forward-to localhost:3000/webhook コマンドは、本番の Webhook シークレットとは別の一時的な whsec_ を発行します。.env.local にはローカルテスト用のシークレットを、本番環境変数には本番の Webhook シークレットを別々に設定する必要があります。同じ値を使い回すと、いずれかの環境で署名検証が失敗し続けます。
Cloudflare Workers へのデプロイ
Node.js ベースの実装を Cloudflare Workers に移行する場合、stripe.webhooks.constructEvent(同期版)が Workers 環境で動作しません。必ず constructEventAsync と Stripe.createSubtleCryptoProvider() を組み合わせてください。
// Cloudflare Workers 向け Webhook ハンドラ(Hono フレームワーク使用)
import { Hono } from 'hono';
import Stripe from 'stripe';
type Bindings = {
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post('/webhook', async (c) => {
const body = await c.req.text();
const signature = c.req.header('stripe-signature');
if (!signature) {
return c.json({ error: 'Missing stripe-signature' }, 400);
}
const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
});
let event: Stripe.Event;
try {
// Workers の Web Crypto API(SubtleCrypto)を使用するために必須
event = await stripe.webhooks.constructEventAsync(
body,
signature,
c.env.STRIPE_WEBHOOK_SECRET,
undefined,
Stripe.createSubtleCryptoProvider()
);
} catch (error) {
return c.json({ error: 'Webhook signature verification failed' }, 400);
}
// waitUntil でレスポンス返却後も非同期処理を継続
c.executionCtx.waitUntil(processWebhookEvent(event, stripe));
return c.json({ received: true });
});
export default app;Workers の crypto モジュールは Node.js のものと異なります。Stripe.createSubtleCryptoProvider() を渡すことで、Workers の Web Crypto API を使って署名検証を行います。この一行を忘れると crypto.createHmac is not a function というエラーが出て、すべての Webhook が 400 になります。
発展:マルチテナントと使用量課金への拡張
複数のテナント(企業顧客)を持つ SaaS では、テナント ID と Stripe Customer ID のマッピングを DB に保持し、MCP サーバーがテナントを意識した API を公開する設計が必要になります。
// テナント対応のサブスクリプション作成ハンドラ
async function handleCreateTenantSubscription(input: {
tenantId: string;
priceId: string;
quantity: number;
metadata?: Record<string, string>;
}): Promise<ToolResult> {
// テナントIDから Stripe Customer ID を取得(DB ルックアップ)
const customerId = await getStripeCustomerIdForTenant(input.tenantId);
if (!customerId) {
return {
content: [{
type: 'text',
text: `テナント "${input.tenantId}" の Stripe Customer が存在しません。先に顧客を作成してください。`,
}],
isError: true,
};
}
try {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: input.priceId, quantity: input.quantity }],
metadata: {
// Webhook 受信時にテナントを特定するために必須
tenantId: input.tenantId,
...input.metadata,
},
expand: ['latest_invoice.payment_intent'],
});
return {
content: [{
type: 'text',
text: JSON.stringify({
subscriptionId: subscription.id,
tenantId: input.tenantId,
seats: input.quantity,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
}, null, 2),
}],
};
} catch (error) {
const stripeError = error as Stripe.errors.StripeError;
return {
content: [{ type: 'text', text: `テナントサブスクリプション作成エラー: ${stripeError.message}` }],
isError: true,
};
}
}
// テナントIDから Stripe Customer ID を解決(DBスタックに依存)
async function getStripeCustomerIdForTenant(tenantId: string): Promise<string | null> {
// 例(Supabase の場合):
// const { data } = await supabase
// .from('tenants')
// .select('stripe_customer_id')
// .eq('id', tenantId)
// .single()
// return data?.stripe_customer_id ?? null
return null; // 実装者が置き換える
}metadata.tenantId を必ず設定しておくことが、後の Webhook 処理で正しいテナントを特定するために欠かせません。この値がないと、customer.subscription.updated を受け取っても「どのテナントの更新か」が分からなくなります。
使用量課金(Stripe Meter Events API)を追加する場合は、stripe.billing.meterEvents.create() を呼び出す report_usage ツールを MCP サーバーに追加します。エージェントが「このテナントの API コール数を報告して」と指示するだけで、Stripe への使用量レポートが自動化できます。
MCP サーバーを TypeScript でゼロから構築したい場合は、Antigravity カスタムMCPサーバー自作完全ガイドも参考になります。マルチベンダー型のマーケットプレイスに Stripe Connect を組み合わせる場合は、Antigravity × Stripe Connect マーケットプレイス収益化完全ガイドをあわせてお読みください。
今日から始める一歩
まずテスト環境で get_stripe_customer ツールだけを実装し、Antigravity から実際に呼び出してみてください。「user@example.com の顧客情報を取得して」という一言でエージェントが Stripe API を呼び出す体験をすると、MCP サーバーの可能性が一気に実感できます。
課金処理を AI に委ねることへの不安は、動くコードを小さな範囲で試すことで解消されていきます。読み取り専用ツールから始め、徐々にミューテーション操作を追加していく段階的なアプローチが、本番環境への信頼を最も速く積み上げる方法だと考えています。
一度 MCP サーバーとして整備した課金ロジックは、次のプロジェクトでもそのまま再利用できます。Antigravity で作るすべてのプロダクトで同じ課金基盤を共有できるようになるのが、この設計の最大の恩恵です。