reloadpi-mcp 1.1.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,25 @@ with `EVM_PRIVATE_KEY`** — they settle x402 payments from your own wallet.
28
28
  > holds, for anyone who connects. Public hosting (like `mcp.reloadpi.com`) must run **without** a
29
29
  > key — browse-only. Keys belong only on a machine you control.
30
30
 
31
+ ### Finding an eSIM that covers a country
32
+
33
+ `browse_esim_offers` keeps *single-country plans* and *multi-country regional bundles* apart, because
34
+ a bundle's region tag says how the provider filed it, not what it covers — the "Asia" bundle covers
35
+ AU, NZ and UZ but not Japan, India or China, which only "Global" bundles reach.
36
+
37
+ | Filter | Returns |
38
+ |--------|---------|
39
+ | `country` | Single-country plans for one country (ISO-2, e.g. `JP`) |
40
+ | `regions` | Single-country plans grouped by area. The tags partition and do not nest: Thailand is `Southeast Asia`, India is `South Asia`, Japan is `Asia` |
41
+ | `regional_region` | Multi-country bundles tagged for an area |
42
+ | `covers_country` | Multi-country bundles that **actually** cover a country |
43
+ | `covers_countries` | One bundle covering **every** country in a list — `["BR","AR","CL"]` for a multi-stop trip |
44
+
45
+ When someone names a country, reach for `country` or `covers_country`, never `regional_region`.
46
+ Coverage is matched against each bundle's real roaming list by the platform API, so results are paged
47
+ server-side and `total` is the true match count. If no single bundle covers everything asked for, the
48
+ reply lists the closest bundles and what each one misses.
49
+
31
50
  ---
32
51
 
33
52
  ## Connecting
@@ -99,6 +118,8 @@ PORT=3100 # optional
99
118
  MCP_AUTH_TOKEN= # optional — Bearer gate on /mcp
