How to Charge for an MCP Server: Two Billing Rails
Short answer: the Model Context Protocol has no concept of money in it, so you charge for an MCP server at the HTTP layer underneath the protocol, not inside it. Two rails work today.
- Prepaid credits. The caller buys a balance (Stripe Checkout), gets an API key, sends it as
Authorization: Bearer <key>, and you debit per tool call. Good when a human is buying on behalf of an agent. - x402. An unpaid call gets an HTTP
402 Payment Requiredwith a machine-readable price. The caller pays in stablecoins, retries with proof, and gets the result. No signup, no key. Good when the buyer is an agent with a wallet and no human in the loop.
Everything below is what we learned building both on one Cloudflare Worker (render.skillforge99.com), which has taken real Stripe payments in production. The x402 rail there is built and tested but has not settled a real payment against a live facilitator, and we say so in the section where it matters rather than at the end.
Does the MCP spec support payments?
No, and it is worth being precise about this, because it explains why every paid MCP server looks slightly hand-rolled.
The current MCP specification revision is 2026-07-28. Its authorization section defines exactly one mechanism: the MCP server acts as an OAuth 2.1 resource server, clients send Authorization: Bearer <access-token> on every HTTP request, and an unauthorized request gets a 401 with a WWW-Authenticate header pointing at the server's Protected Resource Metadata document (RFC 9728) so the client can discover the authorization server.
Its error-handling table has three rows: 401 unauthorized, 403 forbidden (invalid scopes or insufficient permissions), 400 malformed request. There is no 402, no price field, no balance concept, and no mention of payment anywhere in the authorization spec. Authorization itself is OPTIONAL.
Two consequences that shape the design:
- "You are out of credit" has no protocol-native representation. You either return an HTTP
402at the transport layer (outside what the spec describes, though nothing forbids it) or a JSON-RPC error inside a200. We do both: REST callers get a real402 insufficient_credit, and MCPtools/callgets an error result the model can read. - An API key in the
Authorizationheader is a deliberate deviation, not compliance. The spec's flow is OAuth 2.1 with discovery and PKCE. ABearer sfr_...static key is far simpler to buy and paste, and it is what almost every paid MCP server ships. Be honest with yourself about which one you built.
Rail one: prepaid credits
The whole rail is a hashed key, an integer balance, and a strict ordering rule.
Keys are stored as key:<sha256> so a leaked datastore does not leak usable credentials, and authorization rejects in a fixed order with a distinct status for each reason:
// src/lib/credits.js
async function authorize(kv, apiKey) {
if (!/^mcpk_[0-9a-f]{40}$/.test(apiKey || '')) {
return { ok: false, status: 401, code: 'invalid_key' };
}
const record = await loadRecord(kv, apiKey);
if (!record) return { ok: false, status: 401, code: 'invalid_key' };
if (record.disabled) return { ok: false, status: 403, code: 'key_disabled' };
if (record.balance <= 0) return { ok: false, status: 402, code: 'insufficient_credit' };
return { ok: true, record };
}
The ordering rule that matters: debit before the work, refund if the work fails. Checking the balance first and debiting after leaves a race where two concurrent calls both read "enough balance" and both proceed.
// src/lib/pipeline.js, billedWork
const d = await debit(env.CREDITS_KV, apiKey, units);
if (!d.ok) return { ok: false, status: d.status, code: d.code };
let result;
try {
result = await workFn(env, v.params);
} catch (err) {
// Refund on failure: never keep units for work we didn't deliver.
try { await credit(env.CREDITS_KV, apiKey, units); } catch { /* best-effort */ }
return { ok: false, status: 502, code: 'work_failed', message: 'Work failed; credits were not consumed' };
}
The refund sits in its own try/catch on purpose. If the refund fails, that failure must not replace the original error the caller needs to see. A buyer charged for a failed call can be made whole by support; a swallowed root-cause error costs you an afternoon.
Webhook idempotency, or you will double-credit someone
Stripe delivers webhooks at least once, not exactly once. Its own docs are explicit: Stripe retries failed deliveries for up to three days with exponential backoff in live mode, endpoints "might occasionally receive the same event more than once," and the recommended guard is to log the event IDs you have already processed. Every retry also carries a fresh signature and timestamp, so signature verification will not save you here.
If your handler credits a balance every time it sees checkout.session.completed, one retried delivery credits the buyer twice for one payment. Dedupe on the event's own id:
// src/index.js, webhookHandler
const dedupKey = event.id ? `wh:${event.id}` : null;
if (dedupKey && (await env.CREDITS_KV.get(dedupKey))) {
return json(200, { received: true, duplicate: true });
}
// ... credit the balance ...
if (dedupKey) {
await env.CREDITS_KV.put(dedupKey, '1', { expirationTtl: 86400 * 3 });
}
Three details are load-bearing:
- The dedup key is written after processing. Write it first and a handler that throws partway through turns a legitimate retry into a silent no-op.
- A duplicate returns
200, not an error. A4xxor5xxtells Stripe to keep retrying, which is the opposite of what you want. - The TTL (3 days) matches Stripe's own live-mode retry window, so a very late retry still gets caught instead of double-crediting after the key expired.
Dedupe on the id the sender guarantees is stable, never on a payload hash. The moment Stripe adds a field, your derived key changes and the guard silently stops working.
The tax_code 400 nobody warns you about
This one only appears when you try to create your first live Checkout session.
If your sessions use dynamic price_data (price computed at request time from a pack size, rather than a pre-registered Price object) and your account has Managed Payments enabled, Stripe needs a tax_code on the line item to compute sales tax. A dynamic line item has no Product object to inherit one from, so without an explicit code, session creation fails with a 400 before the buyer sees a checkout page.
// src/lib/stripe.js, createTopupSession
'line_items[0][price_data][product_data][tax_code]': 'txcd_10103001',
txcd_10103001 is Stripe's code for "Software as a service (SaaS) - business use" in its public product tax code list, which is the closest fit for metered API credits sold to businesses. It is not automatically the right code for whatever you are selling, and Stripe's own guidance is to split into two products with two codes if your product plausibly spans business and personal use. Look yours up rather than copying ours.
Rail two: x402, and the ordering flips
x402 inverts the money ordering, because the payment is irreversible and happens at the end instead of the beginning. The sequence is verify, then work, then settle:
// src/lib/pipeline.js, x402Work
const verified = await verifyPayment(fetchFn, env, paymentHeader, 'echo', resourceUrl);
if (!verified.ok) return verified;
let result;
try {
result = await workFn(env, v.params);
} catch (err) {
// No payment consumed: settlePayment() is never called on this path.
return { ok: false, status: 502, code: 'work_failed', message: 'Work failed; no payment was consumed' };
}
const settled = await settlePayment(fetchFn, env, verified);
if (!settled.ok) {
// Hand back the result the caller already earned; flag settlement failure.
return { ok: true, status: 200, bytes, contentType, settleFailed: true };
}
Work fails, settle is never called, no money moves. Work succeeds but settle fails (facilitator timeout), and the caller still gets the result, with the failure surfaced in an x-payment-status: settle_failed header. That is a business posture, not a technical necessity: we would rather occasionally eat a fraction of a cent than occasionally take an agent's money and return nothing. Pick your posture deliberately instead of inheriting whichever order was easier to code.
Check the header names against your facilitator before you write them down. The reference implementation at github.com/coinbase/x402 currently documents PAYMENT-REQUIRED, PAYMENT-SIGNATURE, and PAYMENT-RESPONSE, while the older shape our implementation targets uses X-PAYMENT and X-PAYMENT-RESPONSE with facilitator paths /verify and /settle. The legacy types are still re-exported by the reference SDK for legacy facilitator support, so both exist in the wild. This is a protocol still visibly in motion, and the two shapes are not interchangeable.
Our own honest state on this rail: it returns a well-formed 402 challenge with a real receiving address from the live worker, its verify and settle paths are covered by tests against a mocked facilitator, and it has never processed one real settled payment. We would not call it done to a customer, and we do not ship it as the only payment path.
Free trials get abused within days
A trial-key endpoint is a standing offer to anyone who can send an HTTP request. Two independent caps, in this order:
// src/index.js, keysHandler
const globalCap = await dailyCheck(env.CREDITS_KV, 50, now, 'trialmint');
if (!globalCap.ok) return error(429, 'trial_limit', 'Trial key minting limit reached for today');
const perIp = await dailyCheck(env.CREDITS_KV, 1, now, 'trialip', ipHash);
if (!perIp.ok) return error(429, 'trial_limit', 'A trial key was already claimed from this IP today');
The global cap bounds your total giveaway when many IPs each mint once. The per-IP cap stops one IP minting repeatedly. Global first, because if the per-IP counter incremented and then the global cap rejected, that IP would have burned its daily quota on a key it never received.
Both use an atomic increment-then-check against a day-windowed key, not check-then-write. Check-then-write has a race: two concurrent requests read the counter before either write lands, and both pass. Reordering closes it without a lock.
The gateway that puts your key in the query string
Worth knowing before you list on an MCP directory. The spec is unambiguous that "access tokens MUST NOT be included in the URI query string." But gateways that proxy MCP connections may hand your key over however they like, and at least one does exactly the forbidden thing: Smithery's gateway takes the key a user types into its config field and appends it to the upstream URL as https://render.skillforge99.com/mcp?apiKey=<value>, with no Authorization header at all.
We found this from a screenshot of the gateway's own connection settings, after shipping a listing that would have failed auth for every gateway user. Our fix was to accept ?apiKey= on the /mcp route only, with the header still winning when both are present, and to leave the REST /v1/* routes header-only. That is a real weakening of a real rule, scoped as narrowly as we could make it, and we would rather write it down than pretend the tradeoff was free. If you plan to be reachable through gateways, decide this consciously.
FAQ
Can you charge per tool call in MCP? Yes, but not with anything the protocol provides. You meter at the HTTP transport layer: authenticate the request, price the tool by name, debit, then run it. The model never sees the accounting, only a successful result or an error telling it credit ran out.
Do MCP clients handle a 402 response? Do not count on it. A plain HTTP 402 from a tools/call is not something the spec describes, so client behavior varies from a clean error surface to a confusing transport failure. For prepaid credits, return a normal MCP error result whose text explains that the balance is empty and where to top up, so the model can relay something useful. Reserve raw 402 for REST callers and x402 clients that expect it.
OAuth or a static API key? OAuth 2.1 with Protected Resource Metadata is what the spec asks for and what you need if you are authorizing per-user access to someone else's data. A static prepaid key is far less work and far easier to buy, and it is the right call when the thing you are selling is metered compute rather than access to an account. Do not describe the second one as spec-compliant authorization.
What is the cheapest way to accept payments for an MCP server? Stripe Checkout plus a key-and-balance table on any KV store gets you paid without a subscription platform, and hosting the whole thing as a single Cloudflare Worker starts at $5 per month on Cloudflare Workers Paid. The expensive part is never the payment processor; it is the money-path bugs above, each of which costs either real dollars or real trust.
Should I build x402 today? Build the account-based rail first, because it works with human buyers who already exist. Add x402 when agent traffic is real for you, and do not represent it as production-ready until one real payment has settled against a live facilitator.
The pattern underneath all of it
Every rule here is the same rule wearing different clothes: money moves exactly once, at the moment you are certain the work did or did not happen, and when something in between goes wrong, the ambiguity resolves in the buyer's favor. Fail closed on every missing config value, dedupe anything another system can deliver twice, and write down the deviations you chose on purpose so the next person does not have to rediscover them from a support ticket.
Facts in this article were verified on 2026-08-17 against the MCP specification revision 2026-07-28, Stripe's webhook and tax-code documentation, and the coinbase/x402 reference repository.