Last updated 31 August 2026 · engineering facts quoted from the implementation
An MCP tool must never charge a card or accept a price as an argument. It authenticates the caller, re-reads the price from the live catalogue server-side, and hands back a hosted checkout URL the human opens. Money moves on a rail your server controls, not on one the agent describes to you.
Key takeaways
create_checkout(plan_id,
pay_with) re-reads the plan from the provider catalogue at order creation
and freezes it as a snapshot; Stripe's line item is computed from that snapshot,
never from an argument.accepted block. A
signature over terms the client wrote is still a valid signature.tools/call returns 401 plus
a WWW-Authenticate challenge.initialize and
tools/list bypass the auth middleware — because MCP directory
crawlers build their listings that way and a flat 401 makes the server
invisible.mark_paid that reports whether
this call applied the payment, and a provider idempotency key keyed to
the order.This is one server, in production, that sells something. eSIM Tabby is an MCP server with eleven tools covering search, purchase, activation, top-ups, usage and refunds, and it takes money on two rails: a hosted Stripe checkout a human opens, and USDC over x402 for an agent that holds a wallet. I wrote it, and every claim below is quoted from the implementation.
The MCP specification covers transport and authorisation. It has nothing to say about what happens when a tool call is worth $14.00 and the client retries it. That gap is where all of this lives.
Where code is quoted, the file is named relative to the Django project —
core/mcp_server.py, esim/asgi.py. If you want the payment
rail itself in detail, the sibling piece on
what happens when an AI agent buys an
eSIM with USDC covers the x402 handshake byte by byte. This one is about the
server around it.
No, and it should not be able to. Nothing in the codebase touches card data.
Both purchase tools return a hosted checkout URL rather than accepting payment. The rail is chosen at order creation, and asking for a card opens a Stripe session and returns a link. The server instructions say it in the text the model reads first: "Never ask the user for card details". Both purchase tool docstrings repeat it: "Never accepts payment details — a card is entered on the hosted page, and a wallet signs for itself."
Saying it three times isn't belt and braces. The instructions block, the tool docstring and the parameter descriptions are the only documentation the buying model will ever read, and it reads them without much attention. Assume the sentence you write once gets skipped.
One implementation detail cost a morning and is worth stealing. The link handed to the client is our own short URL, not Stripe's hosted one:
def _pay_url(order: Order) -> str:
"""Our own short link, never Stripe's hosted URL.
Stripe's URL only works with its `#fragment` attached, and a fragment does not
survive being passed through a link redirector. See `portal.pay`.
"""
return settings.PUBLIC_BASE_URL + reverse("portal_pay", args=[order.public_id])
A browser never sends a fragment to a server, so a checkout link that gets round-tripped through anything — a redirector, a link unfurler, an assistant that rewrites URLs — arrives stripped, and Stripe answers that the page could not be found. A link on your own domain survives, because the fragment only has to reach the browser once, from your redirect.
There is a second rail for callers that hold a wallet, and its copy is shaped by one
failure. _wallet_routes() leads with the HTTP URL and barely mentions the
pay_order tool:
def _wallet_routes(order: Order) -> dict:
"""The two ways to settle an order in USDC: an HTTP endpoint, or the tool.
The URL leads and the tool is barely mentioned, deliberately. Almost every wallet —
Coinbase's Payments MCP, claude.ai's agentic wallet — is a *tool* that pays HTTP
x402 URLs; only a client that natively signs x402 tool calls can use pay_order.
Advertising the tool first stranded a real buyer: their client retried pay_order
against the 402 three times and never engaged the wallet.
"""
One buyer, three retries, nothing bought. The ordering of two sentences in a return value was the whole bug.
Delegate it. eSIM Tabby's OAuth 2.1 is WorkOS AuthKit, wired in as one provider argument on the server:
auth=AuthKitProvider(
authkit_domain=settings.AUTHKIT_DOMAIN,
base_url=settings.PUBLIC_BASE_URL,
),
FastMCP's http_app publishes
/.well-known/oauth-protected-resource/mcp and
/.well-known/oauth-authorization-server, and those have to stay at the
root of the ASGI app — which is why Django is mounted last, as the catch-all.
That is the door. There is a second lock behind it: every tool that touches an
account calls _current_user(), which refuses on its own rather than
trusting the middleware to have run.
def _current_user() -> User:
token = get_access_token()
if token is None:
raise ToolError("not authenticated")
claims = token.claims or {}
# JWTVerifier builds the AccessToken without a `subject`, so the stable user id
# only ever arrives as the `sub` claim. Reading token.subject alone rejects every
# genuinely authenticated call.
subject = token.subject or claims.get("sub")
That comment is there because the obvious code is wrong. token.subject
is empty on a JWT-verified token, so a server that reads it alone rejects every
authenticated caller and looks, from the outside, exactly like a broken OAuth
configuration. Read the sub claim.
The behaviour is pinned by a test rather than by hope. An unauthenticated
tools/call gets a 401 with a WWW-Authenticate header
pointing at the protected-resource metadata, which is what starts the OAuth flow in a
compliant client.
Worth stating plainly: AuthKit is a paid dependency. "Delegate your OAuth" is advice with a bill attached. What you buy is not having to write token issuance, refresh, revocation and dynamic client registration yourself, which for a one-person project is a straightforward trade — but it's a trade, not a free win.
Because a fully locked MCP server is invisible.
MCP directory crawlers — Smithery, Glama, PulseMCP — build their listings by calling
initialize and tools/list anonymously. A 401 at the transport
door means they see nothing, list nothing, and the server doesn't exist as far as
discovery is concerned. So authorisation is gated per JSON-RPC method instead of at
the door:
_ANON_METHODS = {"initialize", "notifications/initialized", "ping", "tools/list",
"resources/list", "resources/templates/list", "prompts/list"}
Those methods bypass the RequireAuthMiddleware that FastMCP wraps the
/mcp route in. Everything else — every tools/call — still
hits it.
The shim that does this has to peek at the JSON-RPC method before deciding, which means buffering a request body pre-authentication. Two guards make that safe. The buffer is capped at 64 KB, because pre-auth buffering must not hold arbitrary bytes in memory and an introspection call is under a kilobyte; anything larger goes straight to the guard, which 401s off the headers without reading the body. And the parse accepts a single JSON-RPC object only:
anonymous_ok = isinstance(parsed, dict) and parsed.get("method") in _ANON_METHODS
Streamable HTTP does not take batches, and the comment above that line names why a
batch branch was not written: it would fail open on [], because
all() over an empty list is True. A test asserts that
POST /mcp with a bare [] gets a 401.
Now the cost, because there is one. An unauthenticated caller learns the full tool
surface: eleven tool names, their descriptions and their argument schemas. It learns
that cancel_order exists and what shape its arguments take.
What it does not learn is anything about an account. tools/call is not on
the anonymous list, so every tool invocation still gets a 401 at the door — and behind
it, every account-touching tool calls _current_user() and refuses on its
own. The two catalogue reads are the only tools without that second lock, and they only
ever return public pricing. The shim is a door policy, not the only lock. If your tool
descriptions themselves are sensitive, this trade does not work for you.
By never giving it a price to forge. The purchase tool takes a plan and a rail, and that is all:
async def create_checkout(
plan_id: Annotated[str, Field(description="plan_id from search_esim_plans")],
pay_with: Annotated[PayWith, Field(description=PAY_WITH_HELP)] = "card",
) -> dict:
Inside, the plan is re-read from the live provider catalogue and frozen onto the
order as package_snapshot, under a one-line comment that is the whole
rule: "Price comes from the live catalog, never from a tool argument." Stripe's line
item is then computed from that snapshot, not from anything the caller sent. Top-ups
do the same and go one step further, refusing a plan that is not in the list the
provider currently offers for that eSIM.
The strongest version of this is at settlement, and it generalises well beyond eSIMs. When an x402 payment arrives, the payment requirements sent to the facilitator are rebuilt from the order:
def settle(order, payment: dict) -> dict:
"""Verify a signed payment, then broadcast it. Returns the facilitator's receipt.
Verify first and settle second, deliberately: /settle puts a transaction on a chain,
and a payload that was never going to be valid should not get that far.
The requirements sent to the facilitator are rebuilt from the order here, never taken
from the caller's own `accepted` block. Trusting the client's copy would let it
quote itself a cheaper price and have the facilitator agree the payment matched.
"""
accepted = requirements(order)["accepts"][0]
The x402 payload a wallet sends back includes its copy of the terms it signed. It's tempting to use that copy, because it's right there and it is cryptographically signed.
But the signature proves the wallet agreed to those terms, not that those were ever your terms. Echo the client's numbers into the facilitator and it will faithfully confirm that a $0.01 payment matched a $0.01 requirement, for a $14.00 order.
That is the shape of the whole class: an argument is a request, never a fact. Any value with money attached gets re-derived on your side, from your own record, at the moment it matters. Signed does not mean correct; it means the client meant it.
It retries. Plan for it as the normal case, because agents time out, re-plan and resend without asking anyone.
The transport is stateless.
mcp.http_app(path="/mcp", stateless_http=True) — no session map, so a
request may land on any worker. The comment above it names what sessioned mode was
relying on: with --workers 3 it works only because keep-alive happens to
pin a client to one worker, and it breaks the moment there is a second host. The cost
is the server-initiated GET/SSE stream, which this server never used.
One settlement at a time, per order. The row lock is taken with
nowait and held across the whole verify-and-settle:
try:
# nowait: the loser hears "already being settled" now, rather than blocking
# for a 60-second facilitator round-trip to be told the order is paid.
order = Order.objects.select_for_update(nowait=True).get(pk=order.pk)
except DatabaseError as exc:
raise NotPayable(
f"another payment for order {order.public_id} is being settled right "
"now. Nothing was charged for this one."
) from exc
Two things there are deliberate. nowait means the second caller gets an
answer immediately instead of blocking for a facilitator round-trip that can take a
minute. And the error string ends with "Nothing was charged for this one", because the
losing caller is a model deciding what to tell a human, and "already being settled" on
its own reads like a maybe. Write error strings for the agent that has to act on
them.
mark_paid reports whether it was the one that applied the
payment.
with transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
if order.status != Order.PENDING:
return False # replayed event, or a second rail raced us; already handled
It is shared by both rails, so everything after the money lands lives in one place. A caller that has already taken money needs to know it landed nowhere — in the x402 path that branch is unreachable while the lock above holds, and it is kept anyway, logged as an error, because the cost of being wrong is a customer who paid twice and that needs a person rather than a retry.
The provider gets an idempotency key tied to the order.
f"order-{self.public_id}", passed as transaction_id when the
profile is bought. Re-running fulfilment doesn't buy a second eSIM: the order is
skipped once it has a provider order number, and that read is not locked, so if two
calls race it the key is what the provider deduplicates on.
Missed webhooks self-heal. Every status check polls Stripe directly for a pending order:
def reconcile_with_stripe(order: Order) -> None:
"""Ask Stripe directly whether a pending order was in fact paid.
Webhooks get missed — endpoint down, wrong signing secret, nobody running
`stripe listen` locally. Without this a customer who has paid stays `pending`
forever, which is the worst failure this system can have. Polling the session on
each status check costs one API call and makes it self-healing.
"""
Weighed against a customer who paid and never got the thing, that is an easy trade.
And at the top of the funnel, calling create_checkout again for the same
plan within 30 minutes returns the existing pending order with
"reused": true rather than minting a duplicate.
Ordering decides this one, and the rule is: provider first, then the money.
def cancel_order(order: Order) -> str:
"""Cancel an order end to end: provider first, then the customer's money.
Provider-first is deliberate. If the provider refuses (profile already installed)
we must not have refunded already, or we're out both the eSIM and the cash.
"""
If the order is still pending, the Stripe session is expired and the answer is "Order cancelled. You were not charged." If the profile is already with the provider, the provider cancel runs first, and a refusal — usually meaning the eSIM has been installed on a device — stops the whole thing with an error naming the order number for support.
Then there is the rail with no reverse gear. USDC has no refund:
if order.x402_tx_hash:
# ponytail: no on-chain refund rail. An on-chain transfer has no reverse, and
# sending one back means custodying a signing key with real USDC behind it — so
# the eSIM is taken back and the debt is parked as `refund_pending` for a person
# to settle by hand. Safe to do after the provider cancel, unlike a rail that
# could still fail: this status cannot fail to be reached, so the customer is
# never left with neither eSIM nor claim. Revisit if refund volume ever
# justifies custodying a funded key.
The order goes to refund_pending and a human returns the payment to the
wallet that paid. This is safe to do after the provider cancel because setting
a status cannot fail — unlike a rail that could, this step is guaranteed to be reached,
so the customer is never left holding neither the eSIM nor a claim.
Cancellation policy itself is identical on both rails: refundable until the profile is installed. Only the return trip differs.
If you take money on a rail that cannot reverse, decide what the refund state is called before you take the first payment. It will exist whether you named it or not.
Carefully, and by leaving things out.
The $mcp_tool_call event is hand-rolled in a FastMCP middleware. It has
to be: FastMCP is mounted ahead of Django in the ASGI app, so Django's PostHog context
middleware never runs for an MCP request, and PostHog's own MCP SDK is TypeScript-only.
The distinct_id is user.pk rather than the WorkOS subject, so
one customer's chat and browser activity land on one person instead of two.
The interesting part is the properties that aren't there:
posthog.capture("$mcp_tool_call", distinct_id=str(user.pk), properties={
"$mcp_tool_name": context.message.name,
"$mcp_duration_ms": round((time.perf_counter() - started) * 1000),
"$mcp_is_error": failed,
# PostHog's schema also has $mcp_parameters and $mcp_response.
# Deliberately omitted: our arguments and results carry order ids,
# checkout URLs, ICCIDs and activation codes — credentials that
# install a paid eSIM. Analytics is not the place for them.
})
An activation code installs a paid eSIM. Ship the arguments and responses to an analytics warehouse and you have put bearer credentials in a place designed for broad read access and long retention. The tool name, the duration and the error flag answer every question worth asking.
The capture sits in a finally inside its own try/except, so
a failed analytics call logs and does not fail the tool. Instrumentation that can break
the thing it measures is worse than none.
Eleven tools, one product, one provider, one person. This is one implementation, not a survey of the field, and some of it is shaped by constraints you may not share. The failure modes above, though, are the ones every seller on this transport will meet, and most of them are cheaper to design against than to discover.
If someone builds the second one of these and does the pricing rule differently, I'd like to read it.
Want to see the whole thing running?
Add the server to Claude with claude mcp add --transport http esimtabby
https://esimtabby.com/mcp and ask it to search for a plan — the OAuth flow runs on the
first tool call. The machine-readable server details are in the
server card, and setup for Claude
desktop and ChatGPT is on the homepage.
Can an MCP tool charge a credit card?
No, and a well-built one cannot. eSIM Tabby's purchase tools return a hosted checkout URL for a human to open, or an x402 URL for an agent's wallet to settle. No card data reaches the server or the assistant, and both tool docstrings state that payment details are never accepted.
How do I authenticate MCP tool calls?
Delegate OAuth 2.1 to a provider — eSIM Tabby uses WorkOS AuthKit via FastMCP's AuthKitProvider — and check identity again inside every tool that touches an account. Read the user id from the `sub` claim; on a JWT-verified token the `subject` attribute is empty, and reading it alone rejects every genuinely authenticated caller.
How do I stop an agent forging a price in a tool argument?
Don't accept a price. Take a product identifier, re-read the price from your catalogue server-side, and freeze it on the order. At settlement, rebuild the payment terms from your own record rather than from the client's signed copy — a signature proves the client agreed to those terms, not that they were ever yours.
Is it safe to let an AI agent buy something?
It depends on what the seller lets the tool do. Safe means the tool cannot set a price, cannot take card details, and hands back a checkout the human opens. Ask whether the server re-reads prices server-side and where payment happens; if the tool itself takes payment data, that is the problem.
Can I run an MCP server with multiple workers?
Yes, with stateless HTTP. Sessioned mode only appears to work across workers because keep-alive pins a client to one process, and it breaks with a second host. Stateless costs you the server-initiated SSE stream, so it is a straight trade unless you use elicitation, sampling or progress notifications.
What should an MCP analytics event record?
Tool name, duration and whether it errored. Omit arguments and responses: on a commerce server they carry order ids, checkout URLs and activation codes, which are credentials rather than telemetry. Wrap the capture so a failing analytics call cannot fail the tool call it is measuring.