100
119
  ```
101
120
 
121
+ `RELOADPI_AI_BASE` must point at an API that supports the `covers` coverage filter (`api.reloadpi.com` does) — without it, coverage searches come back unfiltered.
122
+
102
123
  You need USDC on Base mainnet. Get it at [Coinbase](https://coinbase.com) or bridge from another chain.
103
124
 
104
125
  > **No account required.** Your wallet is your identity. Payments settle on-chain directly — Reloadpi never holds your funds.
package/index.js CHANGED
@@ -24,6 +24,7 @@ import { ExactEvmScheme } from "@x402/evm";
24
24
  import { privateKeyToAccount } from "viem/accounts";
25
25
  import { z } from "zod";
26
26
  import { randomUUID } from "crypto";
27
+ import { normalizeRoamingCountries, toCountryName } from "./lib/countries.js";
27
28
 
28
29
  // ── Config ────────────────────────────────────────────────────────────────────
29
30
 
@@ -73,14 +74,41 @@ function buildPaidClient() {
73
74
  });
74
75
  }
75
76
 
76
- const asText = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
77
+ // Every tool result goes out through here, so this is the one place that has
78
+ // to normalize the payload: ISO-2 codes in `roamingCountries` are expanded to
79
+ // full country names (with the raw codes kept as `roamingCountriesCodes`) so a
80
+ // client reading the response can tell what a bundle covers without knowing the
81
+ // code table. Upstream requests and the offer schema are untouched.
82
+ const asText = (data) => ({
83
+ content: [{ type: "text", text: JSON.stringify(normalizeRoamingCountries(data)) }],
84
+ });
85
+
86
+ // The /ai browse API caps a page at 50. That is as far as the miss-path probes
87
+ // and the label re-check in the coverage branch can see, so both say only what
88
+ // they can actually confirm rather than extrapolating past it.
89
+ const PROBE_LIMIT = 50;
90
+
91
+ // Does this bundle actually cover `code` (already trimmed + uppercased)?
92
+ //
93
+ // The platform API filters coverage itself now (`covers`, matched against each
94
+ // bundle's roaming list), so this is only used to score near-misses when a
95
+ // coverage search comes back empty. Tolerant by design: coverage arrays carry
96
+ // the occasional non-ISO value (e.g. "CYP" in the Western Europe set), so
97
+ // nothing here assumes the array is clean — an unrecognised entry simply fails
98
+ // to match.
99
+
100
+ function coversCountry(offer, code) {
101
+ const list = offer?.roamingCountries;
102
+ if (!Array.isArray(list)) return false;
103
+ return list.some((c) => typeof c === "string" && c.trim().toUpperCase() === code);
104
+ }
77
105
 
78
106
  // ── MCP server factory (one per session) ─────────────────────────────────────
79
107
 
80
108
  function createMcpServer() {
81
109
  const server = new McpServer({
82
110
  name: "reloadpi",
83
- version: "1.1.4",
111
+ version: "1.3.1",
84
112
  });
85
113
 
86
114
  // ═══════════════════════════════════════════════════════════════════════════
@@ -138,25 +166,251 @@ function createMcpServer() {
138
166
 
139
167
  // ── eSIMs (browse) ─────────────────────────────────────────────────────────
140
168
 
169
+ // The catalog tags every offer with exactly ONE region, and that taxonomy
170
+ // PARTITIONS rather than nests: "Asia" does not contain Thailand or Vietnam
171
+ // (both "Southeast Asia") or India ("South Asia"). Two disjoint valid sets
172
+ // follow from that, so each browse mode gets its own enum:
173
+ //
174
+ // REGION_TAGS — every tag in the catalog; valid for single-country browse.
175
+ // REGIONAL_REGIONS — the only tags that have multi-country bundles behind them.
176
+ //
177
+ // "South America" (340 plans), "Southeast Asia" (225) and "South Asia" (116)
178
+ // are deliberately absent from REGIONAL_REGIONS: they have single-country
179
+ // plans but ZERO regional bundles, so offering them as a regional scope would
180
+ // advertise a menu option that cannot return anything.
181
+ //
182
+ // These are static rather than derived at runtime because an MCP tool's
183
+ // inputSchema must exist synchronously at registration time — deriving them
184
+ // from a live fetch would make tool registration depend on the catalog API
185
+ // being reachable at boot, and would leave the enum empty (an invalid schema)
186
+ // if that call failed. Re-check both lists whenever Zendit's catalog changes:
187
+ // curl -s 'https://api.reloadpi.com/ai/esims?regional=true&limit=200' \
188
+ // | jq -r '.items[].regions[]' | sort -u # → REGIONAL_REGIONS
189
+ // curl -s 'https://api.reloadpi.com/ai/esims?limit=200' \
190
+ // | jq -r '.items[].regions[]' | sort -u # → REGION_TAGS
191
+ const REGION_TAGS = [
192
+ "Global", "Africa", "Asia", "Caribbean", "Central America",
193
+ "Eastern Europe", "Western Europe", "North America", "Oceania",
194
+ "South America", "South Asia", "Southeast Asia",
195
+ "Middle East and North Africa",
196
+ ];
197
+ const REGIONAL_REGIONS = [
198
+ "Africa", "Asia", "Caribbean", "Central America", "Eastern Europe",
199
+ "Global", "Middle East and North Africa", "North America", "Oceania",
200
+ "Western Europe",
201
+ ];
202
+
141
203
  server.tool(
142
204
  "browse_esim_offers",
143
- "Browse eSIM data plans across 190+ countries and regions unlimited plans, regional multi-country bundles, and global roaming. Filter by single country (e.g. ES) or by a multi-country region. Set regional:true to list ONLY multi-country bundles (e.g. ESIM-N-AMERICA-10D-UNLIMITED covering US+CA+MX); these have no single country code. Free — no payment. Returns offer IDs and prices; use them with purchase_esim (requires a self-hosted wallet).",
205
+ "Browse eSIM data plans across 190+ countries — single-country plans and multi-country regional bundles. " +
206
+ "CHOOSE THE RIGHT FILTER: `country` (ISO-2, e.g. JP) for one specific country; " +
207
+ "`regions` to browse SINGLE-COUNTRY plans grouped by area; " +
208
+ "`regional_region` to get MULTI-COUNTRY regional bundles covering an area (this already implies regional-only — do not also set `regional`); " +
209
+ "`regional:true` on its own to list every regional bundle. " +
210
+ "`covers_country` (ISO-2) to find the multi-country bundles that ACTUALLY cover a country. " +
211
+ "COVERAGE IS NOT THE LABEL: a bundle's `regions` tag describes how the provider filed it, not what it covers — the \"Asia\" bundle covers AU, NZ and UZ but NOT Japan, India or China, which are covered only by \"Global\" bundles. So when the user names a country, use `country` (single-country plans) or `covers_country` (bundles covering it), never `regional_region`. " +
212
+ "IMPORTANT — the region taxonomy PARTITIONS and does NOT nest: \"Asia\" does NOT include Thailand or Vietnam (both \"Southeast Asia\") or India (\"South Asia\"). " +
213
+ "So for a Thailand plan use country:\"TH\", or regions:\"Southeast Asia\" for all single-country plans in that area. " +
214
+ "Regional bundles exist only for the values offered by `regional_region`; \"Southeast Asia\", \"South Asia\" and \"South America\" have single-country plans but no bundles, which is why `regional_region` does not offer them. " +
215
+ "Results include roamingCountries / roamingCount, the countries a regional bundle actually covers — roamingCountries holds full country names (with the raw ISO-2 codes in roamingCountriesCodes); check these to confirm a bundle includes the countries the user needs. " +
216
+ "PRICES: each plan has `agent_price_usd` — the exact USDC that purchase_esim charges — and `price` (minor units), which is the webapp checkout price behind buy_url, NOT what purchase_esim charges. Quote agent_price_usd when the user will buy through purchase_esim. " +
217
+ "Free — no payment. Returns offer IDs and prices; use them with purchase_esim (requires a self-hosted wallet).",
144
218
  {
145
- country: z.string().optional().describe("ISO country code for single-country plans, e.g. ES, US, JP. Omit when using regional/regions."),
146
- regions: z.enum([
147
- "Global", "Africa", "Asia", "Caribbean", "Central America",
148
- "Eastern Europe", "Western Europe", "North America", "Oceania",
149
- "South America", "South Asia", "Southeast Asia",
150
- "Middle East and North Africa",
151
- ]).optional().describe("Multi-country region to filter by (exact Zendit enum value)."),
152
- regional: z.boolean().optional().describe("true → return ONLY multi-country regional bundles (country is empty). Combine with `regions` to scope to one region."),
219
+ country: z.string().optional().describe("ISO-2 country code for one specific country, e.g. ES, US, JP."),
220
+ regions: z.enum(REGION_TAGS).optional().describe("Browse SINGLE-COUNTRY plans by area (exact tag). Partitions, does not nest: Thailand/Vietnam are \"Southeast Asia\", India is \"South Asia\", Japan/Hong Kong are \"Asia\". Do NOT use this to find regional bundles — use regional_region instead. These tags apply to single-country plans only: the single-country \"Asia\" tag covers JP and CN, but the multi-country regional_region:\"Asia\" bundle does NOT include them, so for a bundle that actually covers a given country use `covers_country`."),
221
+ regional_region: z.enum(REGIONAL_REGIONS).optional().describe("Get MULTI-COUNTRY regional bundles covering this area. Implies regional-only, so `regional` need not be set. Every value offered here has bundles behind it. Takes precedence over `regions`."),
222
+ regional: z.boolean().optional().describe("true return ONLY multi-country regional bundles. Use alone to list them all; to scope to one area use regional_region instead."),
223
+ covers_country: z.string().optional().describe("ISO-2 code of a country the bundle must ACTUALLY cover, e.g. JP. USE THIS, NOT `regional_region`, whenever the user names a country. `regional_region` filters on the provider's region LABEL, which does not describe coverage and is frequently wrong: regional_region:\"Asia\" returns bundles covering Australia, New Zealand and Uzbekistan while MISSING Japan, India and China. This parameter instead matches each bundle's real roamingCountries list. Expect JP, IN and CN to come back tagged \"Global\" — they are covered by no other bundle, so a \"Global\" result is correct, not a fallback. Returns multi-country bundles only: if the user just wants a plan for that one country, use `country` (more plans, usually cheaper). Implies regional-only. Cannot be combined with `country`."),
224
+ covers_countries: z.array(z.string()).optional().describe("ISO-2 codes a SINGLE bundle must cover ALL of, e.g. [\"BR\",\"AR\",\"CL\"] for a multi-stop trip. Matched against real roamingCountries, so it works where region labels do not: there is no \"South America\" bundle tag, but bundles covering BR/AR/CL/CO/EC/PE/UY exist under the \"Central America\" tag. If no single bundle covers everything, the closest bundles are returned under `closest` with what each one misses. Implies regional-only. Cannot be combined with `country`."),
153
225
  q: z.string().optional().describe("Free-text filter e.g. \"10GB\", \"unlimited\""),
154
226
  limit: z.number().optional().default(10),
155
227
  offset: z.number().optional().default(0),
156
228
  },
157
- async ({ country, regions, regional, q, limit, offset }) => {
229
+ async ({ country, regions, regional_region, regional, covers_country, covers_countries, q, limit, offset }) => {
230
+ // Two scopes imply regional-only: regional_region (scoped by the provider's
231
+ // label) and covers_country (scoped by what a bundle actually covers).
232
+ // `regions` stays the single-country scope, and regional_region wins over it.
233
+ // covers_country is the one-country spelling of covers_countries; both feed
234
+ // a single normalized list, so the rest of the handler has one thing to read.
235
+ const covers = [...new Set(
236
+ [covers_country, ...(covers_countries ?? [])]
237
+ .filter((c) => typeof c === "string")
238
+ .map((c) => c.trim().toUpperCase())
239
+ .filter(Boolean)
240
+ )];
241
+ const regionalOnly = regional === true || Boolean(regional_region) || covers.length > 0;
242
+ const region = regional_region ?? regions;
243
+
244
+ // `country` and the regional flags are two different questions: `country`
245
+ // asks for single-country plans, the regional flags for multi-country
246
+ // bundles. The platform API would now answer the combination as coverage,
247
+ // but a caller who wrote both has said two things at once — so name the
248
+ // one they meant instead of guessing, the same reason the guard below
249
+ // refuses to widen a regional search.
250
+ if (country && regionalOnly) {
251
+ const flag = covers.length
252
+ ? (covers_countries?.length ? "covers_countries" : "covers_country")
253
+ : regional_region ? "regional_region" : "regional:true";
254
+ return asText({
255
+ total: 0,
256
+ items: [],
257
+ error: `country cannot be combined with ${flag} — country selects single-country plans while ${flag} selects multi-country bundles, so the request asks for two different things at once.`,
258
+ hint: covers.length
259
+ ? `${flag} already asks which multi-country bundles cover ${covers.join(", ")} — drop country. For single-country plans instead, pass country:"${country}" alone.`
260
+ : `For single-country plans in ${country}, pass country:"${country}" and omit ${flag}. To find multi-country regional bundles that cover ${country}, use covers_country:"${country}".`,
261
+ });
262
+ }
263
+
264
+ // `regional_region` is enum-constrained, but `regional:true` + `regions`
265
+ // can still express a region that has no bundles (e.g. "Southeast Asia").
266
+ // Answer with explicit guidance rather than an unexplained empty list —
267
+ // and never by silently widening the search, which would let the caller
268
+ // present an unrelated bundle as if it matched the requested area.
269
+ if (regionalOnly && region && !REGIONAL_REGIONS.includes(region)) {
270
+ return asText({
271
+ total: 0,
272
+ items: [],
273
+ error: `No regional bundle carries the "${region}" tag.`,
274
+ valid_regional_regions: REGIONAL_REGIONS,
275
+ // Deliberately NOT "no bundle exists for this area": the catalog files a
276
+ // bundle covering AR/BR/CL/CO/EC/PE/UY under "Central America", so the
277
+ // coverage is real even though the tag is not. Point at the countries.
278
+ hint: `Coverage for that area may still exist under a different tag — the bundle covering AR/BR/CL/CO/EC/PE/UY is tagged "Central America", and the "Asia" bundle covers TH/VN/ID/MY/SG. Name the countries instead: covers_countries:["XX","YY"]. For single-country plans in this area, pass regions:"${region}" and omit regional.`,
279
+ });
280
+ }
281
+
282
+ // Coverage-scoped browse, answered entirely by the platform API: `covers`
283
+ // matches each bundle's real roaming list, and multiple codes are ANDed
284
+ // there, so filtering and pagination both happen server-side and `total`
285
+ // is the true match count.
286
+ //
287
+ // One code path serves covers_country and covers_countries: the singular is
288
+ // just a one-element list. Multiple codes are ANDed — a trip needs ONE
289
+ // bundle covering every stop, not one bundle per stop.
290
+ if (covers.length) {
291
+ const names = covers.map(toCountryName);
292
+ const label = names.join(", ");
293
+
294
+ const res = await freeApi.get("/esims", {
295
+ params: {
296
+ covers: covers.join(","),
297
+ regions: region,
298
+ regional: "true",
299
+ q, limit, offset,
300
+ },
301
+ });
302
+ const matched = res.data?.items ?? [];
303
+ const total = Number(res.data?.total ?? matched.length);
304
+
305
+ if (total === 0) {
306
+ // Nothing matched. Ask what each country CAN reach — one request per
307
+ // code, unscoped — so the reply can say whether the region scope was
308
+ // the problem and what comes closest. Only on the miss path: the hit
309
+ // path above is a single request.
310
+ const probes = await Promise.all(covers.map((code) =>
311
+ freeApi
312
+ .get("/esims", { params: { covers: code, regional: "true", q, limit: PROBE_LIMIT } })
313
+ .then((r) => r.data?.items ?? [])
314
+ ));
315
+ const union = new Map();
316
+ for (const offer of probes.flat()) union.set(offer.offerId, offer);
317
+
318
+ // Score every candidate by how much of the request it covers, so a
319
+ // partial match can be reported instead of a bare "nothing found".
320
+ const scored = [...union.values()].map((offer) => ({
321
+ offer,
322
+ hit: covers.filter((code) => coversCountry(offer, code)),
323
+ }));
324
+
325
+ // A label scope is the usual reason for an empty result: these probes
326
+ // carry no region scope, so anything covering every code here was
327
+ // excluded by `regions`/`regional_region` alone.
328
+ const widerLabels = region
329
+ ? [...new Set(
330
+ scored
331
+ .filter((s) => s.hit.length === covers.length)
332
+ .flatMap((s) => s.offer.regions ?? [])
333
+ )]
334
+ : [];
335
+
336
+ // Otherwise show what comes closest. Bundles collapse to a handful of
337
+ // coverage sets, so dedupe by set — five plans from one bundle family
338
+ // is noise.
339
+ const seen = new Set();
340
+ const closest = scored
341
+ .filter((s) => s.hit.length > 0)
342
+ .sort((a, b) => b.hit.length - a.hit.length)
343
+ .filter((s) => {
344
+ const sig = (s.offer.regions ?? []).join("+") + "|" +
345
+ (s.offer.roamingCountries ?? []).slice().sort().join(",");
346
+ if (seen.has(sig)) return false;
347
+ seen.add(sig);
348
+ return true;
349
+ })
350
+ .slice(0, 3)
351
+ .map((s) => ({
352
+ offerId: s.offer.offerId,
353
+ regions: s.offer.regions,
354
+ covers: s.hit.map(toCountryName),
355
+ missing: covers.filter((c) => !s.hit.includes(c)).map(toCountryName),
356
+ }));
357
+
358
+ return asText({
359
+ total: 0,
360
+ items: [],
361
+ requested_countries: names,
362
+ error: covers.length > 1
363
+ ? `No single regional bundle covers all of ${label}.`
364
+ : region
365
+ ? `No bundle tagged "${region}" covers ${label}.`
366
+ : `No regional bundle covers ${label}.`,
367
+ ...(widerLabels.length ? { covered_by_regions: widerLabels } : {}),
368
+ ...(closest.length ? { closest } : {}),
369
+ hint: widerLabels.length
370
+ ? `${label} is covered by bundles tagged ${widerLabels.map((l) => `"${l}"`).join(", ")}. Drop regions/regional_region to see them.`
371
+ : closest.length
372
+ ? `No one bundle covers every country. Closest options are listed under "closest" — buy per-country plans with country:"XX", or re-run covers_countries with a shorter list.`
373
+ : `Nothing in the regional catalog covers ${label}. For single-country plans, pass country:"${covers[0]}" on its own.`,
374
+ });
375
+ }
376
+
377
+ // `covered_by_regions` describes the WHOLE match, not this page, so it
378
+ // is only reported when the page holds every match — one extra request
379
+ // when it does not, and nothing claimed when even that cannot see them
380
+ // all. A page-scoped label list would read as the complete answer.
381
+ let labels = [...new Set(matched.flatMap((offer) => offer.regions ?? []))];
382
+ let labelsComplete = matched.length === total;
383
+ if (!labelsComplete && total <= PROBE_LIMIT) {
384
+ const full = await freeApi.get("/esims", {
385
+ params: { covers: covers.join(","), regions: region, regional: "true", q, limit: PROBE_LIMIT },
386
+ });
387
+ labels = [...new Set((full.data?.items ?? []).flatMap((offer) => offer.regions ?? []))];
388
+ labelsComplete = true;
389
+ }
390
+
391
+ return asText({
392
+ total,
393
+ items: matched,
394
+ matched_on: "roamingCountries",
395
+ requested_countries: names,
396
+ ...(labelsComplete ? { covered_by_regions: labels } : {}),
397
+ // Worth spelling out when there is only one tag: "Global" is the sole
398
+ // tag covering Japan, India and China, which reads like a glitch
399
+ // otherwise.
400
+ ...(labelsComplete && labels.length === 1
401
+ ? { note: `Every regional bundle covering ${label} is tagged "${labels[0]}" — no narrower regional bundle includes ${covers.length > 1 ? "them all" : "it"}.` }
402
+ : {}),
403
+ });
404
+ }
405
+
406
+
158
407
  const res = await freeApi.get("/esims", {
159
- params: { country, regions, regional: regional ? "true" : undefined, q, limit, offset },
408
+ params: {
409
+ country,
410
+ regions: region,
411
+ regional: regionalOnly ? "true" : undefined,
412
+ q, limit, offset,
413
+ },
160
414
  });
161
415
  return asText(res.data);
162
416
  }
@@ -246,7 +500,7 @@ function createMcpServer() {
246
500
 
247
501
  server.tool(
248
502
  "get_esim_offer",
249
- "Get full details for a specific eSIM plan by ID — exact price, data allowance, duration, coverage countries, and whether data is unlimited. Costs a small x402 fee from your wallet.",
503
+ "Get full details for a specific eSIM plan by ID — exact price, data allowance, duration, coverage countries, and whether data is unlimited. `price` here (minor units, divide by priceCurrencyDivisor) is the exact USDC amount purchase_esim charges — the same as agent_price_usd in browse_esim_offers. Costs a small x402 fee from your wallet.",
250
504
  {
251
505
  offerId: z.string().describe("eSIM offer ID e.g. ESIM-ES-7D-10GB-NOROAM"),
252
506
  },
@@ -257,7 +511,9 @@ function createMcpServer() {
257
511
  }
258
512
  );
259
513
 
260
- // ── Purchase (paid — product price + markup, x402) ─────────────────────────
514
+ // ── Purchase (paid, x402) ──────────────────────────────────────────────────
515
+ // Vouchers/topups: product price + markup. eSIM: the plan's agent_price_usd
516
+ // (min $4, else catalog price + 15%) — priced by the backend, not here.
261
517
 
262
518
  server.tool(
263
519
  "purchase_voucher",
@@ -308,7 +564,7 @@ function createMcpServer() {
308
564
 
309
565
  server.tool(
310
566
  "purchase_esim",
311
- "Purchase an eSIM data plan. The x402 payment (USDC on Base) settles automatically from YOUR wallet. Provide offerId from browse_esim_offers. Returns orderId, txHash, ICCID and QR code (base64 PNG) when ready. If QR is not immediately available, poll get_order with the returned orderId.",
567
+ "Purchase an eSIM data plan. The x402 payment (USDC on Base) settles automatically from YOUR wallet and is exactly the plan's agent_price_usd from browse_esim_offers (the `price` from get_esim_offer). Provide offerId from browse_esim_offers. Returns orderId, txHash, ICCID and QR code (base64 PNG) when ready. If QR is not immediately available, poll get_order with the returned orderId.",
312
568
  {
313
569
  offerId: z.string().describe("eSIM offer ID from browse_esim_offers"),
314
570
  iccid: z.string().optional().describe("Existing ICCID — only for top-up/recharge of an installed eSIM"),
@@ -0,0 +1,296 @@
1
+ // ISO 3166-1 alpha-2 → English country name.
2
+ //
3
+ // Safe to hardcode: ISO 3166-1 is a stable published standard, not Reloadpi
4
+ // business data. The catalog API returns bare ISO-2 codes in roamingCountries,
5
+ // which are opaque both to end users and to an agent reasoning about coverage
6
+ // ("does this bundle cover Japan?" should not require the model to know that
7
+ // JP means Japan). Names are the ISO short names in English, lightly shortened
8
+ // where the official form is unwieldy.
9
+ //
10
+ // Unknown codes are NOT an error — see toCountryName(). Upstream occasionally
11
+ // emits values outside the standard (e.g. the alpha-3 "CYP" in the Europe
12
+ // roaming bundle), and new codes can be assigned before this table is updated.
13
+ export const ISO_3166_1_ALPHA_2 = {
14
+ AD: "Andorra",
15
+ AE: "United Arab Emirates",
16
+ AF: "Afghanistan",
17
+ AG: "Antigua and Barbuda",
18
+ AI: "Anguilla",
19
+ AL: "Albania",
20
+ AM: "Armenia",
21
+ AO: "Angola",
22
+ AQ: "Antarctica",
23
+ AR: "Argentina",
24
+ AS: "American Samoa",
25
+ AT: "Austria",
26
+ AU: "Australia",
27
+ AW: "Aruba",
28
+ AX: "Åland Islands",
29
+ AZ: "Azerbaijan",
30
+ BA: "Bosnia and Herzegovina",
31
+ BB: "Barbados",
32
+ BD: "Bangladesh",
33
+ BE: "Belgium",
34
+ BF: "Burkina Faso",
35
+ BG: "Bulgaria",
36
+ BH: "Bahrain",
37
+ BI: "Burundi",
38
+ BJ: "Benin",
39
+ BL: "Saint Barthélemy",
40
+ BM: "Bermuda",
41
+ BN: "Brunei",
42
+ BO: "Bolivia",
43
+ BQ: "Bonaire, Sint Eustatius and Saba",
44
+ BR: "Brazil",
45
+ BS: "Bahamas",
46
+ BT: "Bhutan",
47
+ BV: "Bouvet Island",
48
+ BW: "Botswana",
49
+ BY: "Belarus",
50
+ BZ: "Belize",
51
+ CA: "Canada",
52
+ CC: "Cocos (Keeling) Islands",
53
+ CD: "Democratic Republic of the Congo",
54
+ CF: "Central African Republic",
55
+ CG: "Republic of the Congo",
56
+ CH: "Switzerland",
57
+ CI: "Côte d'Ivoire",
58
+ CK: "Cook Islands",
59
+ CL: "Chile",
60
+ CM: "Cameroon",
61
+ CN: "China",
62
+ CO: "Colombia",
63
+ CR: "Costa Rica",
64
+ CU: "Cuba",
65
+ CV: "Cabo Verde",
66
+ CW: "Curaçao",
67
+ CX: "Christmas Island",
68
+ CY: "Cyprus",
69
+ CZ: "Czechia",
70
+ DE: "Germany",
71
+ DJ: "Djibouti",
72
+ DK: "Denmark",
73
+ DM: "Dominica",
74
+ DO: "Dominican Republic",
75
+ DZ: "Algeria",
76
+ EC: "Ecuador",
77
+ EE: "Estonia",
78
+ EG: "Egypt",
79
+ EH: "Western Sahara",
80
+ ER: "Eritrea",
81
+ ES: "Spain",
82
+ ET: "Ethiopia",
83
+ FI: "Finland",
84
+ FJ: "Fiji",
85
+ FK: "Falkland Islands",
86
+ FM: "Micronesia",
87
+ FO: "Faroe Islands",
88
+ FR: "France",
89
+ GA: "Gabon",
90
+ GB: "United Kingdom",
91
+ GD: "Grenada",
92
+ GE: "Georgia",
93
+ GF: "French Guiana",
94
+ GG: "Guernsey",
95
+ GH: "Ghana",
96
+ GI: "Gibraltar",
97
+ GL: "Greenland",
98
+ GM: "Gambia",
99
+ GN: "Guinea",
100
+ GP: "Guadeloupe",
101
+ GQ: "Equatorial Guinea",
102
+ GR: "Greece",
103
+ GS: "South Georgia and the South Sandwich Islands",
104
+ GT: "Guatemala",
105
+ GU: "Guam",
106
+ GW: "Guinea-Bissau",
107
+ GY: "Guyana",
108
+ HK: "Hong Kong",
109
+ HM: "Heard Island and McDonald Islands",
110
+ HN: "Honduras",
111
+ HR: "Croatia",
112
+ HT: "Haiti",
113
+ HU: "Hungary",
114
+ ID: "Indonesia",
115
+ IE: "Ireland",
116
+ IL: "Israel",
117
+ IM: "Isle of Man",
118
+ IN: "India",
119
+ IO: "British Indian Ocean Territory",
120
+ IQ: "Iraq",
121
+ IR: "Iran",
122
+ IS: "Iceland",
123
+ IT: "Italy",
124
+ JE: "Jersey",
125
+ JM: "Jamaica",
126
+ JO: "Jordan",
127
+ JP: "Japan",
128
+ KE: "Kenya",
129
+ KG: "Kyrgyzstan",
130
+ KH: "Cambodia",
131
+ KI: "Kiribati",
132
+ KM: "Comoros",
133
+ KN: "Saint Kitts and Nevis",
134
+ KP: "North Korea",
135
+ KR: "South Korea",
136
+ KW: "Kuwait",
137
+ KY: "Cayman Islands",
138
+ KZ: "Kazakhstan",
139
+ LA: "Laos",
140
+ LB: "Lebanon",
141
+ LC: "Saint Lucia",
142
+ LI: "Liechtenstein",
143
+ LK: "Sri Lanka",
144
+ LR: "Liberia",
145
+ LS: "Lesotho",
146
+ LT: "Lithuania",
147
+ LU: "Luxembourg",
148
+ LV: "Latvia",
149
+ LY: "Libya",
150
+ MA: "Morocco",
151
+ MC: "Monaco",
152
+ MD: "Moldova",
153
+ ME: "Montenegro",
154
+ MF: "Saint Martin",
155
+ MG: "Madagascar",
156
+ MH: "Marshall Islands",
157
+ MK: "North Macedonia",
158
+ ML: "Mali",
159
+ MM: "Myanmar",
160
+ MN: "Mongolia",
161
+ MO: "Macao",
162
+ MP: "Northern Mariana Islands",
163
+ MQ: "Martinique",
164
+ MR: "Mauritania",
165
+ MS: "Montserrat",
166
+ MT: "Malta",
167
+ MU: "Mauritius",
168
+ MV: "Maldives",
169
+ MW: "Malawi",
170
+ MX: "Mexico",
171
+ MY: "Malaysia",
172
+ MZ: "Mozambique",
173
+ NA: "Namibia",
174
+ NC: "New Caledonia",
175
+ NE: "Niger",
176
+ NF: "Norfolk Island",
177
+ NG: "Nigeria",
178
+ NI: "Nicaragua",
179
+ NL: "Netherlands",
180
+ NO: "Norway",
181
+ NP: "Nepal",
182
+ NR: "Nauru",
183
+ NU: "Niue",
184
+ NZ: "New Zealand",
185
+ OM: "Oman",
186
+ PA: "Panama",
187
+ PE: "Peru",
188
+ PF: "French Polynesia",
189
+ PG: "Papua New Guinea",
190
+ PH: "Philippines",
191
+ PK: "Pakistan",
192
+ PL: "Poland",
193
+ PM: "Saint Pierre and Miquelon",
194
+ PN: "Pitcairn Islands",
195
+ PR: "Puerto Rico",
196
+ PS: "Palestine",
197
+ PT: "Portugal",
198
+ PW: "Palau",
199
+ PY: "Paraguay",
200
+ QA: "Qatar",
201
+ RE: "Réunion",
202
+ RO: "Romania",
203
+ RS: "Serbia",
204
+ RU: "Russia",
205
+ RW: "Rwanda",
206
+ SA: "Saudi Arabia",
207
+ SB: "Solomon Islands",
208
+ SC: "Seychelles",
209
+ SD: "Sudan",
210
+ SE: "Sweden",
211
+ SG: "Singapore",
212
+ SH: "Saint Helena, Ascension and Tristan da Cunha",
213
+ SI: "Slovenia",
214
+ SJ: "Svalbard and Jan Mayen",
215
+ SK: "Slovakia",
216
+ SL: "Sierra Leone",
217
+ SM: "San Marino",
218
+ SN: "Senegal",
219
+ SO: "Somalia",
220
+ SR: "Suriname",
221
+ SS: "South Sudan",
222
+ ST: "Sao Tome and Principe",
223
+ SV: "El Salvador",
224
+ SX: "Sint Maarten",
225
+ SY: "Syria",
226
+ SZ: "Eswatini",
227
+ TC: "Turks and Caicos Islands",
228
+ TD: "Chad",
229
+ TF: "French Southern Territories",
230
+ TG: "Togo",
231
+ TH: "Thailand",
232
+ TJ: "Tajikistan",
233
+ TK: "Tokelau",
234
+ TL: "Timor-Leste",
235
+ TM: "Turkmenistan",
236
+ TN: "Tunisia",
237
+ TO: "Tonga",
238
+ TR: "Türkiye",
239
+ TT: "Trinidad and Tobago",
240
+ TV: "Tuvalu",
241
+ TW: "Taiwan",
242
+ TZ: "Tanzania",
243
+ UA: "Ukraine",
244
+ UG: "Uganda",
245
+ UM: "United States Minor Outlying Islands",
246
+ US: "United States",
247
+ UY: "Uruguay",
248
+ UZ: "Uzbekistan",
249
+ VA: "Vatican City",
250
+ VC: "Saint Vincent and the Grenadines",
251
+ VE: "Venezuela",
252
+ VG: "British Virgin Islands",
253
+ VI: "U.S. Virgin Islands",
254
+ VN: "Vietnam",
255
+ VU: "Vanuatu",
256
+ WF: "Wallis and Futuna",
257
+ WS: "Samoa",
258
+ YE: "Yemen",
259
+ YT: "Mayotte",
260
+ ZA: "South Africa",
261
+ ZM: "Zambia",
262
+ ZW: "Zimbabwe",
263
+ };
264
+
265
+ // Expand one code. Anything not in the table — a non-ISO value from upstream,
266
+ // or a code assigned after this table was written — falls through unchanged, so
267
+ // a stale table degrades to today's raw-code behaviour instead of losing data.
268
+ export function toCountryName(code) {
269
+ if (typeof code !== "string") return code;
270
+ return ISO_3166_1_ALPHA_2[code.trim().toUpperCase()] ?? code;
271
+ }
272
+
273
+ // Rewrite every `roamingCountries` array found anywhere in an outgoing MCP
274
+ // payload: the array becomes full country names, and the original ISO-2 codes
275
+ // are preserved alongside as `roamingCountriesCodes` for callers that need the
276
+ // codes themselves (flag emoji, deep links, further filtering).
277
+ //
278
+ // Walks the whole payload rather than assuming a shape, because the same offer
279
+ // object reaches clients through several envelopes ({ items: [...] } from
280
+ // browse, a bare offer from detail, nested under an order from purchase). The
281
+ // input is never mutated — upstream data and the offer schema stay as they are.
282
+ export function normalizeRoamingCountries(value) {
283
+ if (Array.isArray(value)) return value.map(normalizeRoamingCountries);
284
+ if (value === null || typeof value !== "object") return value;
285
+
286
+ const out = {};
287
+ for (const [key, val] of Object.entries(value)) {
288
+ if (key === "roamingCountries" && Array.isArray(val)) {
289
+ out.roamingCountries = val.map(toCountryName);
290
+ out.roamingCountriesCodes = val;
291
+ } else {
292
+ out[key] = normalizeRoamingCountries(val);
293
+ }
294
+ }
295
+ return out;
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reloadpi-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.3.1",
4
4
  "description": "Reloadpi MCP server — browse and purchase eSIMs, vouchers, and topups via x402 (USDC on Base)",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "bin/",
12
+ "lib/",
12
13
  "index.js",
13
14
  "README.md"
14
15
  ],