eSIM MCP Server
This server provides a secure action layer for AI chatbots to help users discover and purchase eSIM plans. It covers authentication, catalogue browsing, purchase preparation, and execution via wallet or card.
Authentication: Send, resend, and verify login OTPs via email/phone; check login status; view masked user profile and wallet balance; logout.
Catalogue Browsing (no login required): List countries, regions, and cruise plans; browse home catalogue; find plans by country/region with filters; get detailed plan info.
Purchase Preparation (login required, nothing charged): Prepare a quote for wallet or card payment (reads live price and balance); retrieve or cancel a prepared quote.
Purchase Execution (login required, real money): Confirm wallet purchase (idempotent, never double-charges); open a Stripe-hosted card payment page (card details never touch the server); check card payment status.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@eSIM MCP ServerI need an eSIM for France"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
eSIM MCP Server
The action layer between a chatbot (Claude or ChatGPT) and the existing eSIM platform:
Claude / ChatGPT ← conversation and reasoning happen here
↓ MCP
esim-mcp ← this project: safe, typed actions
↓ HTTPS
eSIM backend ← existing service, unchangedThere is exactly one new service. This project is not a chatbot, not a second API, not an agent backend and not a UI — Claude or ChatGPT holds the conversation and decides which tool to call; this server performs the action and returns short structured facts that the model phrases naturally.
The user experience it is built for:
User: I need an eSIM for France.
Assistant: Here are the plans available for France:
1. 1 GB / 7 days — 5.00 USD
2. 5 GB / 30 days — 12.50 USD
3. 10 GB / 30 days — 20.00 USD
Want details on any of them?
User: Tell me more about the second one.
Assistant: That's 5 GB, valid 30 days from first connection, 12.50 USD…Browsing needs no login. Signing in is asked for only when something needs the account:
User: Log me in.
Assistant: What email or phone should I use?
User: user@example.com
Assistant: I've sent a code to u***@example.com. What's the six-digit code?
User: 482915
Assistant: You're logged in.Buying is a two-step conversation, on purpose — the amount is quoted, said out loud, and agreed to before anything is charged:
User: I'll take the second one.
Assistant: That's 12.50 USD from your wallet balance. Shall I buy it?
User: Yes, buy it.
Assistant: Bought — France 5 GB / 30 days for 12.50 USD, paid from your wallet.The assistant is never told to "call a tool"; it decides from the server instructions and
the tool descriptions shipped by this server. Preparing a purchase never charges; only
confirm_purchase does, and only after an explicit yes — see scope below.
It is a standalone HTTP client of the eSIM platform: it never talks to Supabase, Stripe,
Firebase or an eSIM hub directly, and holds no credentials for them. Built on the official
Python MCP SDK (mcp 2.x), httpx.AsyncClient, Pydantic v2 and pydantic-settings.
Scope
Implemented:
Phase 1 — production-ready server foundation (settings, logging/redaction, error model, HTTP client, Docker image) and multi-user OTP authentication with isolated server-side sessions;
Phase 2 — read-only catalogue and bundle discovery: countries, regions, the home catalogue, and country / region / cruise / global bundles, plus bundle details, with client-side filtering, sorting and result limiting. Browsing requires no login.
Phase 3 — safe purchase preparation: an MCP-local quote for a plan a signed-in user picked, priced from a fresh backend read, with a wallet-balance snapshot for wallet quotes. A quote reserves nothing and has no counterpart in the backend.
Phase 4 — purchase execution, wallet only:
confirm_purchaseturns a prepared quote into a real order over the platform's idempotent MCP purchase route. One idempotency key per quote, a repeated confirmation replays the first purchase instead of making a second, and an unclear outcome is never reported as either success or failure.Phase 5B — card checkout:
create_card_checkoutasks the platform to open its Stripe-hosted payment page for a prepared Card quote and returns the link;check_card_payment_statusreads what happened to that payment. One idempotency key per quote, one page per quote, and the card never touches this server — it is entered on Stripe's own hosted page, in the user's browser.Phase 6A — live eSIM usage:
get_esim_consumptionreports the platform's own reading for one eSIM the signed-in user owns — total, used, remaining, plan status and expiry. Every figure is copied from the platform; none is derived from a date, a price or a catalogue allowance, and an empty answer is reported as "nothing yet", never as zero.Phase 6B — eSIM top-up:
get_esim_topup_optionslists the platform's own compatibility list for one owned eSIM andprepare_esim_topupprices one of them. Both are free.confirm_esim_topupperforms the top-up, but only in QA and behind a flag that defaults to off — seeconfirm_esim_topupis QA-only below.Phase 6C — wallet top-up:
prepare_wallet_topupchecks an amount against the platform's own minimum and rolling limits,create_wallet_topup_checkoutasks the platform to open its Stripe-hosted page and returns the link, andget_wallet_topup_statusreads what happened. Duplicate protection is the platform's own durable pending-order reuse, and the card never touches this server.
Not implemented, by design: taking card details here (a Stripe integration of any kind beyond passing on the platform's own link), performing an eSIM top-up outside QA, promotions, vouchers, DCB, refunds or order cancellation, eSIM provisioning, activation, callbacks and the backend's translation/maintenance routes.
confirm_esim_topup is QA-only, and not idempotent
Set MCP_ESIM_TOP_UP_ENABLED=true and one more tool appears: confirm_esim_topup, which
performs a real top-up over the platform's legacy POST /user/bundle/assign-top-up —
the same route the portal uses. The flag defaults to false and must stay false in
production.
The reason is idempotency. A top-up that runs twice costs the user twice and puts data on a
SIM they did not ask for, and the only honest protection is a durable record that lets a
retry be recognised as the same request. The platform has none: that route accepts no
idempotency key, and the Topup row it writes to user_order carries neither an ICCID nor a
request key, so two identical requests are indistinguishable from one sent twice. It also
debits the wallet before provisioning and swallows a failed provisioning.
So nothing here tries to make a second request safe. It makes a second request impossible instead — a different and weaker promise:
one quote, one attempt, whatever the outcome. The attempt is counted before the request leaves, under a per-quote lock, so a request that dies in flight still locks the quote;
an unknown outcome is terminal. Everywhere else an unresolved write may be presented again with the key it already used; here there is no key, so asking again is a second top-up;
everything is revalidated against the platform immediately before sending — ownership, compatibility, availability, price, currency, balance, expiry, payment method;
the caller must echo the exact amount from the quote, so a confirmation can only come from something that actually read it back;
the lock lives in this process. A restart between sending and recording loses it. That is exactly why this does not go to production.
Three independent gates rest on the flag, and all three are asserted by tests: the tool is
not registered, the service refuses, and enforce_route_is_permitted refuses the path.
Settings refuses to construct when the flag is true and the environment is production,
so a production process with it set will not start.
Production blocker: durable idempotency (a request-key column or table on user_order, which
is a schema change) plus wallet compensation for a debit whose provisioning failed.
Preparation and purchase are two different tools, deliberately
prepare_purchase never charges. It reads the plan and the balance and writes a record
in this process; it creates no backend order, starts no payment, debits no wallet and
reserves nothing. Every preparation result states order_created: false and
charged: false in every branch.
confirm_purchase may charge. It is the only tool in this codebase that can spend a
user's money: it creates an order at the eSIM platform and debits the wallet balance, and
nothing here can undo or refund that. It takes a single argument — the reference of a quote
this same caller prepared — so the plan, the price, the currency and the payment method all
come from the stored quote and cannot be supplied, altered or hallucinated at purchase time.
The split exists so that quoting an amount and agreeing to pay it are separate acts, and so the model cannot slide from one to the other: a user asking what a plan costs never reaches a tool that can charge them.
The two routes that can lead to a charge
POST /api/v1/mcp/user/bundle/assign is the platform's MCP-only purchase endpoint. It
requires an Idempotency-Key, is wallet-only, and replays its stored answer rather than
executing a second purchase when the same key returns. Calling it spends the user's
money.
POST /api/v1/mcp/user/bundle/card/checkout opens the platform's hosted card payment page
and returns a link. It also requires an Idempotency-Key and also replays — but it moves
nothing: the user pays on Stripe's page or they do not, and this server never learns a card
number either way. GET /api/v1/mcp/user/bundle/card/status/{payment_reference} is the read
that says what happened.
POST /api/v1/mcp/wallet/top-up/checkout opens the platform's hosted page for a wallet
top-up and returns a link. It moves nothing either: the wallet is credited only after the
user pays, and only by the platform's own signature-verified Stripe webhook. Duplicate
protection lives at the platform, which reuses the caller's own pending user_order row and
keys the Stripe call on it — durable across a restart of this process, which is why this
server mints no key of its own for that route.
Those three POSTs are the only mutating routes this server may call, allowlisted by
exact method and path in PERMITTED_MUTATION_ROUTES (src/esim_mcp/client/base.py). Reads
whose paths end in caller-supplied references cannot be matched exactly; their prefixes are
allowlisted in PERMITTED_REFERENCE_READ_ROUTES together with the exact number of segments
allowed after them, and every segment must be one plain opaque token — a path that starts
with a prefix and fails that check is refused outright rather than falling through to the
marker scan. That covers the two payment-status reads, GET /user/consumption/{iccid} and
GET /user/related-topup/{bundle_code}/{iccid}. One fixed read,
GET /mcp/wallet/top-up/options, contains a forbidden marker in its own path and is named
back in by exact match in PERMITTED_EXACT_READ_ROUTES.
The whole bundle/card family is a forbidden marker, exactly like bundle/assign, with the
two members above allowlisted back. A card/capture, a card/refund or a future
card/confirm route is therefore unreachable by construction rather than by omission. The
same treatment now covers top-up and topup: the whole family is banned and the four
routes named above are allowlisted back, so /mcp/user/bundle/topup, a future
/wallet/credit or any other top-up route is unreachable simply by not spelling a banned
word.
The legacy POST /api/v1/user/bundle/assign stays forbidden and always will be: it has
no idempotency key and no duplicate-order protection, so a retried call there can buy a plan
twice. That route — and its neighbours (assign-top-up, verify_order_otp, wallet/top-up,
vouchers, promotions, callbacks, the unauthenticated wallet/user_wallet_by_id/{id}) — is
refused by BackendApiClient itself, before any I/O, for any HTTP method. The allowlist
is checked before the marker list and compares whole paths, so the legacy route cannot slip
through on the strength of being a substring of the permitted one. See
FORBIDDEN_PATH_MARKERS and the proofs in tests/test_no_backend_mutation.py.
Related MCP server: firsty-mcp
Architecture
src/esim_mcp/
├── server.py # MCPServer wiring, lifespan, transports, entry point
├── settings.py # typed configuration, production fail-fast validation
├── errors.py # typed errors with MCP-safe messages
├── logging_config.py # JSON logging, correlation id, mandatory redaction filter
├── client/
│ ├── base.py # pooled httpx client, envelope parsing, route guard + allowlist
│ ├── auth.py # one method per /api/v1/auth route
│ ├── card.py # the hosted card checkout + its status read, and nothing else
│ ├── catalog.py # one method per read-only /home and /bundles route
│ ├── purchase.py # the single idempotent wallet purchase, and nothing else
│ └── wallet.py # the single authenticated wallet read, and nothing else
├── models/
│ ├── common.py # backend response envelope
│ ├── auth.py # login / verify / auth-response models, JWT exp reader
│ ├── card.py # checkout + payment-status payloads; closed field set, link guard
│ ├── catalog.py # Country, Region, Bundle, HomeCatalog + defensive parsers
│ ├── purchase.py # the platform's purchase result, parsed defensively
│ └── wallet.py # UserWallet; backend floats → exact Decimal via str()
├── catalog/
│ ├── resolution.py # name/ISO → country tag GUID, name/code → region code
│ ├── selection.py # filters, sort orders, result limits
│ └── summaries.py # the small conversational shapes tools return
├── purchase/
│ ├── models.py # PurchaseQuote and its typed parts; no secret is storable
│ ├── store.py # PurchaseQuoteStore abstraction + in-memory implementation
│ ├── service.py # quote lifecycle: create / read / cancel / consume, supersede
│ ├── execution.py # one idempotency key per quote, attempt limit, replayable outcome
│ ├── card.py # one key and one payment page per quote, bounded status checks
│ └── validation.py # quote ids, lifecycle gates, all Decimal money arithmetic
├── session/
│ ├── identity.py # ClientIdentityProvider + HMAC device-id derivation
│ ├── models.py # UserSession, LoginChallenge
│ ├── store.py # SessionStore abstraction + in-memory implementation
│ └── manager.py # session lifecycle, token refresh, 401 replay
├── safety/redaction.py # masking (for output) and redaction (for logs)
└── tools/
├── guard.py # correlation id + "only typed errors escape" boundary
├── authentication.py # the six authentication tools + AuthenticationService
├── catalog.py # the seven catalogue tools + CatalogService
├── purchase_preparation.py # the three preparation tools + PurchasePreparationService
├── purchase_execution.py # confirm_purchase + PurchaseConfirmationService
└── card_checkout.py # the two card tools + CardPaymentServiceLayering rule: tools never touch HTTP or tokens. Authentication tools call
AuthenticationService, which uses SessionManager (state) and AuthApiClient
(transport). Tokens exist only inside the session layer and the HTTP header builder.
Catalogue layering is the same shape with one less layer, because there is no state to
keep: CatalogService → resolvers → CatalogApiClient → the shared BackendApiClient.
It holds no session and no token at all — it takes the caller's verified identity only
to derive the X-Device-Id the backend requires.
Purchase preparation splits along the same seam, with the domain half kept deliberately
inert: PurchasePreparationService (fetches authoritative facts, shapes results) →
PurchaseQuoteService (the rules) → PurchaseQuoteStore (storage). The whole
esim_mcp/purchase/ package performs no I/O of any kind — it imports no HTTP client and
holds no reference to one, which is asserted by a test rather than left to review.
Purchase execution keeps that seam even though it is the part that spends money:
PurchaseConfirmationService (the only caller of PurchaseApiClient) →
PurchaseExecutionService (mints and remembers the key, decides whether a purchase may be
sent at all) → PurchaseExecutionStore. The middle layer still cannot reach the network, so
the decision "may this be sent?" is made by code that has no way to send it.
Card checkout repeats that shape exactly: CardPaymentService (the only caller of
CardCheckoutApiClient) → CardCheckoutService (mints and remembers the key, decides
whether a page may be opened and whether a payment may be checked again) →
CardCheckoutStore. Everything in esim_mcp/purchase/card.py is inert domain logic like the
rest of that package, so "may a second payment page exist?" is answered by code that could
not create one.
find_bundles_by_country("France")
→ CountryResolver: GET /bundles/countries, exact match → tag GUID (never guessed)
→ CatalogApiClient: GET /bundles/by-country?country_codes=<guid>
→ filter (client-side) → sort → limit → small summaries + counts + price noteRequest flow for an authenticated call:
MCP tool → verified client identity → session key → SessionManager
→ (refresh if inside the expiry window) → AuthApiClient
→ httpx (Authorization header set here only) → backend envelope
→ data → masked tool resultMCP tools
Tool | Purpose | Backend route |
| Send a one-time code to an email or phone |
|
| Resend the code for a pending challenge |
|
| Exchange the six-digit code for a server-side session |
|
| Report session state; no backend call | – |
| Masked profile and wallet balance |
|
| End this client's session |
|
Token refresh (POST /api/v1/auth/refresh-token) is not a tool. It happens inside
SessionManager, proactively before expiry and once reactively on a 401.
Catalogue tools (Phase 2 — read-only, no login required)
Tool | Purpose | Backend route |
| List destinations, or resolve the one the user named |
|
| List regions, or resolve the one the user named |
|
| Overview: counts plus cruise and global previews |
|
| Plans for one country |
|
| Plans for one region |
|
| Plans sold for cruise ships |
|
| Full detail for one plan |
|
GET /api/v1/home/land is wrapped by CatalogApiClient.get_land_catalog but is not
exposed as a tool: browse_home_catalog already covers the same ground for a conversation.
Deliberately not used: /bundles/translate_* (maintenance), every /callback/* route, and
anything that orders, pays, provisions or mutates a wallet.
Purchase-preparation tools (Phase 3 — sign-in required, nothing is bought)
Tool | Purpose | Backend route |
| Quote a plan the user picked, for Wallet or Card |
|
| Read one of this caller's own quotes back | – (local only) |
| Discard one of this caller's own quotes | – (local only) |
prepare_purchase accepts no price, balance, tax, discount, card, token or identity
argument: every priced value is re-read from the platform, so a model cannot put an invented
figure into a quote. Wallet arithmetic is Decimal throughout and monetary values cross the
wire as strings.
Quotes are owned by the verified MCP client identity plus the authenticated eSIM user, are
looked up only within that owner (a foreign quote is invisible, not refused), expire after
ESIM_MCP_PURCHASE_QUOTE_TTL_SECONDS, are capped at ESIM_MCP_MAX_ACTIVE_QUOTES_PER_USER,
and are cancelled whenever their session ends. Re-preparing the same plan and payment method
supersedes the older quote rather than returning it, so a quoted price is never stale.
InMemoryPurchaseQuoteStore keeps quotes in the process heap: they are lost on restart and
are not shared between replicas. That is expected — a quote holds no money, no reservation
and no backend order, so losing one costs a re-prepare and nothing else. PurchaseQuoteStore
is the seam for an encrypted Redis implementation later, and the MCP tools would not change.
Purchase execution (Phase 4 — sign-in required, this one charges)
Tool | Purpose | Backend route |
| Buy a prepared plan from the user's wallet |
|
Contract
confirm_purchase(quote_reference: str) -> purchase resultOne argument, and it must be the quote_id from this caller's own prepare_purchase
result. There is no price, amount, balance, bundle, payment-method, currency, idempotency-key
or identity argument — every one of those comes from the stored quote or from the session, so
a hallucinated figure has no way into the request.
Request mapping (built from the stored quote only):
Backend field | Source |
|
|
| always |
|
|
|
|
Headers: Authorization (from the session, refreshed before the call), X-Device-Id,
Accept-Language (quote.locale), X-Currency (quote.price.currency, sent explicitly so
the platform refuses a purchase it would settle in a different currency) and
Idempotency-Key.
Idempotency and repetition
One cryptographically random key (
secrets.token_urlsafe(48)) is minted per quote, on first confirmation, and reused for every later attempt on that quote.A new key is produced only by a new quote. A timeout, a dropped connection or an "in progress" answer keeps the existing key — the recovery is to ask the platform again with it, never to start a fresh purchase.
A terminal outcome is stored and replayed: confirming the same quote twice returns the first purchase's result with
replayed: trueand sends nothing.Attempts are capped (
MAX_EXECUTION_ATTEMPTS = 3) so an unresolved purchase cannot become an unattended retry loop; past the cap the tool refuses and points at support.The quote is marked
consumedonly after the platform confirms a completed purchase.Purchases for one quote are serialized by a lock held across the backend call, so two concurrent confirmations cannot both reach the platform.
Outcome mapping
Backend | MCP error / result | Recorded as | Retry with the same key? |
| success result ( | succeeded | replayed, never re-sent |
|
| failed (nothing charged) | replayed, never re-sent |
|
| failed (nothing charged) | replayed, never re-sent |
|
| unresolved | yes — same key |
|
| escalated | never |
|
| not recorded | yes — same key |
|
| not recorded | yes — same key |
|
| not recorded | yes — same key |
|
| not recorded | yes — same key |
timeout / lost connection / unreadable |
| unresolved | yes — same key |
The two ambiguous outcomes (purchase_needs_support, purchase_outcome_unknown) carry
instructions never to claim success or failure, never to prepare another quote for the
same plan, and never to re-key. Nothing else in the codebase sets charged: true.
Example result (nothing sensitive is ever returned — no token, key, key fingerprint, correlation id, developer message, activation code or ICCID):
{
"status": "purchased",
"quote_reference": "…",
"order_created": true,
"charged": true,
"payment_method": "Wallet",
"order_id": "…",
"order_status": "SUCCESS",
"payment_status": "COMPLETED",
"provisioning_status": "COMPLETED",
"next_state": "GET_ESIM_BY_ORDER",
"bundle": { "bundle_code": "…", "name": "France 5GB / 30 Days", "data": "5.0 GB", "validity": "30 Day" },
"pricing": { "quoted_amount": "8.06", "currency": "USD" },
"replayed": false,
"message": "The order was created and the wallet was charged."
}InMemoryPurchaseExecutionStore holds execution records — including the idempotency key, as
a SecretStr — in the process heap, and they are dropped whenever their session ends. A
restart between sending a purchase and recording its answer loses this server's memory of the
key; the platform's record survives, so the purchase is still protected from duplication
there, but this server can no longer replay the result. PurchaseExecutionStore is the same
Redis seam as PurchaseQuoteStore.
Card checkout (Phase 5B — sign-in required, this one never sees a card)
Tool | Purpose | Backend route |
| Open the platform's Stripe-hosted payment page for a prepared Card quote |
|
| Read what happened to that payment |
|
Contract
create_card_checkout(quote_reference: str) -> checkout result (link, amount, reference)
check_card_payment_status(payment_reference: str) -> payment stateOne argument each, and neither can carry a card, an amount or an outcome. There is no
card_number, expiry, cvv, cardholder, payment_token, amount, currency,
checkout_url or idempotency_key argument on either tool, and no paid / success /
redirect argument through which a model could assert that a payment happened. The card is
entered on Stripe's own hosted page, in the user's browser; this process never renders it,
never proxies it and never receives its contents.
Request body — exactly three fields, and no more. The endpoint declares
extra="forbid()", so one unexpected key is a 422 for the whole request rather than a field
the platform politely ignores:
{
"bundle_code": "<from the stored quote>",
"quote_reference": "<the stored quote's id>",
"related_search": { "countries": [ { "iso3_code": "FRA", "country_name": "France" } ] }
}Backend field | Source |
|
|
|
|
|
|
There is no payment_type: the backend fixes the payment type internally for this route,
so sending one would be both redundant and rejected. There is likewise no amount, price, tax,
discount, currency, user id, order id, URL, card detail, provider token or access token —
none of them belongs in the body, and several belong nowhere at all. The dict is built
literally, with no code path that can add a key.
Headers: Authorization (refreshed before the call), X-Device-Id, Accept-Language
(quote.locale), X-Currency (quote.price.currency) and Idempotency-Key. Currency
travels only as X-Currency, so the settlement currency is negotiated in exactly one place.
The status read reuses the same locale and currency, so an amount read back to the user is the
one they agreed to rather than a server-default conversion of it.
Backend response fields this server reads. Exactly the documented field sets, with no
invented aliases — a name this server reads has to be a name the platform actually sends —
and extra="ignore" closing the rest:
Payload | Fields read |
create checkout |
|
payment status |
|
* correlation_id and message are named so they are known to be dropped. A correlation
id is a backend tracing handle and a message is prose written for the platform, not for a
traveller; neither ever reaches a result, which is asserted by a test. A provider session id,
a client secret, a publishable key or a developerMessage is not named at all, so it cannot
be forwarded even by mistake — the field set is closed rather than filtered.
One page per quote
One cryptographically random key (
secrets.token_urlsafe(48)) is minted per quote, on first checkout, and reused for every later attempt on that quote.Once a page exists, its stored result is replayed — the same link,
replayed: true, and no request sent. The user is never handed two links, and never asked to pay twice.The replay is checked before the quote-lifecycle gates, so a quote whose short TTL lapsed while the user was paying still returns the link they have open rather than an expiry error.
Attempts are capped (
MAX_CHECKOUT_ATTEMPTS = 3) so an unresolved checkout cannot become an unattended loop.
The link is validated, not trusted
safe_checkout_url passes on only a plain https address with a host, no embedded
credentials, no whitespace or control characters and a sane length. javascript:, data:,
plain http and anything malformed are refused outright and never shown — a payment link is
the one value a user is asked to act on, so repairing a bad one would be worse than
declining it. A page whose payment_reference this server could not use afterwards is also
refused: the user would otherwise pay with no way for anyone here to confirm it.
Who is allowed to say a payment happened
The backend's existing signature-verified payment webhook (POST /api/v1/callback/payment-webhook), and nothing else. Stripe calls it; it settles the payment
and triggers provisioning. This server never calls that webhook, never simulates one, never
marks a payment paid and never provisions — it holds no Stripe credential with which it
could, and the route is refused by the transport guard for every method. Every paid and
provisioned below is copied from a status read, never decided here.
Payment status, and what may be concluded from it
The eight statuses are the platform's own normalized words. Only case and surrounding whitespace are normalized on this side; no synonym is accepted, because inventing a mapping for a word the platform does not send would mean guessing.
Platform status | What the tool returns | What the model is told to do |
|
| Re-offer the link; wait; never ask for card details |
|
| Payment arrived, eSIM not ready — never say active/installed |
|
| Being set up; offer to check again in a minute |
|
| Say plainly it was paid by card; quote is consumed |
|
| Say nothing was charged; offer a new page |
| raises | Stop. Never say succeeded or failed; contact support |
unrecognized word | raises | Never guessed at, in either direction |
Three facts are kept deliberately separate, because collapsing any two is how a user gets told
something untrue: paid (the platform says money arrived), provisioned (the platform says
the eSIM exists — never inferred from paid), and is_final (no later check can change the
answer). next_action is the platform's own eSIM-retrieval instruction, passed through
verbatim when it sends one and never invented when it does not.
paid comes from the platform's own status word and from nothing else. A browser redirect, a
success screen, the user returning to the conversation and the user saying "I paid" are
explicitly named as not evidence, in the tool description, in every status result and in
the server instructions.
Checking is bounded and never automatic. One backend read per tool call — there is no
retry loop inside either tool — and at most MAX_STATUS_CHECKS = 20 reads per payment before
the server refuses and points at support. A terminal answer is stored and replayed instead of
re-read, so a settled payment costs zero further requests. An AMBIGUOUS answer is recorded
as terminal: it stops later checks and stops a second checkout for the same quote.
Example checkout result:
{
"status": "checkout_ready",
"quote_reference": "…",
"payment_reference": "…",
"checkout_url": "https://checkout.test/pay/…",
"payment_method": "Card",
"amount": "10.00",
"currency": "USD",
"expires_at": "2026-01-01T00:30:00+00:00",
"payment_status": "PENDING",
"charged": false,
"paid": false,
"provisioned": false,
"order_id": "…",
"order_state": "unpaid",
"bundle": { "name": "France 5GB / 30 Days", "data": "5.0 GB", "validity": "30 Day" },
"message": "A secure payment page was opened for this plan. Nothing has been charged yet."
}The backend records an unpaid order alongside the page, so order_id is reported — next
to paid: false and a next_step that says in words that the order becomes a purchase only
once the user pays on Stripe. There is deliberately no order_created: true here: that flag
means "bought and charged" everywhere else in this codebase, and it would be a lie here.
InMemoryCardCheckoutStore holds checkout records — including the idempotency key, as a
SecretStr — in the process heap, and they are dropped whenever their session ends. A restart
loses this server's memory of an open page: the platform's record survives, so the user's
payment is unaffected and the same key would still resolve there, but this server can no
longer replay the link or check the payment. CardCheckoutStore is the same Redis seam as
PurchaseQuoteStore.
Example catalogue result:
{
"status": "ok",
"destination": "France",
"country": { "country": "France", "country_code": "FR", "iso3_code": "FRA" },
"total_count": 6,
"returned_count": 5,
"more_available": true,
"bundles": [
{ "bundle_code": "…", "name": "France 5GB / 30 Days", "data": "5.0 GB", "unlimited": false,
"validity": "30 Day", "price": "12.50 USD", "price_amount": 12.5, "currency": "USD",
"coverage": { "countries_count": 1, "countries": ["France"] } }
],
"price_note": "Displayed catalogue price may not include final tax; final amount will be confirmed before purchase.",
"note": "Plans the platform sells for France. This is that destination's list, not the platform's entire catalogue."
}Example results (nothing sensitive is ever returned):
{ "status": "otp_requested", "channel": "EMAIL", "destination": "m***@example.com", "expires_in_seconds": 300 }{
"status": "authenticated",
"is_verified": true,
"user": { "user_id": "b3f1...6666", "email": "m***@example.com", "phone": "+961******67", "currency": "USD" }
}How the model knows when to call what
Three mechanisms, all supported by the installed SDK (mcp 2.0.0) and all verified by
tests in tests/test_tool_guidance.py:
Server instructions (
SERVER_INSTRUCTIONSinsrc/esim_mcp/server.py) are returned in theinitializeresult, so the model sees them once per session. They cover how to behave: talk naturally, never tell the user to invoke a tool, never show raw requests or responses, never ask for a token, ask only for what the current step needs, check login status before authenticated actions, never claim success without a tool result, confirm amounts before any future financial action, never repeat a complete identifier, that browsing needs no login, that plans are always found for a destination and that no endpoint lists every plan — plus the scope this version actually has.Tool descriptions state, per tool, when to call it, what to ask the user first, what to say afterwards, and what never to do (for example: after
request_login_otp, ask for the code and do not say login is complete; resend only on explicit request; log out only on explicit request; afterfind_bundles_by_country, offer a short numbered list and keep eachbundle_codefor the follow-up).Argument descriptions and tool annotations — each argument says where its value must come from ("the user's email address, exactly as they gave it… never invent"; "the destination country the user named, in their own words"), and annotations mark every read-only tool as such, and
logoutandconfirm_purchasedestructive.create_card_checkoutis deliberately not destructive: it charges nothing, and a client that gates destructive tools should not be made to block a payment page that costs the user nothing.
The purchase conversation, end to end
login request_login_otp → verify_login_otp (needed before any purchase step)
browse find_bundles_by_country / … (no login needed)
prepare quote prepare_purchase(bundle_code, "Wallet") ← NEVER charges
read it back "That's 8.06 USD from your wallet. Buy it?" ← the amount is said out loud
explicit yes "yes, buy it" / "confirm" / "pay now" ← the user's own words
buy confirm_purchase(quote_reference) ← MAY charge; creates the order
result "Bought — France 5GB for 8.06 USD."The middle two steps are not decoration. prepare_purchase never charges and
confirm_purchase may, so the amount has to be spoken and agreed to between them; asking
for a price is not agreement to pay it, and the server instructions say so in those words.
A lapsed quote means starting again: prepare, say the new amount, get a fresh yes.
The card conversation, end to end
login request_login_otp → verify_login_otp
browse find_bundles_by_country / … (no login needed)
prepare quote prepare_purchase(bundle_code, "Card") ← NEVER charges, opens no page
read it back "That's 8.06 USD by card. Shall I open the payment page?"
explicit yes "yes, pay by card" / "open it" / "go ahead" ← the user's own words
open the page create_card_checkout(quote_reference) ← opens a page; charges NOTHING
give the link "Here's the secure payment link — 8.06 USD…" ← and then WAIT
user pays …on Stripe's hosted page, in their browser ← never in this conversation
user says so "I paid" / "can you check?" ← still not proof of anything
check check_card_payment_status(payment_reference) ← the ONLY source of truth
result "Paid — France 5GB for 8.06 USD."The two steps that carry the whole design are "and then WAIT" and "still not proof". Opening a page is not a payment, and returning from one is not either: only the status read can say what happened, so the model is told to wait for the user rather than poll, and never to conclude a payment from a redirect, a success screen or the user's own account of it.
Never, at any point, does the assistant ask for or accept a card number, an expiry, a security code or a cardholder name. There is nowhere to put one, and the guidance says so at every step — in the tool description, in every card result, and in the server instructions.
Tools return short structured facts, never pre-written chat sentences: the model does the
phrasing. Errors come back as a safe code plus a plain message (for example
authentication_required: No active eSIM session…), which the model turns into the right
next step.
"Show me all bundles"
There is no backend endpoint that returns every bundle for every country, so the server
never lets the model imply otherwise. browse_home_catalog returns counts and a small
preview per category, and its result carries a note saying it is an overview, not every
plan; the server instructions say the same. The expected behaviour is that the assistant
explains that browsing needs a destination and asks for a country, a region, or global vs
cruise — never a fabricated list. This is asserted in tests/test_tool_guidance.py and
tests/test_catalog_tools.py, and is scenario 2.9 of the manual QA plan.
Country and region resolution
Chat users say "France", "FR", "FRA" or "Europe". The backend wants a country tag GUID
or a region code. The mapping is always fetched from the backend
(GET /bundles/countries, GET /bundles/region) — never remembered, never invented.
Country matching is deterministic and tried in a fixed priority order:
exact ISO2 → 2. exact ISO3 → 3. exact country name → 4. exact alternative name.
Comparison is case-insensitive with collapsed whitespace, and nothing else — no stemming, no edit distance, no substring matching. The first order that produces matches decides. Then:
exactly one match → its tag GUID is used;
several matches →
ambiguous_country, listing the options for the user to choose;no match →
country_not_found, carrying up to five real catalogue destinations whose names start with or contain the query, for the model to offer. Suggestions are never auto-selected: "Franc" does not silently become France.
Regions match the same way on exact region code then exact region name. The backend's
placeholder values ("Unknown", "") are normalized to nothing and can never match.
Because the MCP SDK renders a tool error as str(exception), the suggestions and choices
are written into the error message rather than a structured payload — otherwise the
model would never see them.
A region code is sent back exactly as the backend spelled it
GET /bundles/by-region/{region_code} selects with region.region_code == region_code
against the same list GET /bundles/region returned, and raises 400 "Region Not Found"
when nothing matches. The comparison is case-sensitive, and region codes originate as
the upstream hub's zone tag (EUROPE, ASIA, GLOBAL) — upper-case by convention, not by
contract.
Region therefore exposes two codes, and they are not interchangeable:
Property | Value | Used for |
| the backend's spelling, untouched | the URL path — the only one that may go on the wire |
|
| display in a tool result, and matching a user's wording |
Matching a user's wording is case-insensitive either way, so a model that passes back the
upper-cased region_code it was shown still resolves to the right region, and the search
still goes out under the backend's own spelling.
This was a live defect: find_bundles_by_region sent Region.code, so any region whose
code was not already upper-case was listed happily by list_regions and then rejected by
/bundles/by-region — which the assistant reported to the user as "no plans for that
region". tests/test_catalog_tools.py::test_a_region_is_searched_by_the_code_the_backend_gave_not_an_upper_cased_one
stubs both spellings and fails if it ever comes back.
The by-region route has no pagination
GET /bundles/by-region/{region_code} declares no page, limit, offset or size
parameter — its only inputs are the path code and the X-Device-Id / Accept-Language /
X-Currency headers, and the whole list comes back in one response. The client sends no
paging parameter, and there is never a second page to fetch. Bounding the result is this
server's own job (see Result sizes), not the backend's.
An error is never an empty catalogue
A failed region search raises a typed error; only a 200 with data: [] produces an empty
result. 401/403 → authentication_required, 429 → rate_limited, 400/404 →
region_not_found, 5xx → catalog_unavailable. The region_not_found raised by the
client (as opposed to the resolver) means the backend rejected a code that came out of its
own region list, so its message says explicitly that this is not a statement that the region
has no plans. The server instructions and the tool description repeat the rule: say a
destination has no plans only when a search returned successfully and said so.
Result sizes
A destination can carry dozens of bundles and the catalogue has hundreds of countries, so every result is bounded and every result says how much was left out.
Result | Default | Maximum |
Bundles ( | 5 | 20 |
Countries / regions ( | 20 | 100 |
Home overview | 8 countries, 20 regions, 5 cruise, 5 global | fixed |
Coverage inside a bundle summary | 3 country names + count | fixed |
Coverage inside | 30 country names + count | fixed |
Ships inside | 20 + count | fixed |
Every bundle result carries total_count (how many matched) and returned_count (how many
are in the result), plus more_available when the list was truncated, and
total_available and filters_applied when filters narrowed it. An oversized limit is
capped rather than rejected, so a model asking for "all of them" still gets a usable
answer.
Raw home/catalogue responses are never returned: icons, marketing copy, category codes, region objects, operator lists and full country lists are dropped in the summary layer.
Multi-user session model
Multiple eSIM users are supported from this first version.
Every MCP caller gets an isolated server-side session, keyed by a SHA-256 digest of its verified identity. There is no global "current user" and no global token.
A session holds: identity source, device id, access token, refresh token, token expiry, eSIM user id, masked email/phone, currency, and creation/update times.
One client can never read or overwrite another client's session: the key is derived from identity, never from an argument, an email or a phone number.
All store operations are asynchronous and concurrency-safe, and each session has its own async lock so parallel calls cause exactly one token refresh.
Client identity
ClientIdentityProvider is the only source of identity.
Streamable HTTP with OAuth configured — the SDK verifies the bearer token; identity is the
(client_id, issuer, subject)principal frommcp.server.auth.middleware.auth_context.get_access_token(). This is the production path.stdio — the transport has no principal; the process itself is the trust boundary, so a configured local identity (
ESIM_MCP_DEV_CLIENT_ID) is used. Development only.Production without a verified principal — the call fails closed with
client_identity_unavailable. The development provider refuses to even be constructed for a production configuration.
An X-Client-Id (or any other) request header is not an identity assertion and is
never trusted — the SDK documents request headers as client-supplied input.
Stable device id
The backend requires X-Device-Id. It is derived as:
HMAC-SHA256(ESIM_MCP_DEVICE_ID_SALT, verified_client_identity) # hex, 64 charsStable across logins and restarts for a given salt, different per client, non-reversible,
and never built from Python's randomized hash(). The salt is never logged, and a missing
or short (< 32 chars) salt aborts startup in production.
Session storage
SessionStore is an abstract, async interface (get/save/delete for sessions and
challenges, plus lock). Phase 1 ships InMemorySessionStore.
The in-memory store is for local or single-instance operation only. State lives in the process heap, so a horizontally scaled deployment would drop sessions whenever a client is routed to another replica, and nothing is encrypted at rest. Multi-instance production must replace it with an encrypted Redis store (encryption at rest, per-session TTL, distributed lock behind
SessionStore.lock). No tool, service or API client code changes when that happens — only the injected store.
Security model
Tokens are never MCP tool arguments and never tool results. An argument would put the token in the model's context, in client-side transcripts and in tool-call logs, and would let any caller present a token that is not theirs — identity, not a bearer value the caller hands over, decides which session is used.
AuthorizationandX-Refresh-Tokenare attached inside the HTTP client and nowhere else.The OTP is never persisted and never logged. The login challenge stores only a masked identifier, the login type, the device id and a timestamp.
JWTs are decoded without signature verification and only to read
exp, purely to schedule refresh. This is never treated as authorization: the backend remains the sole authority for validating tokens.Errors are typed and mapped to safe messages. Stack traces, raw provider responses, JWTs, refresh tokens, OTPs and the backend's
developerMessagenever reach a client.Every log record is rendered and then redacted: authorization headers, access/refresh tokens, OTPs, emails, phone numbers, device ids, session identifiers, ICCIDs, activation codes and future Stripe client secrets. Full request/response bodies are never logged at any level. Logs go to stderr so stdout stays pure JSON-RPC for the stdio transport.
Retry policy: reads may retry with bounded exponential backoff — including every catalogue call, which is always a
GET. OTP request, OTP resend, OTP verification, refresh-token rotation and logout are never retried.Production mode fails fast on unsafe configuration (non-https base URL, missing or weak device-id salt) and fails closed on unverified identity.
Identifier privacy
The Phase 1 QA run found the assistant repeating the user's full email address after login and again inside the profile answer, even though the tool result was masked — it had simply reused what the user typed earlier in the chat. Both layers are now closed:
Output.
get_user_profilemasks at the point of use (mask_email(info.email)), not merely by inheriting a mask from the session, so no branch can return the complete value even though the backend payload contains it. The same holds forverify_login_otp,get_login_statusand the OTP destination. The account id is truncated (b3f1...6666). Useful non-sensitive fields — name, country, language, currency, verification status and wallet balance — are unaffected.Instructions. The server instructions and the
get_user_profiledescription now say to repeat the masked form exactly and never to write out a complete email address, phone number or account id — "not even when the user typed it earlier in this conversation" — and never to read abundle_codeout to the user.Logs. Unchanged and already strict: every record is rendered and redacted before a handler sees it.
Regression tests live in tests/test_privacy.py: they assert that the full address and
number never appear in any tool result (including when the backend returns them in full and
the session recorded no mask) and never survive into a log record, message, structured
extra or traceback. tests/test_repository_hygiene.py additionally fails the build if a
real backend host or a real-looking email address is ever committed.
The identifier the user supplies is still sent to the backend in full where the contract
requires it (/auth/login, /auth/resend-otp, /auth/verify_otp) — masking is about what
comes back out, not about what the platform needs to receive.
Catalogue error behaviour
Every failure the model can act on has its own code and an actionable, data-free message:
Situation | Code | The next action it suggests |
Country not in the catalogue |
| offer the close catalogue names, or another destination |
Several countries match |
| ask the user which one |
Region not in the catalogue |
| name the real regions |
Several regions match |
| ask the user which one |
Unknown bundle code |
| use a code from a result already shown; never invent one |
Filters exclude everything |
| say what is constraining the search and offer to relax it |
Catalogue unreachable |
| tell the user and offer to try again shortly |
Backend too slow |
| tell the user it did not respond in time |
Unparseable response |
| generic failure; nothing internal is shown |
A destination that genuinely has no plans is not an error: the result comes back with
total_count: 0 and a note offering a neighbouring country, a regional plan or a global
plan. Bundles the backend marks is_active: false are dropped from every list, and
get_bundle_details reports availability honestly.
Environment configuration
Variable | Default | Notes |
| – | Required. Backend base URL without |
|
|
|
|
|
|
|
| HTTP transport bind host. |
|
| HTTP transport bind port. Falls back to a platform |
| – | Required in production, >= 32 chars. Ephemeral outside production |
|
| Sent as |
|
| Sent as |
|
| Seconds |
|
| Seconds. The general read budget, sized for cached catalogue lookups |
|
| Seconds. |
|
| Seconds. The card-checkout |
|
| Seconds. The wallet-purchase |
|
| Seconds |
|
| Seconds |
|
| Refresh this long before |
|
| Pending-OTP lifetime |
|
| Prepared-quote lifetime (30…1800) |
|
| Simultaneous prepared quotes per user (1…50) |
|
|
|
|
| stdio/dev identity; ignored in production |
Only these prefixed names are read by the settings model, so a platform-provided HOST,
ENVIRONMENT or LOG_LEVEL cannot silently reconfigure the server.
Read budgets are per-route, never global
There are four, and widening one never widens another. ESIM_MCP_READ_TIMEOUT is the general
one and it is sized for what most of this server does: cached catalogue lookups that answer in
well under a second. The other three exist because three specific routes do far more work than
that, and each is applied to its own route and to nothing else.
ESIM_MCP_ACCOUNT_READ_TIMEOUT covers the two authenticated account-history reads —
GET /api/v1/user/my-esim behind get_my_esims, and GET /api/v1/user/order-history behind
get_order_history. Neither is cached: the platform builds the answer per user and per
request, re-reading every bundle the account owns and re-localizing every row, so the time it
takes grows with the account. On a real account that ran past the 20-second general budget
while the portal — which imposes no budget of its own — got the same answer and rendered it.
The default of 120 is deliberately well clear of the measured latency rather than tight
against it.
Two rules go with that budget, and both are enforced in code:
One attempt. These two reads do not use the shared three-attempt read retry. Three attempts at a two-minute budget is six minutes of a chat client waiting to be told the platform was slow. One request goes out per tool call, and one answer or one typed timeout comes back.
A timeout is not an empty account, and not an authentication failure. A read that runs out of budget raises the typed
account_read_timeouterror, which says in words that the account was not read rather than that it is empty. The access token is not refreshed, the read is not replayed, and nothing retries on its own. Only a real401from the platform causes a refresh, and that refresh replays the read exactly once.
ESIM_MCP_CHECKOUT_READ_TIMEOUT and ESIM_MCP_PURCHASE_READ_TIMEOUT are the two payment
budgets and are unrelated to the above; see the purchase and card-checkout sections for why
they are sized the way they are. Changing the account budget leaves both untouched, and
changing either of them leaves the account budget untouched.
The one deliberate exception is the listening port, and it lives in the HTTP entry point
(esim_mcp/http_app.py) rather than in the settings model: ESIM_MCP_PORT > a
platform-supplied PORT (Render, Heroku, Cloud Run) > the 8080 default. A hosted service
has to listen where its platform routes, the port is not a security decision, and the
precedence keeps an explicit setting of your own on top. A PORT that is not a valid port
number aborts startup rather than falling back.
The QA URL belongs in your local, git-ignored .env — never in committed source.
.env.example contains placeholders only. Tests never read a .env.
QA example — copy into .env and replace both placeholders with your QA URL and a long
random local secret:
ESIM_API_BASE_URL=https://qa-placeholder.example.com
ESIM_MCP_ENVIRONMENT=development
ESIM_MCP_TRANSPORT=stdio
ESIM_MCP_DEVICE_ID_SALT=replace-with-a-long-random-local-secret
ESIM_MCP_DEFAULT_LOCALE=en
ESIM_MCP_DEFAULT_CURRENCY=USDESIM_MCP_ENVIRONMENT describes this server's mode, not the backend's: development
(or qa) keeps the local stdio identity available while pointing at the QA backend. Only
production switches on fail-closed identity and the https/salt requirements.
The MCP server needs nothing beyond that public backend URL — no Supabase, Stripe, eSIM Hub or database credentials are used, requested or accepted anywhere in this project.
Local setup
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -e ".[dev]"
cp .env.example .env # then fill in your QA valuesRun over stdio
ESIM_MCP_TRANSPORT=stdio python -m esim_mcp.server
# or, after installing the project:
esim-mcpRun over Streamable HTTP
ESIM_MCP_TRANSPORT=streamable-http ESIM_MCP_PORT=8080 python -m esim_mcp.server
# MCP endpoint: http://127.0.0.1:8080/mcp
# health: http://127.0.0.1:8080/healthDocker:
docker build -t esim-mcp .
docker run --rm -p 8080:8080 --env-file .env esim-mcpDeploying over Streamable HTTP (Render and friends)
src/esim_mcp/http_app.py is the deployed entry point. create_app() builds the ASGI
application around the same MCPServer the stdio entry point uses and serves exactly two
routes:
Route | Purpose |
| Streamable HTTP MCP endpoint. The remote server URL a client is given is this URL |
| Liveness for the platform's health check. No backend call, no session state |
Nothing else is mounted. In particular there is no /register and no
/.well-known/oauth-*: the SDK publishes those only when an OAuth AuthSettings and token
verifier are configured, and none is (see Security model). A client that probes them gets
a 404, which is the honest answer from a server that cannot issue or verify tokens — a stub
there would advertise an authorization server that does not exist.
Build command
pip install --upgrade pip && pip install .requirements.txt is the legacy FastAPI skeleton's dependency list and does not
install this server. pip install . installs esim_mcp and its real dependencies from
pyproject.toml.
Start command
uvicorn esim_mcp.http_app:create_app --factory --host 0.0.0.0 --port $PORTpython -m esim_mcp.server with ESIM_MCP_TRANSPORT=streamable-http is equivalent: it
serves the same app and resolves the same host and port.
Environment
Variable | Value | Why |
| your backend base URL, no | required |
|
| keeps the explicit QA/dev identity available. |
|
| |
|
| listen on the platform's interface, and drop the SDK's loopback-only |
| a long random value | keeps device ids stable across restarts |
|
|
The port comes from the platform's own PORT. ESIM_MCP_PORT still wins when it is set —
set it only if you mean to override the platform.
Verify a deployment
curl -sS https://<your-service>.onrender.com/health
curl -sS -X POST https://<your-service>.onrender.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'The second call must return 200 with the server's initialize result. A 404 means the
platform is running something other than this app (the legacy app.main:app skeleton, for
instance); a 421 Invalid Host header means ESIM_MCP_HOST was left at its loopback
default.
Who the caller is. Over Streamable HTTP without OAuth the transport has no verified principal, so every caller of a deployed instance shares one server-side session and one device id. That is acceptable for a single-tester QA URL and is exactly why
ESIM_MCP_ENVIRONMENT=productionrefuses to serve without a token verifier. Wiring the verifier remains the prerequisite for a shared deployment — deploying is not that step.
Connecting Claude to this server (stdio)
Which command starts the server
.venv/bin/python -m esim_mcp.serverpython -m esim_mcp.server calls main() in src/esim_mcp/server.py, which reads the
settings, configures logging and runs the transport named by ESIM_MCP_TRANSPORT
(stdio by default). esim-mcp is the same entry point once the project is installed.
The interpreter must be the one with the dependencies installed — the project venv.
Which working directory is required
The project root (the directory holding pyproject.toml). Two things depend on it:
PYTHONPATH=src resolves from there when the project is not pip-installed, and .env is
read from the current directory.
How environment variables are loaded
pydantic-settings reads the process environment first, then ./.env as a fallback, and
only the ESIM_* names listed under Environment configuration. Two equivalent options:
keep everything in the git-ignored
.env(recommended locally — no secrets in any MCP config file);or set the variables in the MCP client's
envblock, which then wins over.env.
How Claude discovers the server
Claude Code, project-scoped (already committed here): .mcp.json in the project root
declares the server. It deliberately contains no URL, no salt and no absolute path — the
QA URL and salt come from your local .env:
{
"mcpServers": {
"esim": {
"type": "stdio",
"command": ".venv/bin/python",
"args": ["-m", "esim_mcp.server"],
"env": { "PYTHONPATH": "src" }
}
}
}Open Claude Code in this directory and approve the project server when prompted
(/mcp lists it). Adjust command if your virtualenv lives elsewhere.
Claude Desktop (user-level config, not managed by this repo — edit it yourself): it does not run the server from this directory, so give absolute paths and the variables inline. Placeholders only below; substitute your own values and never commit the result:
{
"mcpServers": {
"esim": {
"command": "/absolute/path/to/mcp-service/.venv/bin/python",
"args": ["-m", "esim_mcp.server"],
"cwd": "/absolute/path/to/mcp-service",
"env": {
"PYTHONPATH": "src",
"ESIM_API_BASE_URL": "https://qa-placeholder.example.com",
"ESIM_MCP_ENVIRONMENT": "development",
"ESIM_MCP_TRANSPORT": "stdio",
"ESIM_MCP_DEVICE_ID_SALT": "replace-with-a-long-random-local-secret",
"ESIM_MCP_DEFAULT_LOCALE": "en",
"ESIM_MCP_DEFAULT_CURRENCY": "USD"
}
}
}
}How to verify the nineteen tools are available
In Claude Code:
/mcp→ theesimserver → its tool list.Or ask in chat: "What eSIM tools do you have?" — expect exactly
request_login_otp,resend_login_otp,verify_login_otp,get_login_status,get_user_profile,logout,list_countries,list_regions,browse_home_catalog,find_bundles_by_country,find_bundles_by_region,list_cruise_bundles,get_bundle_details,prepare_purchase,get_prepared_purchase,cancel_prepared_purchase,confirm_purchase,create_card_checkoutandcheck_card_payment_status. There must be nothing calledbuy,pay,capture,top_up,voucher,refund,activateorprovision.Or without a client at all (
tools/listis answered locally — it makes no backend call):
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| PYTHONPATH=src .venv/bin/python -m esim_mcp.serverHow to inspect startup errors
The server logs JSON to stderr (stdout carries only JSON-RPC). Claude Code shows it
under /mcp → the server → logs; Claude Desktop writes it to its own MCP log files. To
see it directly, run the command above in a terminal: a bad configuration fails fast and
loudly, e.g. ESIM_API_BASE_URL … Field required, ESIM_MCP_DEVICE_ID_SALT is required in production, or ESIM_API_BASE_URL must use https:// in production. Raise detail with
ESIM_MCP_LOG_LEVEL=DEBUG — secrets stay redacted at every level.
How to disconnect
Remove or rename .mcp.json (or the entry in the Claude Desktop config) and restart the
client; in Claude Code you can also decline/disable the project server from /mcp. The
client owns the process lifetime — closing it stops the server, which closes its HTTP pool
and drops all in-memory sessions.
Streamable HTTP: run with ESIM_MCP_TRANSPORT=streamable-http and point the client at
http://<host>:<port>/mcp.
Testing
Automated (no network — httpx is mocked with respx):
pytest
ruff check .
ruff format --check .Manual QA against the real backend, all five conversation-level plans:
docs/QA_AUTHENTICATION_TEST.md— email login, phone login, status/logout, error cases, multi-user isolation, and the masked-identifier check.docs/QA_CATALOGUE_TEST.md— countries, a country search, sorting and filtering, "the second one", regions, global, cruise, and the "show me all bundles" scenario, plus how to verify the displayed values against the QA API by hand.docs/QA_PURCHASE_PREPARATION_TEST.md— preparing a wallet quote and a card quote, insufficient balance, expiry, logout invalidation, client isolation, and the backend checks that prove no order, charge, payment intent or provisioning happened at preparation time.docs/QA_PURCHASE_EXECUTION_TEST.md— the one plan that spends real money: consent before the charge, the single wallet purchase, the replay check, the ambiguous-outcome drill, and the database and wallet checks that prove exactly one order and one debit exist. Read its preconditions before running it.docs/QA_CARD_CHECKOUT_TEST.md— the other plan that spends real money, on a real card: consent before the link, the single payment page, the replay check, paying and not paying, the redirect-is-not-proof drill, the ambiguous outcome, and the checks that prove exactly one Stripe Session and one order exist. Read its preconditions before running it.
No automated test ever buys anything or opens a real payment page. The suite mocks httpx
with respx, so the purchase route and both card routes are stubs in every test;
tests/test_repository_hygiene.py fails if a real backend host or a real-looking email
address is committed. The only real payments in this project are the ones a human makes
deliberately by following the QA plans above.
Known limitations
In-memory sessions. Single instance only; replace with encrypted Redis before scaling out (see above).
In-memory purchase quotes, lost on restart.
InMemoryPurchaseQuoteStorekeeps quotes in the process heap, so a restart drops every prepared quote and two replicas do not share them. This is expected rather than a defect: a quote reserves nothing and has no backend counterpart, so the worst case is that the user is asked to prepare again. Replacing it needs no change to the MCP tools — implementPurchaseQuoteStoreand pass it tobuild_components(quote_store=...).A quote is a snapshot, not a hold. The price and the wallet balance in a prepared quote were true when it was made. Neither is reserved, and both can change a second later, which is why quotes are short-lived, why re-preparing supersedes rather than reuses, and why the final payable amount is never reported as confirmed. The platform re-reads the plan, the price and the balance authoritatively at purchase time, so a stale quote is refused there rather than honoured.
In-memory execution records, lost on restart. A restart between sending a purchase and recording its outcome loses this server's copy of the idempotency key. The platform's record survives, so the purchase is still protected from duplication at the backend, but this server can no longer replay the answer and a fresh quote would carry a fresh key. Implement
PurchaseExecutionStoreand pass it tobuild_components(execution_store=...)to make this durable.In-memory card checkouts, lost on restart. A restart loses this server's copy of an open payment page and its idempotency key. The platform's record survives, so the user's payment is unaffected and the same key would still resolve there, but this server can no longer replay the link or check that payment, and a fresh quote would carry a fresh key. Implement
CardCheckoutStoreand pass it tobuild_components(checkout_store=...)to make this durable.A card payment is completed by the user, not by this server.
create_card_checkoutopens the page and stops. Whether money moves is decided on Stripe's hosted page, in the user's browser, and this server learns it only by asking the platform. It cannot capture, confirm, cancel or refund a card payment, and it never sees a card number, an expiry or a security code — there is no argument, no field and no route through which one could arrive.A card payment can end up genuinely unknown. If the platform reports the payment as ambiguous, this server stops: no further checks, no second page, and a message that says plainly that it is neither confirmed nor failed. Resolving that needs eSIM support, not a retry.
No refund, cancellation or provisioning. Once a purchase completes there is no tool here that can reverse it, cancel the order, or install, activate or check the usage of the eSIM. That is why consent is required before the call rather than recoverable after it.
DCB is not offered. The backend's
PaymentTypeEnumalso hasDCB; onlyWalletandCardcan be prepared here, andDCBis refused with the same message as any other unsupported method.Tool guidance is advisory, enforcement is not. Instructions and descriptions steer the model's choices, but the model can still call a tool at an odd moment. Nothing security-relevant depends on it: the server enforces its own rules regardless — no tool takes or returns a token, sessions are keyed by verified identity, a resend needs a pending challenge, and mutations are never retried. Wording changes therefore affect conversation quality, never safety.
Identity depends on the transport. A verified principal exists only when the server runs over Streamable HTTP with OAuth configured (
MCPServer(auth=..., token_verifier=...)). Over stdio there is no transport principal at all, so the development identity is used and every caller of that process is one user — correct for a local single-operator setup, not for shared hosting. Production refuses to start a session without a verified principal rather than guessing. Wiring the OAuth token verifier for a deployed environment is the next step and is intentionally not configured here.No machine-readable backend error code. The envelope's
titleis a localized message that falls back to the backend's internal error key when no translation exists, so error classification matches both forms. A dedicated error-code field in the backend envelope would make this exact.OTP delivery window is a local setting. The backend does not return an OTP expiry, so
expires_in_secondsreflectsESIM_MCP_LOGIN_CHALLENGE_TTL_SECONDS.Identifier repeated at verify/resend. The challenge deliberately stores only the masked identifier, and the backend requires the real email/phone in the resend and verify bodies, so the caller supplies it again. The server checks it against the pending challenge's mask.
The backend logs refresh tokens internally. This server does not repeat that: refresh tokens are
SecretStr, travel only in theX-Refresh-Tokenheader, and are redacted from every log record.No full-catalogue and no free-text search. The platform has no endpoint returning every bundle for every country, and no real search endpoint.
list_countriestherefore resolves and suggests against the country list rather than searching, and every bundle result is scoped to one destination. This is a backend capability limit, not a client choice.The country list is fetched per call. Resolving a country costs one extra
GETbefore the bundle call. The backend caches both, and the read is retryable, so no local cache is kept — one less thing to invalidate. If profiling ever justifies it, a short TTL cache belongs inCatalogApiClient, behind the same interface.Catalogue prices are display prices. They come from the backend's exchange-rate conversion and may exclude final payment tax, which is why every priced result carries the tax note. The final amount can only be confirmed by a purchase flow, which does not exist in this codebase.
Data allowances are compared via the display string.
gprs_limitis unit-less on its own, sominimum_data_gbreads the unit out ofgprs_limit_display("5.0 GB"). A bundle whose allowance cannot be established is excluded from a minimum-data filter rather than assumed to qualify.Bundle deduplication happens upstream. The backend already collapses bundles sharing a data allowance and validity, keeping the cheapest, and sorts by price — so a "complete" list for a country is the backend's deduplicated one, not every underlying SKU.
The legacy FastAPI health skeleton from the initial scaffold still lives in
app/with its own tests. It is unrelated to the MCP server and can be removed once it is no longer wanted.
Available Tools
19 toolsbrowse_home_catalogBrowse the eSIM catalogueARead-onlyIdempotent
Show what the catalogue offers: how many destinations and regions exist, plus a few cruise and global plans. WHEN: the user asks something broad like "show me all bundles", "what do you have" or "what plans are there". IMPORTANT: there is no endpoint that returns every plan for every country, so never claim to be showing all of them. Use this overview to say what kinds of plans exist, then ask which country or region the user is travelling to, or whether they want a global or cruise plan. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark readOnly, openWorld, idempotent, and non-destructive. The description adds crucial operational context beyond those: 'there is no endpoint that returns every plan for every country' and 'This needs no login.' This explains a real limitation and authentication requirement that annotations do not convey.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is compact, front-loaded with the core purpose, and every sentence earns its place: WHAT it shows, WHEN to use, IMPORTANT limitation, and NEXT STEP. No filler or repetition of schema fields.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a 0-required-parameter tool with a rich output schema and strong annotations, the description covers all needed context: what result looks like, usage triggers, endpoint limitation, and follow-up action. It also names the no-login requirement. Sibling tools provide further avenues but are not necessary to enumerate.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema covers 100% of parameters with clear descriptions for locale ('Optional language tag for platform text, e.g. 'en') and currency ('Optional ISO-4217 currency code, e.g. 'USD'). The description does not repeat or add param syntax, which is acceptable because the schema already carries full weight, so baseline 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states a specific action and resource: 'Show what the catalogue offers: how many destinations and regions exist, plus a few cruise and global plans.' It distinguishes itself from siblings by explicitly noting that there is no endpoint returning every plan, making its overview role distinct from country/region-specific tools like find_bundles_by_country and list_regions.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The WHEN clause gives explicit triggers: 'user asks something broad like "show me all bundles", "what do you have" or "what plans are there".' It also provides a when-not ('never claim to be showing all of them') and guides the next step: 'ask which country or region the user is travelling to, or whether they want a global or cruise plan.' It even notes login not required, which is a precondition statement.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
cancel_prepared_purchaseCancel a prepared eSIM quoteADestructiveIdempotent
Throw away a quote you prepared earlier. This discards local information only. WHEN: the user says they do not want the plan you prepared, or wants to start again. IMPORTANT: there is no order behind a prepared quote, so nothing is cancelled at the eSIM platform and nothing is refunded or reversed -- there was never a charge. Tell the user the prepared quote was discarded; never tell them an order was cancelled. This never contacts the eSIM platform.
| Name | Required | Description | Default |
|---|---|---|---|
| quote_id | Yes | The quote reference returned by prepare_purchase in this conversation. Never invent, guess or edit one. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description goes well beyond the annotations by disclosing the operation is purely local ('discards local information only'), never contacts the eSIM platform, and has no refund/charge implications. It also specifies the correct user-facing messaging, which is valuable behavioral context not present in the structured annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear 'WHEN' and 'IMPORTANT' labels, each sentence earns its place, and it avoids redundancy. It is neither too terse nor overly verbose for the complexity of the operation.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Combined with the schema and annotations, the description covers the action, scope, side effects, user communication, and parameter source. The presence of an output schema accounts for return-value details, making this description fully adequate for a single-parameter cancellation tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already fully documents the only parameter (quote_id: 'returned by prepare_purchase... Never invent, guess or edit one'), achieving 100% schema description coverage. The description adds no further parameter detail, so the baseline score of 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a clear verb+resource ('Throw away a quote you prepared earlier') and explicitly differentiates from siblings like prepare_purchase and confirm_purchase by clarifying it only discards a local quote, not an order. The title 'Cancel a prepared eSIM quote' reinforces the purpose.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The 'WHEN' clause gives explicit usage context: 'the user says they do not want the plan you prepared, or wants to start again.' It also provides communication guidance ('never tell them an order was cancelled'). However, it does not explicitly name alternative tools or provide when-not conditions, so it stops short of a full 5.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
check_card_payment_statusCheck what happened to a card paymentARead-onlyIdempotent
Ask the eSIM platform what happened to a card payment you started with create_card_checkout. This is the ONLY way to know whether a card payment went through: the platform's own payment webhook confirms the payment and sets up the eSIM, and this tool reads the result of that. Nothing here can make a payment succeed. WHEN: the user says they have paid, or asks you to check. Do not call it on a loop and do not keep checking on your own -- one check per request, and only when there is a reason to. NEVER treat any of these as proof of payment: the user returning to this conversation, a browser redirect, a success screen on the payment page, or the user simply saying it worked. Only this tool's answer counts, and there is no argument here through which you could tell it that a payment succeeded. The answer means exactly what it says:
not paid yet: give the user the link again and wait; do not ask for card details.
payment received: the money arrived but the eSIM is not ready -- never say the plan is active, installed or activated; offer to check again in a minute.
complete: the plan was paid for by card and the order is done. Tell the user plainly, with the plan name and the amount.
failed, expired or cancelled: nothing was charged. Say so plainly and offer to prepare the plan again and open a new payment page. IF THE RESULT IS UNCLEAR: if the payment is reported as unresolved or as needing support, never say it succeeded and never say it failed. Do not check it again, do not open another payment page and do not prepare another quote for the same plan -- tell the user the platform is investigating it and that they should contact eSIM support.
| Name | Required | Description | Default |
|---|---|---|---|
| payment_reference | Yes | The payment reference returned by create_card_checkout in this conversation. Never invent, guess or edit one, and never take one from the user. There is no argument here for telling this tool that the payment succeeded: whether it did is read from the eSIM platform and from nowhere else. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the annotations (readOnly, idempotent, openWorld), the description adds crucial behavioral context: the tool cannot make a payment succeed, it only reads results from the platform, and it has no argument for asserting success. It also explains the exact meaning of each possible result, which is not deducible from annotations alone.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but well-structured with clear sections (WHEN, NEVER, status meaning, unclear result). Every sentence earns its place by preventing a costly mistake or clarifying a critical nuance. The front-loaded purpose makes the tool's role immediately clear.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers the tool's purpose, trigger conditions, non-triggers, result interpretation, and ambiguous/unresolved cases. With an output schema present, no return format explanation is needed; the description fully prepares the agent to act correctly in all described scenarios.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Although the schema already describes the parameter at 100% coverage, the description adds significant semantics: the payment_reference must come from create_card_checkout in this conversation, must never be invented or taken from the user, and no argument can override the platform's state. This is high-value guidance beyond the raw schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's function: asking the eSIM platform what happened to a card payment started with create_card_checkout. It also distinguishes this tool from siblings by noting it is the ONLY way to know whether a card payment went through, making it impossible to confuse with other payment or checkout tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit when-to-use guidance is given: use it when the user says they have paid or asks to check, and only once per request. It also tells the agent when NOT to use it (not on a loop, not on its own) and clarifies that no other evidence counts as proof, effectively steering away from alternative interpretations.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
confirm_purchaseBuy a prepared eSIM plan with the user's walletADestructiveIdempotent
Buy the plan in a quote you prepared earlier, paying from the user's eSIM wallet. THIS SPENDS REAL MONEY: it creates an order at the eSIM platform and debits the wallet balance. It cannot be undone from here, and there is no refund tool. WHEN: only after the user has been told the plan, the exact amount and the payment method, and has explicitly said yes to that amount -- "yes, buy it", "confirm", "go ahead". Never call this on your own initiative, never to "check" something, and never because the user merely asked to prepare or price a plan. Wanting a quote is not agreeing to be charged. FIRST: prepare the plan with prepare_purchase, read the amount back to the user, and wait for their answer. If the quote has expired, prepare it again and get their agreement to the new amount -- never confirm an amount they have not heard. Pass only the quote reference from your own prepare_purchase result. The plan, the price, the currency and the payment method come from that stored quote, so you cannot supply or change any of them here. Wallet only in this version. A quote prepared for card payment cannot be bought. SAFE TO REPEAT: calling this twice with the same quote reference returns the stored result of the first purchase and never buys the plan twice. AFTER SUCCESS: tell the user plainly that the plan was bought and paid for from their wallet, with the plan name and the amount. IF THE RESULT IS UNCLEAR: if the outcome is reported as unknown or as needing support, never say the purchase succeeded and never say it failed. Do not prepare a new quote for the same plan and do not try to buy it again -- say the platform is confirming it, and offer to check the same purchase again.
| Name | Required | Description | Default |
|---|---|---|---|
| quote_reference | Yes | The quote reference returned by prepare_purchase in this conversation, for the plan the user has just explicitly agreed to buy. Never invent, guess or edit one, and never take one from the user -- use the reference from your own prepare_purchase result. If you do not have one, or it has expired, prepare the plan again and ask the user to confirm the new amount before calling this. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description goes beyond annotations by stating that it 'SPENDS REAL MONEY', debits the wallet, and cannot be undone. It also discloses idempotent repeat behavior, unclear-result handling, and post-success actions. No contradiction with annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but every sentence adds value, with clear headings for WHEN, FIRST, SAFE TO REPEAT, AFTER SUCCESS, and IF RESULT IS UNCLEAR. It is well-structured and free of fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a high-stakes purchase tool, the description covers all necessary context: prerequisites, execution, idempotency, post-success reporting, and failure uncertainty. The presence of an output schema means return values need not be described, so this is fully complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, but the description adds crucial context: the quote_reference must come from the agent's own prepare_purchase result, must not be invented or user-supplied, and expired quotes require re-preparation. This significantly constrains the parameter beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's function: buying a previously prepared eSIM plan using the wallet. The verb 'buy' plus the resource (prepared plan) and payment method (wallet) make it distinct from siblings like prepare_purchase and create_card_checkout.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The 'WHEN' and 'FIRST' sections provide explicit conditions for use: only after user confirmation of a specific amount, and after prepare_purchase has been called. It also lists exclusions (never on own initiative, never for checking) and differentiates from card payment.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
create_card_checkoutOpen a secure card payment page for a prepared eSIM planAIdempotent
Open the eSIM platform's secure hosted checkout page for a plan the user prepared for card payment, and return the payment link. THIS CHARGES NOTHING BY ITSELF: it creates a payment page, and the user pays on that page or does not. WHEN: only after the user has been told the plan, the exact amount and the payment method, and has explicitly said yes to paying that amount by card -- "yes, pay by card", "open the payment page", "go ahead". Never call this on your own initiative, and never because the user merely asked to prepare or price a plan. Wanting a quote is not agreeing to pay. FIRST: the user must be signed in, and the plan must already be prepared with prepare_purchase for 'Card'. Read the amount back to them and wait for their answer. If the quote has expired, prepare it again and get their agreement to the new amount -- never start a payment for an amount they have not heard. Pass only the quote reference from your own prepare_purchase result. The plan, the price, the currency and the payment method come from that stored quote, so you cannot supply or change any of them here. NEVER ask the user for a card number, an expiry date, a security code, a cardholder name or any other card detail, and never offer to enter one for them. Card details are entered only on Stripe's own secure hosted page, which the returned link opens in the user's own browser. This server never sees a card. SAFE TO REPEAT: calling this twice for the same prepared quote returns the same payment link and never opens a second page, so the user is never asked to pay twice. AFTER SUCCESS: give the user the link, the plan and the amount, and say plainly that nothing has been charged yet. Then wait -- do not check the payment until they say they have paid or ask you to check, and use check_card_payment_status when they do.
| Name | Required | Description | Default |
|---|---|---|---|
| quote_reference | Yes | The quote reference returned by prepare_purchase in this conversation, for the Card quote the user has just explicitly agreed to pay. Never invent, guess or edit one, and never take one from the user -- use the reference from your own prepare_purchase result. If you do not have one, or it has expired, prepare the plan again for card payment and ask the user to confirm the new amount before calling this. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description adds context beyond annotations: it clarifies that the tool charges nothing by itself, is idempotent (returns same link, no second page), requires user sign-in, and never handles card details. This goes well beyond the idempotentHint annotation and gives a complete picture of side effects.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but well-structured with clear headings (WHEN, FIRST, NEVER, etc.). Every sentence adds necessary safety or usage information. Slightly verbose, but the complexity and risk of payment tools justify it.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Covers all necessary context: prerequisites, step-by-step process, limitations, post-success expectations, and relation to other tools. The output schema handles return structure, and the description explains the semantics of the returned link. Very complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already covers the single parameter quote_reference well, with concrete instructions. The description reinforces the semantics by emphasizing to use only the reference from prepare_purchase, never inventing or accepting user-supplied references, and explaining the consequences of an expired quote.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states it opens a secure hosted checkout page for a prepared eSIM plan and returns the payment link. It distinguishes itself from sibling tools by explicitly referencing prepare_purchase and check_card_payment_status, making its unique role clear.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides explicit WHEN/FIRST/NEVER guidance. It states to call only after explicit user consent, only with a prepared quote, and to use check_card_payment_status after payment. Also explicitly says never to call on its own initiative or for mere quotes.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
find_bundles_by_countryFind eSIM plans for a countryARead-onlyIdempotent
Find the eSIM plans available for one country. WHEN: the user names a destination country -- "I need an eSIM for France". Pass the country exactly as the user said it (name, ISO2 or ISO3); this tool resolves it against the platform's own list. Only pass a filter the user actually stated (budget, minimum data, minimum validity, unlimited). AFTER SUCCESS: present a few options as a numbered list with data, validity and price, and ask whether the user wants details on one of them. Keep the bundle_code of each option so that "the second one" can be looked up with get_bundle_details; never read a code out to the user and never invent one. The result covers this destination only -- never describe it as the platform's whole catalogue. Prices are catalogue prices and may not include final tax. This needs no login; browsing must never be blocked behind signing in.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | How many plans to return. Default 5, maximum 20. Keep it small: a chat reply lists a handful of options, and the user can ask for more. | |
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| country | Yes | The destination country the user named, in their own words: a country name ('France', 'United Arab Emirates'), an ISO2 code ('FR') or an ISO3 code ('FRA'). Pass what the user said; this tool resolves it against the platform's own country list. Never invent a country identifier or code. | |
| sort_by | No | Optional ordering: 'price' (cheapest first), 'data' (largest allowance first) or 'validity' (longest first). Omit to keep the platform's own order, which is already price-first. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| max_price | No | Optional maximum price, in the result currency. Only pass a budget the user actually stated. | |
| unlimited_only | No | Set true only when the user explicitly asks for unlimited-data plans. | |
| minimum_data_gb | No | Optional minimum data allowance in GB. Unlimited plans always satisfy this. | |
| minimum_validity_days | No | Optional minimum validity in days. A 1-month plan counts as 30 days. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark the tool as readOnly, openWorld, idempotent, and non-destructive. The description goes further by stating 'This needs no login,' warning that 'Prices are catalogue prices and may not include final tax,' and clarifying the result covers only the destination. These are meaningful behavioral disclosures beyond the annotations and do not contradict them.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is structured with WHEN and AFTER SUCCESS sections, making it scannable. Every sentence provides actionable guidance: trigger condition, parameter handling, output formatting, scope limitation, and auth note. It is somewhat long but justified given the tool's complexity; no filler exists.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Despite 9 parameters, the description covers the trigger, parameter discipline, result presentation (numbered list with bundle_code linkage), destination-only scope, pricing caveat, and no-login requirement. The presence of an output schema means return-value details are not needed in the description, and the post-success workflow is fully explained.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100% with per-parameter explanations, so the baseline is 3. The description adds critical semantics for the country parameter ('Pass the country exactly as the user said it... Never invent a country identifier') and for filters ('Only pass a filter the user actually stated'), which meaningfully enriches parameter usage beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'Find the eSIM plans available for one country,' a specific verb+resource+scope combination. It clearly distinguishes from the sibling tool find_bundles_by_region by emphasizing 'one country,' and the AFTER SUCCESS section reinforces this singular scope.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The WHEN clause explicitly says to use this tool when 'the user names a destination country' and gives an example. It also instructs to only pass filters the user actually stated and mentions get_bundle_details as a follow-up tool. It does not explicitly name find_bundles_by_region as an alternative, though the distinction is implied.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
find_bundles_by_regionFind eSIM plans for a regionARead-onlyIdempotent
Find the multi-country eSIM plans available for one region, such as Europe. WHEN: the user names a region, or is visiting several countries in the same area. Pass the region as the user said it (name or code); this tool resolves it against the platform's own region list. Filters and presentation work exactly as in find_bundles_by_country: only pass filters the user stated, then offer a short numbered list and keep each bundle_code for follow-up. The result covers this region only. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | How many plans to return. Default 5, maximum 20. Keep it small: a chat reply lists a handful of options, and the user can ask for more. | |
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| region | Yes | The region the user named: a region name ('Europe') or a region code ('EUR'), as returned by list_regions or browse_home_catalog. Never invent a region code. | |
| sort_by | No | Optional ordering: 'price' (cheapest first), 'data' (largest allowance first) or 'validity' (longest first). Omit to keep the platform's own order, which is already price-first. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| max_price | No | Optional maximum price, in the result currency. Only pass a budget the user actually stated. | |
| unlimited_only | No | Set true only when the user explicitly asks for unlimited-data plans. | |
| minimum_data_gb | No | Optional minimum data allowance in GB. Unlimited plans always satisfy this. | |
| minimum_validity_days | No | Optional minimum validity in days. A 1-month plan counts as 30 days. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, and non-destructive, but the description adds valuable context beyond that: 'This needs no login,' 'resolves it against the platform's own region list,' and 'The result covers this region only.' It also mentions keeping bundle_code for follow-up, which informs the agent about downstream behavior. This is more than just restating annotations, though it doesn't disclose return formatting (which is covered by the output schema).
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is about five sentences and each earns its place: it states purpose, gives a WHEN condition, explains region resolution, references the sibling tool for filter/presentation behavior, and notes no login requirement. It is front-loaded with the main action and not overly verbose, though it could be tightened by removing some redundancy like 'such as Europe' and 'The result covers this region only' (both are useful, so not wasted).
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool has an output schema and rich annotations, the description covers the key contextual aspects: when to use it, how to pass the region, that it requires no login, and that it behaves like the country-based sibling. It does not mention edge cases like invalid region codes, but the schema instruction 'Never invent a region code' partially covers that. Overall, it is sufficiently complete for a read-only search tool with a clear usage model.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the baseline is 3. The description adds a bit of semantic guidance for the region parameter ('Pass the region as the user said it (name or code)') and for filters ('only pass filters the user stated'), but most parameters are already well-documented in the schema. The description does not compensate heavily for parameter semantics, as the schema already does that work.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb+resource: 'Find the multi-country eSIM plans available for one region, such as Europe.' This clearly states what the tool does and distinguishes it from the sibling find_bundles_by_country by emphasizing 'multi-country' and 'region.' It also explicitly says 'The result covers this region only,' reinforcing scope.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description includes an explicit 'WHEN' clause: 'the user names a region, or is visiting several countries in the same area.' It also tells the agent to pass the region as the user said it and to filter/present exactly as in find_bundles_by_country, referencing a sibling for consistency. However, it does not explicitly state when not to use this tool (e.g., when a single country is named), so it falls short of a fully explicit exclusion list.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_bundle_detailsGet eSIM plan detailsARead-onlyIdempotent
Read the full details of one plan: data, validity, price, coverage, plan type, activation policy and availability. WHEN: the user asks about a specific plan you already listed -- "tell me more about the second one". Pass the bundle_code of the option the user picked, taken from the result you already have. Never invent a code, and never ask the user to read one out. AFTER SUCCESS: describe the plan in ordinary language and pass on the price note: the displayed catalogue price may not include final tax. Nothing here reserves, orders or charges anything -- this version cannot buy a plan. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| bundle_code | Yes | The bundle_code of a plan from a result you have already received. Never invent, guess or edit one, and never ask the user to read one out -- if you do not have it, search again and use the code from the option the user picked. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations declare readOnlyHint and idempotentHint true; the description reinforces this by stating 'Nothing here reserves, orders or charges anything'. It also adds context about no login required and a price tax caveat, going beyond the annotations without contradicting them.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Organized with purpose, WHEN, and AFTER SUCCESS sections. Front-loaded with the main action and every sentence adds useful operational or behavioral information without fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, return-value details are not needed. The description covers purpose, usage, parameter source, side-effect profile, and even a pricing nuance, making it fully complete for this simple read tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100% and the description's guidance for bundle_code largely repeats the schema's own description ('from a result you have already received; never invent'). It does not add significant meaning beyond what the schema already provides, so the baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Description states 'Read the full details of one plan' and lists the specific fields (data, validity, price, coverage, plan type, activation policy, availability). This uses a specific verb (Read) and resource, and clearly distinguishes from sibling tools like browse/find by focusing on a single plan already listed.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly provides a usage condition: 'WHEN: the user asks about a specific plan you already listed'. It instructs to pass the bundle_code from existing results and never invent one, and also states that this tool does not buy or reserve anything, guiding away from purchase use cases.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_login_statusCheck eSIM login statusARead-onlyIdempotent
Check whether this client already has a signed-in eSIM user. Local and fast: it does not call the eSIM platform. WHEN: before anything that needs a signed-in user, and whenever the user asks whether they are logged in. If 'authenticated' is true, carry on -- do not ask the user to log in again. If the result shows a pending login, ask the user for the six-digit code instead of starting a new login.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, and non-destructive. The description adds valuable behavioral context: the call is local, fast, makes no network request to the eSIM platform, and the output can indicate a pending login that requires a six-digit code. This goes beyond annotations without contradicting them.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three sentences, with the purpose in the first sentence, usage timing in the second, and actionable result handling in the third. Every sentence earns its place with no redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given zero parameters and an output schema present, the description fully covers what the tool does, when to use it, and how to interpret and act on the result. It is complete for an agent to select and invoke correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters and the input schema is an empty object, making parameter explanations unnecessary. The baseline for 0 params is 4 and the description correctly focuses on behavior rather than inputs.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb 'Check' with a clear resource 'eSIM login status' and further clarifies it checks whether the client has a signed-in eSIM user. It distinguishes itself from sibling login tools by noting it is local, fast, and does not call the eSIM platform.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides explicit WHEN guidance: use before anything needing a signed-in user and whenever the user asks about login state. It also gives conditional actions based on the result (carry on if authenticated, ask for six-digit code if pending). However, it does not explicitly name alternative tools or state when not to use it, so it misses the top tier.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_prepared_purchaseRead a prepared eSIM quoteARead-onlyIdempotent
Read back a quote you prepared earlier in this conversation: the plan, the amount, the payment method and whether it is still valid. WHEN: the user asks about what you prepared -- "show me my prepared purchase", "what was that going to cost". Pass the quote reference from the prepare_purchase result. This reads local state only and never contacts the eSIM platform, so it changes nothing. A quote is short-lived. If the result says it expired or was cancelled, say so plainly and offer to prepare the plan again -- and repeat that no order was created and nothing was charged.
| Name | Required | Description | Default |
|---|---|---|---|
| quote_id | Yes | The quote reference returned by prepare_purchase in this conversation. Never invent, guess or edit one. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already indicate read-only and idempotent, but the description adds critical context: it reads local state only, changes nothing, and handles expiring quotes with guidance to offer re-preparation and clarify no charge occurred. This goes beyond the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with a clear WHEN prefix and concise sentences. Each sentence adds value: purpose, usage triggers, parameter source, behavioral note, and expiration handling. No fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Despite having an output schema (so return values are covered), the description fully covers purpose, usage, local-state behavior, and edge cases like expired quotes. It is complete for an agent to select and invoke the tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description for quote_id already covers the 'returned by prepare_purchase' and 'never invent' guidance, matching the description. With 100% schema coverage, the description adds no new parameter meaning, so baseline 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool reads back a prepared quote, specifying the exact data returned (plan, amount, payment method, validity). This distinguishes it from siblings like prepare_purchase and confirm_purchase by focusing on reading local state.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN section with example user phrasings ('show me my prepared purchase', 'what was that going to cost'). It instructs to pass the quote reference from prepare_purchase and contrasts with platform-contacting tools by noting it never contacts the eSIM platform.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_user_profileGet eSIM account profileARead-onlyIdempotent
Read the signed-in user's own profile and wallet balance from the eSIM platform. WHEN: the user asks about their account, profile, name or balance -- and only while they are signed in. If this answers 'authentication_required', start the login flow with request_login_otp instead of asking the user for any credential. Contact details come back masked; tokens and internal session data are never included, so do not ask for or expect them. PRIVACY: repeat the masked email and phone exactly as returned. Never restore or retype the complete address or number, not even when the user typed it earlier in this conversation -- say 'your email' or the masked form instead.
| Name | Required | Description | Default |
|---|---|---|---|
| locale | No | Optional language tag for platform messages, e.g. 'en'. Omit to use the server default. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Even with annotations marking the tool as readOnly and idempotent, the description adds valuable behavioral detail: contact details come back masked, tokens and internal session data are never included, and the response may include 'authentication_required'. This is well beyond the abstract annotation flags and gives the agent operationally relevant expectations about output content and edge cases.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear section markers (WHEN, PRIVACY) and front-loaded with the core action. Every sentence provides essential guidance: the trigger condition, the authentication fallback, the masking behavior, and the privacy rule. No filler or redundant information is present, making it appropriately concise while thorough.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The tool has a rich output schema (indicated by 'Has output schema: true'), so the description need not explain return values. It covers the user-facing purpose, the trigger condition, the authentication failure mode, and privacy constraints. Combined with the annotations and schema, the description fully equips the agent to select and call this tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already covers both parameters (locale and currency) with descriptions stating they are optional language/currency tags. The description does not add any additional parameter semantics beyond this, but it also doesn't need to — schema coverage is 100%. Per the rubric, the baseline of 3 is appropriate when the schema fully describes the parameters.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'Read the signed-in user's own profile and wallet balance from the eSIM platform.' This clearly distinguishes get_user_profile from sibling tools like purchase or login tools, which have different resources and actions. It also scopes the tool to the signed-in user, preventing confusion with other profile-related functionality.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explicitly states when to use this tool: when the user asks about their account, profile, name, or balance while signed in. It also provides an exclusion/alternative: if the tool returns 'authentication_required', the agent should start the login flow with request_login_otp. This is a clear, actionable usage guideline that sets expectations and directs behavior.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_countriesList eSIM destinationsARead-onlyIdempotent
List the countries the eSIM platform sells plans for, or check the one the user named. WHEN: the user asks which destinations are available, or you want to confirm a country exists before searching for plans. Pass the user's own wording as 'query' to resolve it; omit 'query' to browse. The result is deliberately a limited extract plus a total count -- do not read the whole list out, ask the user where they are travelling. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | How many entries to return. Default 20, maximum 100. | |
| query | No | Optional search text the user gave, e.g. 'France' or 'FR'. Omit it to browse the list. With it, the result is an exact match or a short list of close suggestions. | |
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, and non-destructive. The description adds valuable behavioral context beyond annotations: the result is 'deliberately a limited extract plus a total count', and 'This needs no login' clarifies access requirements. This goes well beyond what the annotations provide.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is short and front-loaded with the core purpose. The 'WHEN' label organizes usage guidance clearly. Every sentence earns its place with no fluff or repetition.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple list tool with an output schema, the description adequately covers purpose, usage, and result behavior. It explains the limited extract nature and instructs the agent to ask the user where they are travelling, which is critical for effective interaction. No gaps remain.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, with detailed parameter descriptions for limit, query, and locale. The description complements the schema by clarifying query usage ('Pass the user's own wording as 'query' to resolve it') and browsing behavior ('omit 'query' to browse'), adding value beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: 'List the countries the eSIM platform sells plans for, or check the one the user named.' This uses a specific verb and resource and distinguishes it from siblings like list_regions and find_bundles_by_country.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN guidance is given: 'WHEN: the user asks which destinations are available, or you want to confirm a country exists before searching for plans.' It also explains how to use the query parameter ('Pass the user's own wording as 'query' to resolve it; omit 'query' to browse') and what not to do ('do not read the whole list out').
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_cruise_bundlesFind eSIM plans for cruisesARead-onlyIdempotent
List the eSIM plans sold for cruise ships. WHEN: the user says they are going on a cruise, names a ship or a cruise line, or asks for maritime coverage. Each plan covers specific ships; the summary carries how many, and get_bundle_details names them. If the user has a particular ship in mind, check it there before promising coverage. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | How many plans to return. Default 5, maximum 20. Keep it small: a chat reply lists a handful of options, and the user can ask for more. | |
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| sort_by | No | Optional ordering: 'price' (cheapest first), 'data' (largest allowance first) or 'validity' (longest first). Omit to keep the platform's own order, which is already price-first. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| max_price | No | Optional maximum price, in the result currency. Only pass a budget the user actually stated. | |
| unlimited_only | No | Set true only when the user explicitly asks for unlimited-data plans. | |
| minimum_data_gb | No | Optional minimum data allowance in GB. Unlimited plans always satisfy this. | |
| minimum_validity_days | No | Optional minimum validity in days. A 1-month plan counts as 30 days. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the readOnlyHint/idempotentHint annotations, the description discloses that each plan covers specific ships, that the summary only reports a count, and that get_bundle_details is needed to name ships. It also states 'This needs no login,' adding auth context not present in the annotations. No contradictions with annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four short sentences with a clear WHEN marker make the content scannable and front-loaded. Every sentence earns its place: purpose, triggers, ship-coverage caveat, and auth note.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given 8 optional parameters, rich schema descriptions, a present output schema, and strong annotations, the description covers the remaining context: when to use, ship-specific behavior, and login requirements. No major gaps remain for an agent to invoke the tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already provides 100% description coverage, including usage guidance like 'Only pass a budget the user actually stated' and 'Set true only when the user explicitly asks.' The tool description itself adds no parameter-level semantics, so the schema-heavy baseline of 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific action and resource: 'List the eSIM plans sold for cruise ships.' This clearly distinguishes it from country/region bundle finders and identifies the maritime domain. The title reinforces the purpose, and the special-case mention of get_bundle_details further clarifies scope.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It states explicit trigger conditions ('user says they are going on a cruise, names a ship or a cruise line, or asks for maritime coverage'), and directs the agent to get_bundle_details when a specific ship is involved before promising coverage. It also notes no login is required, which helps with alternative handling.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_regionsList eSIM regionsARead-onlyIdempotent
List the regions the eSIM platform sells multi-country plans for, such as Europe. WHEN: the user mentions a region, asks what regional plans exist, or is travelling to several countries in one area. Use a region from this result with find_bundles_by_region. This needs no login.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | How many entries to return. Default 20, maximum 100. | |
| query | No | Optional search text the user gave, e.g. 'France' or 'FR'. Omit it to browse the list. With it, the result is an exact match or a short list of close suggestions. | |
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint, openWorldHint, idempotentHint, and destructiveHint false. The description adds useful context beyond that: 'This needs no login' and clarifies the scope with 'such as Europe.' No contradiction with annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short, purposeful sentences: what it does, when to use it, and how to chain it with another tool. Front-loaded and free of redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple list tool with no required parameters, full schema coverage, strong annotations, and an output schema, the description together with the schema fully covers usage, triggers, and constraints. No important gap remains.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, and each parameter (limit, query, locale) is already well documented in the schema. The description adds no additional parameter-level meaning, so the baseline of 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific action and resource: 'List the regions the eSIM platform sells multi-country plans for.' The example 'such as Europe' and the name clearly distinguish it from siblings like list_countries and find_bundles_by_region.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN triggers are provided: 'the user mentions a region, asks what regional plans exist, or is travelling to several countries in one area.' It also names the next step (find_bundles_by_region) and notes that no login is required, giving clear guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
logoutLog out of eSIMADestructiveIdempotent
Sign the current user out of the eSIM platform, for this client only. WHEN: only when the user explicitly asks to log out or sign out. DO NOT log anyone out on your own -- not after an error, not to 'reset' things, not at the end of a conversation. Other clients and other users are unaffected.
| Name | Required | Description | Default |
|---|---|---|---|
| locale | No | Optional language tag for platform messages, e.g. 'en'. Omit to use the server default. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond annotations (destructiveHint=true, etc.), the description adds crucial scope: 'for this client only' and 'Other clients and other users are unaffected.' This explains the isolation behavior without contradicting annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is compact and front-loaded: the first sentence states purpose, followed by a tight WHEN/DO NOT guideline. No unnecessary words.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple logout tool with output schema and one documented optional parameter, the description covers purpose, usage constraints, and scope. Nothing essential is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100% with a clear description of the optional 'locale' parameter. The tool description adds nothing about parameters, but the schema already fully documents it, so baseline 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource: 'Sign the current user out of the eSIM platform, for this client only.' This clearly distinguishes it from sibling login tools and matches the title.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN guidance is provided: 'only when the user explicitly asks to log out or sign out' and a clear DO NOT directive ('DO NOT log anyone out on your own'), which fully clarifies appropriate usage vs. alternatives.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
prepare_purchasePrepare an eSIM plan for purchaseA
Prepare a signed-in user's chosen plan for buying, and report exactly what it would cost. This does NOT buy anything: it creates no order, moves no money, and reserves nothing. WHEN: a signed-in user has picked a real plan from a list you showed and wants to go ahead -- "I want the second one", "prepare the cheapest France plan". FIRST: the user must be signed in. Check get_login_status and run the normal login conversation if they are not. Then ask whether they want to use their wallet balance or a card, and pass their answer -- never pick for them. Pass the bundle_code of the plan they chose, taken from the catalogue result you already have. Never invent a code and never ask the user for one. If it is unclear which plan they mean, ask them which one before calling this. The plan's price, availability and the wallet balance are all re-read from the platform here, so you cannot supply them and must not assume them. AFTER SUCCESS: tell the user the plan, the amount and the payment method, and say plainly that no order was created and nothing was charged. Never say the plan is reserved, held or bought. Do not ask for card details. Do not call this again for the same choice -- if you do, the earlier quote is replaced. Preparing is not paying, and this tool cannot take a payment of either kind. Only once the user has heard the amount and explicitly agreed to it does paying begin: confirm_purchase for a Wallet quote, create_card_checkout for a Card quote. Never start either on your own initiative.
| Name | Required | Description | Default |
|---|---|---|---|
| locale | No | Optional language tag for platform text, e.g. 'en'. Omit to use the server default. | |
| region | No | Optional. The region whose plan list the user chose from, when the selection came out of a region search. Preserves the destination context; it never affects the price. | |
| country | No | Optional. The country whose plan list the user chose from, in their own words, when the selection came out of a country search. Preserves the destination context; it never affects the price. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| bundle_code | Yes | The bundle_code of the plan the user chose, taken verbatim from a catalogue result you already have in this conversation. Never invent, guess or edit one, never derive one from a plan's display name, and never ask the user to read one out. If you do not have the code for the plan they mean, search the destination again and use the code from the option they picked. | |
| payment_method | Yes | How the user wants to pay: 'Wallet' to use their eSIM account balance, or 'Card'. Ask the user which one they want and pass their answer -- never choose on their behalf, and never default to one. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Even with annotations present, the description adds substantial behavioral context: it creates no order, moves no money, reserves nothing, replaces the earlier quote if called again, re-reads price/availability/balance from the platform, and must never imply the plan is reserved or bought. No contradiction with the annotations exists; the openWorldHint aligns with the re-reading behavior.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Though long, the description is tightly structured with clear sections: WHAT, WHEN, FIRST, AFTER SUCCESS, and boundary statements. Every paragraph covers a distinct operational concern, and the opening sentence front-loads the core purpose. No filler sentences are present.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity, 6 parameters, output schema, and a web of related sibling tools, the description fully covers prerequisites, side effects, follow-up actions, and post-success messaging. It leaves no meaningful gap for an agent deciding when and how to invoke this tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already documents all six parameters with 100% coverage, so the baseline is 3. The description adds genuine value by insisting bundle_code must come verbatim from a catalogue result and never be invented or asked from the user, plus clarifying that region/country preserve context but never affect price. This goes beyond the schema's already-rich parameter docs.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a precise verb+resource statement: prepare a signed-in user's chosen plan for buying and report its cost, while explicitly clarifying that it does NOT buy anything. It clearly distinguishes this from confirm_purchase and create_card_checkout by stating those are the actual paying steps.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives explicit when-to-use context ('a signed-in user has picked a real plan... and wants to go ahead'), prerequisites (must check get_login_status), and directly names the alternatives: confirm_purchase for Wallet and create_card_checkout for Card. It also tells the agent not to call this again for the same choice and not to start payment on its own.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
request_login_otpSend eSIM login codeA
Start login: ask the eSIM platform to send a six-digit one-time code to the user's email address or phone number. WHEN: the user wants to log in or sign in, or something you are about to do needs a signed-in user and get_login_status reports nobody is signed in. FIRST: ask the user which email address or phone number to use if they have not said yet. Pass exactly one of them, exactly as the user gave it. Never invent, guess or auto-complete an address or number. AFTER SUCCESS: tell the user that a code was sent to the masked destination in the result and ask them to read out the six-digit code, then call verify_login_otp. The user is NOT logged in yet -- do not say login is complete. DO NOT call this tool again for the same login attempt; it is rate limited. If the user says the code never arrived, use resend_login_otp.
| Name | Required | Description | Default |
|---|---|---|---|
| No | The user's email address, exactly as they gave it. Ask the user for it if you do not have it yet; never invent, guess or auto-complete an address. | ||
| phone | No | The user's phone number in international format, for example +CCXXXXXXXXX, exactly as they gave it. Never invent or guess a number. | |
| locale | No | Optional language tag for platform messages, e.g. 'en'. Omit to use the server default. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| otp_channel | No | Delivery channel for the code: 'EMAIL' or 'SMS'. Omit it unless the user asked for a specific channel -- email logins default to EMAIL and phone logins to SMS. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description discloses rate limiting ('DO NOT call this tool again for the same login attempt; it is rate limited'), the fact that the user is NOT logged in after success, and the masked destination in the result. These behaviors go beyond the annotations, which only indicate non-read-only, non-idempotent, and non-destructive hints.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is organized with clear, labeled sections (WHEN, FIRST, AFTER SUCCESS, DO NOT) and each sentence conveys a necessary instruction. It is longer than average but every line adds value, and the structure makes it easy for an agent to parse and execute.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity, the description covers the full lifecycle: what triggers the tool, what to ask the user, what to pass, what the result means, what to do next, and what to avoid. The presence of an output schema means return values need not be detailed, and the description provides all needed context.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Though schema coverage is 100%, the description adds critical semantic usage: 'Pass exactly one of them, exactly as the user gave it' and 'Never invent, guess or auto-complete an address or number.' It also clarifies the otp_channel default behavior and the optional nature of locale/currency without repeating schema details.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states the specific verb and resource: 'ask the eSIM platform to send a six-digit one-time code to the user's email address or phone number.' This clearly distinguishes it from siblings like resend_login_otp and verify_login_otp by framing the action as starting a login.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN conditions are provided: when the user wants to log in, or when an action requires a signed-in user and get_login_status reports no one signed in. It also instructs to ask for the destination first, to call verify_login_otp after success, and explicitly says to use resend_login_otp if the code never arrives.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
resend_login_otpResend eSIM login codeA
Send the pending one-time code again, to the same destination as the login already in progress. WHEN: only when the user explicitly asks for another code ("resend it", "I never got it"). Pass the same email or phone that started the login. DO NOT call this on your own after a wrong or expired code, and do not use it to retry a failed request_login_otp. If the platform reports that a code is still active or that the limit was reached, tell the user plainly and wait -- do not call it again.
| Name | Required | Description | Default |
|---|---|---|---|
| No | The user's email address, exactly as they gave it. Ask the user for it if you do not have it yet; never invent, guess or auto-complete an address. | ||
| phone | No | The user's phone number in international format, for example +CCXXXXXXXXX, exactly as they gave it. Never invent or guess a number. | |
| locale | No | Optional language tag for platform messages, e.g. 'en'. Omit to use the server default. | |
| otp_channel | No | Delivery channel for the code: 'EMAIL' or 'SMS'. Omit it unless the user asked for a specific channel -- email logins default to EMAIL and phone logins to SMS. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the annotations (which only indicate non-read-only, non-idempotent, non-destructive), the description discloses critical behavioral constraints: the code goes to the same destination as the ongoing login, resend should only occur on explicit user request, and the agent must not auto-retry after failures. It also specifies how to respond to platform-reported rate limits or still-active codes.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is compact and well-structured: it opens with the core action, then provides clear WHEN and DO NOT conditions. Every sentence contributes meaningful guidance, and there is no redundant or filler content.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the presence of an output schema, rich parameter descriptions, and sibling context, the description fully covers the tool's purpose, usage triggers, exclusions, and failure-handling behavior. It is complete for safely selecting and invoking this resend operation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already provides extremely detailed parameter descriptions (100% coverage), including exact-format requirements and warnings not to invent values. The description adds further value by emphasizing 'Pass the same email or phone that started the login,' which is a key semantic constraint not explicit in the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's function: 'Send the pending one-time code again, to the same destination as the login already in progress.' It distinguishes this from sibling tools like request_login_otp and verify_login_otp by emphasizing it resends a pending code rather than requesting or verifying one.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit when-to-use and when-not-to-use guidance: 'WHEN: only when the user explicitly asks for another code' and 'DO NOT call this on your own after a wrong or expired code, and do not use it to retry a failed request_login_otp.' It also instructs the agent to wait if the platform reports an active code or a limit was reached.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
verify_login_otpVerify eSIM login codeA
Finish login: verify the six-digit code the user received and open their eSIM session. WHEN: the user has given you a six-digit code for a login you started with request_login_otp. Pass that code together with the same email or phone the code was sent to. AFTER SUCCESS (status 'authenticated'): tell the user they are signed in. The session is kept on this server for this client; you never see, need or handle any token. DO NOT ask the user for an access token, a refresh token or a password, and do not retry a rejected code by yourself -- ask the user to re-read it, or to ask for a new one.
| Name | Required | Description | Default |
|---|---|---|---|
| No | The user's email address, exactly as they gave it. Ask the user for it if you do not have it yet; never invent, guess or auto-complete an address. | ||
| phone | No | The user's phone number in international format, for example +CCXXXXXXXXX, exactly as they gave it. Never invent or guess a number. | |
| locale | No | Optional language tag for platform messages, e.g. 'en'. Omit to use the server default. | |
| currency | No | Optional ISO-4217 currency code, e.g. 'USD'. Omit to use the server default. | |
| verification_pin | Yes | The six-digit code the user read out from their email or SMS. Digits only. Never guess it, and never ask the user for any other credential or token. |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description discloses significant behavioral context beyond annotations: 'you never see, need or handle any token' clarifies token handling, and 'do not retry a rejected code by yourself -- ask the user to re-read it, or to ask for a new one' explains failure behavior. Annotations only indicate readOnlyHint=false, but the description adds actionable safety and session management details.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with labeled sections (WHEN, AFTER SUCCESS, DO NOT) and every sentence delivers necessary guidance. It is concise enough to parse quickly while covering purpose, usage, and exceptions.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description is complete given the tool's complexity and the presence of an output schema. It covers the success flow ('tell the user they are signed in'), session handling, and failure handling (no automatic retries). Optional parameters (locale, currency) are adequately described in the schema, so no further detail in the description is needed.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
With 100% schema description coverage, the baseline is 3. The description adds extra meaning by instructing to 'Pass that code together with the same email or phone the code was sent to,' which clarifies an important relationship between the verification_pin and email/phone parameters not stated in the schema. This elevates the score to 4.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's function: 'verify the six-digit code the user received and open their eSIM session.' This specific verb and resource make the purpose unmistakable. It also distinguishes itself from siblings by completing the login flow initiated by request_login_otp.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides a clear 'WHEN' condition: 'the user has given you a six-digit code for a login you started with request_login_otp.' It does not explicitly name an alternative tool to use instead, but the condition implies this is the correct step in the OTP flow. The 'DO NOT retry' instruction gives additional usage guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
19 tool updates
v0.1.0- First observed
browse_home_catalog - First observed
cancel_prepared_purchase - First observed
check_card_payment_status - First observed
confirm_purchase - First observed
create_card_checkout - First observed
find_bundles_by_country - First observed
find_bundles_by_region - First observed
get_bundle_details - First observed
get_login_status - First observed
get_prepared_purchase - First observed
get_user_profile - First observed
list_countries - First observed
list_cruise_bundles - First observed
list_regions - First observed
logout - First observed
prepare_purchase - First observed
request_login_otp - First observed
resend_login_otp - First observed
verify_login_otp
TDQS
Scored across 19 tools
Each tool has a clearly distinct role: login OTP request/resend/verify/status, profile/logout, country/region/cruise/catalog browsing, bundle details, purchase preparation/read/cancel, and payment confirmation/status. No two tools overlap in purpose or action.
Tool names follow a consistent verb_noun pattern throughout, with clear prefixes like list_, find_, get_, create_, confirm_, and check_. Even the multi-word names like create_card_checkout and check_card_payment_status maintain the pattern.
19 tools is slightly above the typical 3-15 range, but the eSIM purchase lifecycle (auth, browse, prepare, pay) justifies the count. Each tool is necessary and there is no redundancy, so the count is well-scoped despite being on the higher side.
The tool set provides full coverage of the essential workflows: login/logout, browsing countries/regions/cruise plans, retrieving bundle details, preparing/canceling quotes, and completing payment via wallet or card with status checks. No obvious gaps that would cause agent failures.
Maintenance
Related MCP Connectors
MCP server unifying ERPs, CRMs, APIs and knowledge base for Claude, ChatGPT and Gemini.
BuyUKeSIM MCP lets AI agents search and purchase global travel eSIMs, UK +44 number eSIMs, and related connectivity services programmatically. Agents can search plans, compare pricing and coverage, create and fund prepaid crypto wallets, purchase eSIMs, and check order status — with no traditional account or KYC workflow.
Flight search MCP server providing search, pagination, and itinerary details for AI assistants.
MCP server connecting AI agents to 100+ apps (Gmail, Slack, Notion, GitHub) via one-click OAuth.
Related MCP Servers
- AlicenseAqualityDmaintenanceMCP server for BubblyPhone that lets AI assistants make real phone calls, manage AI voice agents, buy phone numbers in 30+ countries, and track billing. Supports 20 tools for full telephony control.205 npm1MIT
- -licenseNot gradedqualityNot gradedmaintenanceAn MCP server that wraps the Firsty telecom API, allowing AI agents to manage eSIMs, data plans, phone numbers, and network coverage.-
- AlicenseAqualityCmaintenanceAn official MCP server for ConnectMeGuru to search active travel eSIM packages, apply discount coupons, check wallet balance, and purchase eSIM plans directly within AI chats.141MIT
- FlicenseNot gradedqualityCmaintenanceMCP server for AI agents to execute the complete NevioServiceCenter flight booking flow. It provides 10 tools for flight search, cart management, passenger details, seat selection, ancillaries, and booking confirmation with automatic JWT token management.-