An OAuth server so an AI can book a cleaning
Letting Claude or ChatGPT place a real, paid booking meant writing an authorisation server, keeping the assistant's access separate from the phone's, and making sure money never moves inside a chat.
August 23, 2026 · 6 min read
Leestly runs a remote MCP server, which means you can book a home cleaning from inside Claude or ChatGPT without leaving the conversation. Ask what a clean would cost, check availability, get a booking link back.
Making that work is a weekend. Making it safe took considerably longer, and the interesting parts are all in the second half.
Nine tools, and what they are allowed to know
Three tools need no account at all: list_cleaning_types, estimate_price,
check_availability. Anyone can ask what a clean costs.
Six require you to be signed in, across four scopes — profile:read,
addresses:read, orders:read, orders:write: get_my_profile,
get_my_addresses, get_my_orders, create_order_draft,
create_payment_link, cancel_order.
The tool layer holds no business logic. Its header states the rule plainly: no duplicated formulas and no money operations. A price the assistant quotes comes from the same pricing service the app calls, because two implementations of a price is how you end up showing one number and charging another.
What leaves the building is deliberately thin — only the fields needed to place a booking. No phone numbers, no internal id chains, no audit timestamps. The assistant is a client, not an admin.
Sign in with Leestly
Connecting an assistant to an account is an authorisation problem, and the answer is a full OAuth 2.1 authorisation server. We wrote one.
Three registration paths, not one. The obvious one is Dynamic Client
Registration (RFC 7591) — the client POSTs to /oauth/register and gets
credentials. But the AI vendors prefer something else: Client ID Metadata
Documents, where the client_id is an HTTPS URL pointing at a metadata
document. So that's supported too, plus manual registration for anything else.
CIMD has an obvious failure mode — resolving a client would mean fetching a
third-party URL on every single /authorize request. The document is cached
and re-read hourly, behind a five-second abort timeout, with redirects refused
outright.
PKCE with S256 is mandatory. An authorize request without a
code_challenge is rejected. The discovery document advertises S256 because,
per the MCP specification, a client that doesn't see it there will refuse to
proceed.
The verification is timing-safe, and there's a subtlety worth knowing if you
implement this yourself: Node's crypto.timingSafeEqual throws on
unequal-length inputs. A length check has to come first, which is fine here
because both sides are fixed-length hashes.
Audience binding, RFC 8707. This is the defence against a token issued for
one resource being replayed at another. The aud claim is pinned to this
server's own MCP endpoint; a token request naming a different resource comes
back invalid_target. Verification pins the algorithm to HS256 rather than
trusting whatever the token header claims.
The consent screen is sixty lines of hand-written HTML. Scope labels in
English and Russian, noindex, escaping done locally. It is rate-limited with
the same limiter as login, because that is effectively what it is — a page
where someone types credentials is a brute-force target regardless of what
you call it.
One small piece of specification fidelity worth copying: /oauth/revoke
returns 200 for a token it has never seen. Not 404. Telling a caller which
tokens exist is itself a leak.
The assistant's access is not your phone's session
This is the design decision I'd defend hardest, and it's stated in the model file rather than left implicit: a separate namespace from the mobile authorisation — revoking an external AI's access must not touch app sessions, and vice versa.
Concretely:
- Access tokens are 15-minute JWTs that are never stored. They're validated by signature and audience, so there's nothing to leak from a database.
- Refresh tokens are opaque, 30 days, stored only as SHA-256 hashes, and rotated on every use with a breadcrumb pointing at what replaced them.
- The database holds only what must survive a restart: registered clients, single-use authorisation codes, and those hashes. TTL indexes reap both.
Revoke the assistant, and your phone stays logged in. Sign out of the phone, and the assistant keeps working until you revoke it separately. Two grants, two lifecycles.
Money never moves in the chat
This is the rule that shaped everything else, and it survived every opportunity to bend it.
The assistant can create an order draft. It cannot take a payment. What
create_payment_link returns is a Stripe hosted checkout link. The card is
authorised there, in Stripe's page, and captured after the clean is
finished — exactly the same rule the app follows. There is no faster path for
the convenient case, because the convenient case is precisely where you'd
regret having one.
Three problems showed up in the handoff, and each one is the kind of thing you only find by building it:
A replayed idempotency key must only replay a live draft. If an earlier
attempt has gone terminal — checkout expired, order cancelled — handing that
same order back means create_payment_link rejects it as unpayable and the
user is stuck in a loop with no way forward. The fix is to fall through and
mint a fresh draft, repointing the stale key.
Stripe's idempotency key needs to be deterministic and rotatable at once. It's derived from the order id, so a retry can't double-charge. But once a session is known dead, the key rotates past it, keyed on the dead session's id. The comment in the code puts the distinction better than I can: the guard handles "response saved", the key handles "response lost before save".
A cancelled draft has to expire its Stripe session proactively, otherwise a
late checkout.session.completed can place a capturable hold on an order
that no longer exists.
Two more things are server-authoritative on purpose. A cancellation reason supplied by the assistant can never select a full-refund tier — the refund policy is decided by the server, from the role, not from free text a model produced. And connector cancellations route through the same core function as the app's, so the path can't silently flip a status while leaving the cleaner unaware.
Failure as data, not as an error
A small thing with a large effect. When a tool call needs authentication that
isn't there, the server doesn't raise an error — it returns
{ ok: false, code: 'AUTH_REQUIRED' } with the scope required.
An error is something the assistant apologises for. Data is something it can act on: it sees what's missing and offers the user a sign-in link. Shaping the refusal so a model can do something useful with it is most of the difference between a connector that works and one that dead-ends.
One endpoint, two ecosystems
ChatGPT's Apps SDK support sits on the same endpoint as a purely additive
layer: _meta annotations and an order-card widget that Claude simply ignores.
The widget is built entirely with textContent and never innerHTML — it
renders order data that ultimately came from user input, inside someone else's
host page. Its content-security policy declares exactly one external redirect
domain, Stripe's checkout.
The honest caveat: the widget MIME type is mid-transition in the spec, and that needs verifying in ChatGPT's developer mode rather than assumed.
What we'd tell you before you start
The protocol work is not the hard part. The hard parts are the ones that existed before MCP did: token lifecycle, idempotency, and refusing to let a convenient path skip a rule the inconvenient path follows.
And be clear-eyed about the platforms. Claude supports this today. ChatGPT requires review, and there's real uncertainty about how payment approval treats services rather than physical goods. Consumer Gemini isn't a self-serve option at all — its connected apps are closed partnerships with no public process to apply to.
One of those three is a product decision. The other two are a waiting list.
This is part of Leestly, a marketplace for home cleaning.