I spent a week on OAuth plumbing for an MCP server before writing
a single line of actual product logic.
Four hours in I was reading RFC 9728. Not one line of that code
made the server smarter or more useful. I shipped with a static
API key and moved on.
Eventually I went back and built it properly. And the part I
thought would be easy — Stripe billing — turned out to have three
edge cases that nobody documents anywhere.
This is about those three cases.
The setup
You've got an MCP server. Users call it. You want to charge per call.
The obvious path: POST to Stripe's API after each tool call, done.
Except it's not done.
Problem 1: Retries
A client makes a tool call. Your server processes it. Before the
response arrives, the client's network drops. The client retries.
You now have two requests for what is logically one call. Without
protection, you bill twice.
The fix is an idempotency key — a stable identifier for the
logical request, not the HTTP request. The client generates it
once and reuses it across retries. Your server enforces uniqueness
at the database level:
// POST /api/usage
const existing = await prisma.usageEvent.findUnique({
where: { idempotencyKey: body.idempotencyKey }
})
if (existing) {
return Response.json({ ok: true, deduplicated: true })
}
The key detail: uniqueness enforced by a DB constraint, not a
SELECT-before-INSERT. The race condition between two simultaneous
retries hitting the server at the same instant is closed by the
constraint, not by application logic.
The client side looks like this:
// In your MCP server — generate once per logical call
const idempotencyKey = `${mcpRequestId}-search_documents`
await fetch(`${APP_URL}/api/usage`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({
idempotencyKey,
endpoint: 'search_documents',
units: 1,
status: 'completed',
}),
})
Problem 2: Interrupted streaming responses
You're streaming a response. 3,000 tokens in, the user closes
the tab.
Do you bill for those 3,000 tokens? Or nothing?
There's no objectively correct answer — it depends on your cost
structure and what you've promised users. But you have to make
the decision explicitly, and you have to make it before you ship.
The approach I took: register usage once, when the stream finishes.
If it doesn't finish, send a partial status instead of skipping
the call entirely.
// At stream completion
await reportUsage({
idempotencyKey,
endpoint: 'generate_text',
units: totalTokensStreamed,
status: streamCompleted ? 'completed' : 'partial',
})
partial events are always recorded in Postgres for auditing.
Whether they're reported to Stripe is configurable:
// Default: only 'completed' goes to Stripe
const BILLABLE_STATUSES = ['completed']
// If you want to bill partial calls too:
// const BILLABLE_STATUSES = ['completed', 'partial']
The important thing isn't which option you pick — it's that you
pick one explicitly and document it, because your users will ask.
Problem 3: What "failed" means
Not every failed call should be treated the same way.
A tool call that fails because your server threw an unhandled
exception: probably shouldn't be billed.
A tool call that fails because the user's input was invalid
(and you processed it, returned a 400, and spent compute doing
so): maybe should be billed.
The status field handles this:
type UsageStatus = 'completed' | 'partial' | 'failed'
failed events are always written to Postgres — you want the
audit trail — but never reported to Stripe by default. If you
need to bill for certain failure modes, add them to
BILLABLE_STATUSES.
The failed events in Postgres are also useful for debugging.
If a specific endpoint has a high failure rate, you'll see it
in the usage table before it becomes a support ticket.
The thing Stripe's docs don't say
Stripe's usage-based billing documentation covers the happy path
well. It doesn't cover what to do when:
- The same event arrives twice (retries)
- The event never fully completes (streaming, disconnects)
- The event completes but represents a failure (errors)
These aren't edge cases in practice — they're normal operating
conditions for any MCP server with real users.
I ended up building all of this into a boilerplate
(MCP-Billing) along with OAuth 2.1,
API key management, and rate limiting. But the decisions above
are framework-agnostic — you can apply them to any Stripe
usage-based integration.
The main takeaway: make the decisions explicit, document them in
your README or changelog, and make BILLABLE_STATUSES configurable
from day one. You will change your mind about what's billable
as you learn more about your users' behavior.
Top comments (0)