reloadpi-mcp 1.1.4 → 1.3.0
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 +21 -0
- package/index.js +266 -13
- package/lib/countries.js +296 -0
- package/package.json +2 -1
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
|
-
|
|
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.
|
|
111
|
+
version: "1.3.0",
|
|
84
112
|
});
|
|
85
113
|
|
|
86
114
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -138,25 +166,250 @@ 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
|
|
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
|
+
"Free — no payment. Returns offer IDs and prices; use them with purchase_esim (requires a self-hosted wallet).",
|
|
144
217
|
{
|
|
145
|
-
country: z.string().optional().describe("ISO country code for
|
|
146
|
-
regions: z.enum(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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."),
|
|
218
|
+
country: z.string().optional().describe("ISO-2 country code for one specific country, e.g. ES, US, JP."),
|
|
219
|
+
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`."),
|
|
220
|
+
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`."),
|
|
221
|
+
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."),
|
|
222
|
+
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`."),
|
|
223
|
+
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
224
|
q: z.string().optional().describe("Free-text filter e.g. \"10GB\", \"unlimited\""),
|
|
154
225
|
limit: z.number().optional().default(10),
|
|
155
226
|
offset: z.number().optional().default(0),
|
|
156
227
|
},
|
|
157
|
-
async ({ country, regions, regional, q, limit, offset }) => {
|
|
228
|
+
async ({ country, regions, regional_region, regional, covers_country, covers_countries, q, limit, offset }) => {
|
|
229
|
+
// Two scopes imply regional-only: regional_region (scoped by the provider's
|
|
230
|
+
// label) and covers_country (scoped by what a bundle actually covers).
|
|
231
|
+
// `regions` stays the single-country scope, and regional_region wins over it.
|
|
232
|
+
// covers_country is the one-country spelling of covers_countries; both feed
|
|
233
|
+
// a single normalized list, so the rest of the handler has one thing to read.
|
|
234
|
+
const covers = [...new Set(
|
|
235
|
+
[covers_country, ...(covers_countries ?? [])]
|
|
236
|
+
.filter((c) => typeof c === "string")
|
|
237
|
+
.map((c) => c.trim().toUpperCase())
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
)];
|
|
240
|
+
const regionalOnly = regional === true || Boolean(regional_region) || covers.length > 0;
|
|
241
|
+
const region = regional_region ?? regions;
|
|
242
|
+
|
|
243
|
+
// `country` and the regional flags are two different questions: `country`
|
|
244
|
+
// asks for single-country plans, the regional flags for multi-country
|
|
245
|
+
// bundles. The platform API would now answer the combination as coverage,
|
|
246
|
+
// but a caller who wrote both has said two things at once — so name the
|
|
247
|
+
// one they meant instead of guessing, the same reason the guard below
|
|
248
|
+
// refuses to widen a regional search.
|
|
249
|
+
if (country && regionalOnly) {
|
|
250
|
+
const flag = covers.length
|
|
251
|
+
? (covers_countries?.length ? "covers_countries" : "covers_country")
|
|
252
|
+
: regional_region ? "regional_region" : "regional:true";
|
|
253
|
+
return asText({
|
|
254
|
+
total: 0,
|
|
255
|
+
items: [],
|
|
256
|
+
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.`,
|
|
257
|
+
hint: covers.length
|
|
258
|
+
? `${flag} already asks which multi-country bundles cover ${covers.join(", ")} — drop country. For single-country plans instead, pass country:"${country}" alone.`
|
|
259
|
+
: `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}".`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `regional_region` is enum-constrained, but `regional:true` + `regions`
|
|
264
|
+
// can still express a region that has no bundles (e.g. "Southeast Asia").
|
|
265
|
+
// Answer with explicit guidance rather than an unexplained empty list —
|
|
266
|
+
// and never by silently widening the search, which would let the caller
|
|
267
|
+
// present an unrelated bundle as if it matched the requested area.
|
|
268
|
+
if (regionalOnly && region && !REGIONAL_REGIONS.includes(region)) {
|
|
269
|
+
return asText({
|
|
270
|
+
total: 0,
|
|
271
|
+
items: [],
|
|
272
|
+
error: `No regional bundle carries the "${region}" tag.`,
|
|
273
|
+
valid_regional_regions: REGIONAL_REGIONS,
|
|
274
|
+
// Deliberately NOT "no bundle exists for this area": the catalog files a
|
|
275
|
+
// bundle covering AR/BR/CL/CO/EC/PE/UY under "Central America", so the
|
|
276
|
+
// coverage is real even though the tag is not. Point at the countries.
|
|
277
|
+
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.`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Coverage-scoped browse, answered entirely by the platform API: `covers`
|
|
282
|
+
// matches each bundle's real roaming list, and multiple codes are ANDed
|
|
283
|
+
// there, so filtering and pagination both happen server-side and `total`
|
|
284
|
+
// is the true match count.
|
|
285
|
+
//
|
|
286
|
+
// One code path serves covers_country and covers_countries: the singular is
|
|
287
|
+
// just a one-element list. Multiple codes are ANDed — a trip needs ONE
|
|
288
|
+
// bundle covering every stop, not one bundle per stop.
|
|
289
|
+
if (covers.length) {
|
|
290
|
+
const names = covers.map(toCountryName);
|
|
291
|
+
const label = names.join(", ");
|
|
292
|
+
|
|
293
|
+
const res = await freeApi.get("/esims", {
|
|
294
|
+
params: {
|
|
295
|
+
covers: covers.join(","),
|
|
296
|
+
regions: region,
|
|
297
|
+
regional: "true",
|
|
298
|
+
q, limit, offset,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const matched = res.data?.items ?? [];
|
|
302
|
+
const total = Number(res.data?.total ?? matched.length);
|
|
303
|
+
|
|
304
|
+
if (total === 0) {
|
|
305
|
+
// Nothing matched. Ask what each country CAN reach — one request per
|
|
306
|
+
// code, unscoped — so the reply can say whether the region scope was
|
|
307
|
+
// the problem and what comes closest. Only on the miss path: the hit
|
|
308
|
+
// path above is a single request.
|
|
309
|
+
const probes = await Promise.all(covers.map((code) =>
|
|
310
|
+
freeApi
|
|
311
|
+
.get("/esims", { params: { covers: code, regional: "true", q, limit: PROBE_LIMIT } })
|
|
312
|
+
.then((r) => r.data?.items ?? [])
|
|
313
|
+
));
|
|
314
|
+
const union = new Map();
|
|
315
|
+
for (const offer of probes.flat()) union.set(offer.offerId, offer);
|
|
316
|
+
|
|
317
|
+
// Score every candidate by how much of the request it covers, so a
|
|
318
|
+
// partial match can be reported instead of a bare "nothing found".
|
|
319
|
+
const scored = [...union.values()].map((offer) => ({
|
|
320
|
+
offer,
|
|
321
|
+
hit: covers.filter((code) => coversCountry(offer, code)),
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
// A label scope is the usual reason for an empty result: these probes
|
|
325
|
+
// carry no region scope, so anything covering every code here was
|
|
326
|
+
// excluded by `regions`/`regional_region` alone.
|
|
327
|
+
const widerLabels = region
|
|
328
|
+
? [...new Set(
|
|
329
|
+
scored
|
|
330
|
+
.filter((s) => s.hit.length === covers.length)
|
|
331
|
+
.flatMap((s) => s.offer.regions ?? [])
|
|
332
|
+
)]
|
|
333
|
+
: [];
|
|
334
|
+
|
|
335
|
+
// Otherwise show what comes closest. Bundles collapse to a handful of
|
|
336
|
+
// coverage sets, so dedupe by set — five plans from one bundle family
|
|
337
|
+
// is noise.
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
const closest = scored
|
|
340
|
+
.filter((s) => s.hit.length > 0)
|
|
341
|
+
.sort((a, b) => b.hit.length - a.hit.length)
|
|
342
|
+
.filter((s) => {
|
|
343
|
+
const sig = (s.offer.regions ?? []).join("+") + "|" +
|
|
344
|
+
(s.offer.roamingCountries ?? []).slice().sort().join(",");
|
|
345
|
+
if (seen.has(sig)) return false;
|
|
346
|
+
seen.add(sig);
|
|
347
|
+
return true;
|
|
348
|
+
})
|
|
349
|
+
.slice(0, 3)
|
|
350
|
+
.map((s) => ({
|
|
351
|
+
offerId: s.offer.offerId,
|
|
352
|
+
regions: s.offer.regions,
|
|
353
|
+
covers: s.hit.map(toCountryName),
|
|
354
|
+
missing: covers.filter((c) => !s.hit.includes(c)).map(toCountryName),
|
|
355
|
+
}));
|
|
356
|
+
|
|
357
|
+
return asText({
|
|
358
|
+
total: 0,
|
|
359
|
+
items: [],
|
|
360
|
+
requested_countries: names,
|
|
361
|
+
error: covers.length > 1
|
|
362
|
+
? `No single regional bundle covers all of ${label}.`
|
|
363
|
+
: region
|
|
364
|
+
? `No bundle tagged "${region}" covers ${label}.`
|
|
365
|
+
: `No regional bundle covers ${label}.`,
|
|
366
|
+
...(widerLabels.length ? { covered_by_regions: widerLabels } : {}),
|
|
367
|
+
...(closest.length ? { closest } : {}),
|
|
368
|
+
hint: widerLabels.length
|
|
369
|
+
? `${label} is covered by bundles tagged ${widerLabels.map((l) => `"${l}"`).join(", ")}. Drop regions/regional_region to see them.`
|
|
370
|
+
: closest.length
|
|
371
|
+
? `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.`
|
|
372
|
+
: `Nothing in the regional catalog covers ${label}. For single-country plans, pass country:"${covers[0]}" on its own.`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// `covered_by_regions` describes the WHOLE match, not this page, so it
|
|
377
|
+
// is only reported when the page holds every match — one extra request
|
|
378
|
+
// when it does not, and nothing claimed when even that cannot see them
|
|
379
|
+
// all. A page-scoped label list would read as the complete answer.
|
|
380
|
+
let labels = [...new Set(matched.flatMap((offer) => offer.regions ?? []))];
|
|
381
|
+
let labelsComplete = matched.length === total;
|
|
382
|
+
if (!labelsComplete && total <= PROBE_LIMIT) {
|
|
383
|
+
const full = await freeApi.get("/esims", {
|
|
384
|
+
params: { covers: covers.join(","), regions: region, regional: "true", q, limit: PROBE_LIMIT },
|
|
385
|
+
});
|
|
386
|
+
labels = [...new Set((full.data?.items ?? []).flatMap((offer) => offer.regions ?? []))];
|
|
387
|
+
labelsComplete = true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return asText({
|
|
391
|
+
total,
|
|
392
|
+
items: matched,
|
|
393
|
+
matched_on: "roamingCountries",
|
|
394
|
+
requested_countries: names,
|
|
395
|
+
...(labelsComplete ? { covered_by_regions: labels } : {}),
|
|
396
|
+
// Worth spelling out when there is only one tag: "Global" is the sole
|
|
397
|
+
// tag covering Japan, India and China, which reads like a glitch
|
|
398
|
+
// otherwise.
|
|
399
|
+
...(labelsComplete && labels.length === 1
|
|
400
|
+
? { note: `Every regional bundle covering ${label} is tagged "${labels[0]}" — no narrower regional bundle includes ${covers.length > 1 ? "them all" : "it"}.` }
|
|
401
|
+
: {}),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
158
406
|
const res = await freeApi.get("/esims", {
|
|
159
|
-
params: {
|
|
407
|
+
params: {
|
|
408
|
+
country,
|
|
409
|
+
regions: region,
|
|
410
|
+
regional: regionalOnly ? "true" : undefined,
|
|
411
|
+
q, limit, offset,
|
|
412
|
+
},
|
|
160
413
|
});
|
|
161
414
|
return asText(res.data);
|
|
162
415
|
}
|
package/lib/countries.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
],
|