reloadpi-mcp 1.1.3 → 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 +40 -8
- package/index.js +501 -169
- package/lib/countries.js +296 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server for the [Reloadpi](https://reloadpi.com) digital goods catalog.
|
|
4
4
|
|
|
5
|
-
Exposes
|
|
6
|
-
in USDC on Base via the [x402 protocol](https://x402.org)
|
|
5
|
+
Exposes tools across eSIMs, mobile top-ups, and gift vouchers. **Browsing and order-polling are
|
|
6
|
+
free.** Purchasing settles in USDC on Base via the [x402 protocol](https://x402.org) from **your
|
|
7
|
+
own wallet** — so purchase tools are only available when you self-host with a key (see below).
|
|
8
|
+
No account needed.
|
|
7
9
|
|
|
8
|
-
**Live endpoint:** `https://mcp.reloadpi.com/mcp`
|
|
10
|
+
**Live endpoint (browse-only):** `https://mcp.reloadpi.com/mcp`
|
|
9
11
|
|
|
10
12
|
---
|
|
11
13
|
|
|
@@ -18,7 +20,32 @@ in USDC on Base via the [x402 protocol](https://x402.org). No account needed. Ag
|
|
|
18
20
|
| eSIMs | `browse_esim_offers` `get_esim_offer` `purchase_esim` |
|
|
19
21
|
| Orders | `get_order` `recover_order_by_txhash` `claim_refund` |
|
|
20
22
|
|
|
21
|
-
Browse and order-polling tools are free
|
|
23
|
+
Browse, filter and order-polling tools are **free** and always available (they use the free `/ai`
|
|
24
|
+
API — no wallet). `get_*_offer` (paid detail) and `purchase_*` appear **only when you self-host
|
|
25
|
+
with `EVM_PRIVATE_KEY`** — they settle x402 payments from your own wallet.
|
|
26
|
+
|
|
27
|
+
> ⚠️ **Never set `EVM_PRIVATE_KEY` on a public deployment.** The server signs from whatever key it
|
|
28
|
+
> holds, for anyone who connects. Public hosting (like `mcp.reloadpi.com`) must run **without** a
|
|
29
|
+
> key — browse-only. Keys belong only on a machine you control.
|
|
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.
|
|
22
49
|
|
|
23
50
|
---
|
|
24
51
|
|
|
@@ -47,7 +74,7 @@ No cloning needed. Add to your MCP config and set your wallet key:
|
|
|
47
74
|
"mcpServers": {
|
|
48
75
|
"reloadpi": {
|
|
49
76
|
"command": "npx",
|
|
50
|
-
"args": ["-y", "reloadpi-mcp"],
|
|
77
|
+
"args": ["-y", "reloadpi-mcp", "--stdio"],
|
|
51
78
|
"env": {
|
|
52
79
|
"EVM_PRIVATE_KEY": "0x..."
|
|
53
80
|
}
|
|
@@ -83,11 +110,16 @@ Then update your MCP config:
|
|
|
83
110
|
### Environment variables
|
|
84
111
|
|
|
85
112
|
```env
|
|
86
|
-
EVM_PRIVATE_KEY=0x... # funded Base wallet
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
EVM_PRIVATE_KEY=0x... # SELF-HOST ONLY — funded Base wallet; enables purchase tools.
|
|
114
|
+
# NEVER set on a public host. Unset = browse-only.
|
|
115
|
+
RELOADPI_API_BASE=https://api.reloadpi.com/api/catalog # optional — paid routes
|
|
116
|
+
RELOADPI_AI_BASE=https://api.reloadpi.com/ai # optional — free browse API
|
|
117
|
+
PORT=3100 # optional
|
|
118
|
+
MCP_AUTH_TOKEN= # optional — Bearer gate on /mcp
|
|
89
119
|
```
|
|
90
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
|
+
|
|
91
123
|
You need USDC on Base mainnet. Get it at [Coinbase](https://coinbase.com) or bridge from another chain.
|
|
92
124
|
|
|
93
125
|
> **No account required.** Your wallet is your identity. Payments settle on-chain directly — Reloadpi never holds your funds.
|
package/index.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// mcp.reloadpi.com — Reloadpi MCP Server
|
|
2
|
-
// Exposes Reloadpi catalog as MCP tools
|
|
3
|
-
// Agents bring their own EVM wallet via EVM_PRIVATE_KEY env var.
|
|
2
|
+
// Exposes Reloadpi catalog as MCP tools.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Two modes, decided by whether EVM_PRIVATE_KEY is present:
|
|
5
|
+
//
|
|
6
|
+
// • Browse-only mode (NO EVM_PRIVATE_KEY) — safe for public hosting.
|
|
7
|
+
// Browse / filter / order tools only. They hit the FREE /ai API, so the
|
|
8
|
+
// server never holds or spends a wallet. This is what mcp.reloadpi.com runs.
|
|
9
|
+
//
|
|
10
|
+
// • Purchase mode (EVM_PRIVATE_KEY set) — for self-hosters (npx / local).
|
|
11
|
+
// Adds get_*_offer (paid detail) and purchase_* tools that settle x402
|
|
12
|
+
// payments from the operator's OWN wallet. Never set this on the public host.
|
|
13
|
+
//
|
|
14
|
+
// Transport: Streamable HTTP — compatible with Claude Desktop 2025/2026.
|
|
7
15
|
|
|
8
16
|
import "dotenv/config";
|
|
9
17
|
import axios from "axios";
|
|
@@ -16,24 +24,44 @@ import { ExactEvmScheme } from "@x402/evm";
|
|
|
16
24
|
import { privateKeyToAccount } from "viem/accounts";
|
|
17
25
|
import { z } from "zod";
|
|
18
26
|
import { randomUUID } from "crypto";
|
|
27
|
+
import { normalizeRoamingCountries, toCountryName } from "./lib/countries.js";
|
|
19
28
|
|
|
20
29
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
21
30
|
|
|
31
|
+
// Paid x402 routes (offer detail + purchase). Only used in purchase mode.
|
|
22
32
|
const API_BASE = process.env.RELOADPI_API_BASE ?? "https://api.reloadpi.com/api/catalog";
|
|
33
|
+
// Free, walletless browse API. Used for all browse/filter tools in both modes.
|
|
34
|
+
const AI_BASE = process.env.RELOADPI_AI_BASE ?? "https://api.reloadpi.com/ai";
|
|
23
35
|
const PORT = process.env.PORT ?? 3100;
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
|
|
37
|
+
// Optional shared-secret gate for /mcp. OFF by default so the public browse
|
|
38
|
+
// server stays open to anonymous MCP clients. Set MCP_AUTH_TOKEN to require
|
|
39
|
+
// `Authorization: Bearer <token>` (useful for a private self-hosted instance).
|
|
40
|
+
const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN?.trim() || null;
|
|
41
|
+
|
|
42
|
+
const HAS_WALLET = Boolean(process.env.EVM_PRIVATE_KEY);
|
|
43
|
+
|
|
44
|
+
// Transport: default HTTP (for the hosted server). Pass --stdio (or MCP_STDIO=1)
|
|
45
|
+
// when an MCP client spawns this process locally via `command`/`args` — Claude
|
|
46
|
+
// Desktop and Cursor talk to spawned servers over stdio, not HTTP.
|
|
47
|
+
const STDIO = process.argv.includes("--stdio") || process.env.MCP_STDIO === "1";
|
|
48
|
+
|
|
49
|
+
process.on("unhandledRejection", (reason) => {
|
|
50
|
+
console.error("[unhandledRejection]", reason);
|
|
26
51
|
});
|
|
27
|
-
if (!process.env.EVM_PRIVATE_KEY) {
|
|
28
|
-
console.warn("⚠️ EVM_PRIVATE_KEY not set — browse tools work, purchase tools will fail.");
|
|
29
|
-
console.warn(" Set it in your MCP config env or in a .env file to enable purchases.");
|
|
30
|
-
}
|
|
31
52
|
|
|
32
|
-
// ──
|
|
53
|
+
// ── HTTP clients ────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// Free browse client — no wallet, never pays.
|
|
56
|
+
const freeApi = axios.create({ baseURL: AI_BASE });
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
// Paid x402 client — built lazily, only when a wallet is configured.
|
|
59
|
+
function buildPaidClient() {
|
|
60
|
+
if (!HAS_WALLET) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"This tool requires a funded wallet. Self-host reloadpi-mcp with EVM_PRIVATE_KEY " +
|
|
63
|
+
"in your MCP config env to enable offer-detail and purchase tools."
|
|
64
|
+
);
|
|
37
65
|
}
|
|
38
66
|
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
|
|
39
67
|
return wrapAxiosWithPaymentFromConfig(axios.create({ baseURL: API_BASE }), {
|
|
@@ -46,99 +74,82 @@ function buildHttpClient() {
|
|
|
46
74
|
});
|
|
47
75
|
}
|
|
48
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
|
+
}
|
|
105
|
+
|
|
49
106
|
// ── MCP server factory (one per session) ─────────────────────────────────────
|
|
50
107
|
|
|
51
108
|
function createMcpServer() {
|
|
52
109
|
const server = new McpServer({
|
|
53
110
|
name: "reloadpi",
|
|
54
|
-
version: "1.
|
|
111
|
+
version: "1.3.0",
|
|
55
112
|
});
|
|
56
113
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
// FREE TOOLS — always available, no wallet. Backed by the /ai browse API.
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
// ── Vouchers (browse) ──────────────────────────────────────────────────────
|
|
60
119
|
|
|
61
120
|
server.tool(
|
|
62
121
|
"browse_voucher_offers",
|
|
63
|
-
"Search gift cards and vouchers from 5000+ brands across 150+ countries (Amazon, Google Play, Netflix, Steam, Visa and more). Filter by brand, country or category.
|
|
122
|
+
"Search gift cards and vouchers from 5000+ brands across 150+ countries (Amazon, Google Play, Netflix, Steam, Visa and more). Filter by brand, country or category. Free — no payment. Returns offer IDs and prices; use them with purchase_voucher (requires a self-hosted wallet).",
|
|
64
123
|
{
|
|
65
124
|
brand: z.string().optional().describe("Brand name filter e.g. Amazon"),
|
|
66
125
|
country: z.string().optional().describe("ISO country code e.g. US"),
|
|
67
126
|
category: z.string().optional().describe("Category slug e.g. shopping, gaming, entertainment"),
|
|
68
|
-
limit: z.number().optional().default(10).describe("Results per page"),
|
|
127
|
+
limit: z.number().optional().default(10).describe("Results per page (max 50)"),
|
|
69
128
|
offset: z.number().optional().default(0).describe("Pagination offset"),
|
|
70
129
|
},
|
|
71
130
|
async ({ brand, country, category, limit, offset }) => {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
params: { brand, country, category, limit, offset },
|
|
131
|
+
const res = await freeApi.get("/vouchers", {
|
|
132
|
+
params: { brand, country, subType: category, limit, offset },
|
|
75
133
|
});
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
server.tool(
|
|
81
|
-
"get_voucher_offer",
|
|
82
|
-
"Get full details for a specific voucher or gift card offer by ID — including exact price, denomination, currency, priceType (FIXED or RANGE), and requiredFields for the recipient. Call this before purchasing to confirm the exact payment amount.",
|
|
83
|
-
{
|
|
84
|
-
offerId: z.string().describe("Voucher offer ID e.g. 1-800-BASKETS_US_002_EGIFT"),
|
|
85
|
-
},
|
|
86
|
-
async ({ offerId }) => {
|
|
87
|
-
const api = buildHttpClient();
|
|
88
|
-
const res = await api.get(`/vouchers/offers/${offerId}`);
|
|
89
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
134
|
+
return asText(res.data);
|
|
90
135
|
}
|
|
91
136
|
);
|
|
92
137
|
|
|
93
138
|
server.tool(
|
|
94
139
|
"get_voucher_filters",
|
|
95
|
-
"Get all valid filter values for the voucher catalog — available brand names, ISO country codes,
|
|
140
|
+
"Get all valid filter values for the voucher catalog — available brand names, ISO country codes, category slugs and regions. Free — no payment. Use before browse_voucher_offers to know what filter values are accepted.",
|
|
96
141
|
{},
|
|
97
142
|
async () => {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
143
|
+
const res = await freeApi.get("/capabilities");
|
|
144
|
+
return asText(res.data?.vouchers ?? res.data);
|
|
101
145
|
}
|
|
102
146
|
);
|
|
103
147
|
|
|
104
|
-
|
|
105
|
-
"purchase_voucher",
|
|
106
|
-
"Purchase a gift card or voucher. The x402 payment (USDC on Base) is handled automatically from the agent's wallet. Provide offerId from browse_voucher_offers. For RANGE priceType (open-value cards like Amazon), also supply value in USD. Returns orderId, txHash, and delivery (pinCode / redeemUrl) when available.",
|
|
107
|
-
{
|
|
108
|
-
offerId: z.string().describe("Voucher offer ID from browse_voucher_offers"),
|
|
109
|
-
firstName: z.string().describe("Recipient first name"),
|
|
110
|
-
lastName: z.string().describe("Recipient last name"),
|
|
111
|
-
email: z.string().optional().describe("Recipient email — required for some brands, check requiredFields on the offer"),
|
|
112
|
-
country: z.string().optional().describe("Recipient ISO country code — required for some brands"),
|
|
113
|
-
value: z.number().optional().describe("USD amount — RANGE priceType only (open-value cards). Omit for FIXED."),
|
|
114
|
-
},
|
|
115
|
-
async ({ offerId, firstName, lastName, email, country, value }) => {
|
|
116
|
-
const api = buildHttpClient();
|
|
117
|
-
const body = {
|
|
118
|
-
offerId,
|
|
119
|
-
recipient: { firstName, lastName, ...(email && { email }), ...(country && { country }) },
|
|
120
|
-
...(value !== undefined && { value }),
|
|
121
|
-
};
|
|
122
|
-
try {
|
|
123
|
-
const res = await api.post("/vouchers/purchase", body);
|
|
124
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
125
|
-
} catch (err) {
|
|
126
|
-
console.error('[purchase_voucher error]', err?.message);
|
|
127
|
-
console.error('[purchase_voucher status]', err?.response?.status);
|
|
128
|
-
console.error('[purchase_voucher data]', JSON.stringify(err?.response?.data));
|
|
129
|
-
console.error('[purchase_voucher stack]', err?.stack);
|
|
130
|
-
throw err;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
-
// TOPUP TOOLS
|
|
137
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// ── Topups (browse) ────────────────────────────────────────────────────────
|
|
138
149
|
|
|
139
150
|
server.tool(
|
|
140
151
|
"browse_topup_offers",
|
|
141
|
-
"Search prepaid mobile airtime and data top-up offers across 500+ operators in 150+ countries — including MTN, Airtel, Orange, Movistar, Digicel and more. Filter by country or operator.
|
|
152
|
+
"Search prepaid mobile airtime and data top-up offers across 500+ operators in 150+ countries — including MTN, Airtel, Orange, Movistar, Digicel and more. Filter by country or operator. Free — no payment. Returns offer IDs and prices; use them with purchase_topup (requires a self-hosted wallet).",
|
|
142
153
|
{
|
|
143
154
|
country: z.string().optional().describe("ISO country code e.g. GH, ES, NG"),
|
|
144
155
|
operator: z.string().optional().describe("Operator name filter e.g. MTN, Airtel"),
|
|
@@ -146,105 +157,265 @@ function createMcpServer() {
|
|
|
146
157
|
offset: z.number().optional().default(0),
|
|
147
158
|
},
|
|
148
159
|
async ({ country, operator, limit, offset }) => {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
params: { country, operator, limit },
|
|
152
|
-
});
|
|
153
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
154
|
-
}
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
server.tool(
|
|
158
|
-
"get_topup_offer",
|
|
159
|
-
"Get full details for a specific mobile top-up offer by ID — including price, operator, country, and required recipient fields.",
|
|
160
|
-
{
|
|
161
|
-
offerId: z.string().describe("Topup offer ID e.g. AIRTELTIGO_GH_025"),
|
|
162
|
-
},
|
|
163
|
-
async ({ offerId }) => {
|
|
164
|
-
const api = buildHttpClient();
|
|
165
|
-
const res = await api.get(`/topups/offers/${offerId}`);
|
|
166
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
167
|
-
}
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
server.tool(
|
|
171
|
-
"purchase_topup",
|
|
172
|
-
"Purchase a prepaid mobile airtime or data top-up. The x402 payment (USDC on Base) is handled automatically from the agent's wallet. Provide offerId from browse_topup_offers and the recipient phone number in E.164 format. Top-up is delivered directly to the recipient's SIM. Returns orderId and txHash — poll get_order for delivery confirmation.",
|
|
173
|
-
{
|
|
174
|
-
offerId: z.string().describe("Topup offer ID from browse_topup_offers"),
|
|
175
|
-
msisdn: z.string().describe("Recipient phone number in E.164 format e.g. +233201234567"),
|
|
176
|
-
},
|
|
177
|
-
async ({ offerId, msisdn }) => {
|
|
178
|
-
const api = buildHttpClient();
|
|
179
|
-
const res = await api.post("/topups/purchase", {
|
|
180
|
-
offerId,
|
|
181
|
-
recipient: { msisdn },
|
|
160
|
+
const res = await freeApi.get("/topups", {
|
|
161
|
+
params: { country, brand: operator, limit, offset },
|
|
182
162
|
});
|
|
183
|
-
return
|
|
163
|
+
return asText(res.data);
|
|
184
164
|
}
|
|
185
165
|
);
|
|
186
166
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
//
|
|
167
|
+
// ── eSIMs (browse) ─────────────────────────────────────────────────────────
|
|
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
|
+
];
|
|
190
202
|
|
|
191
203
|
server.tool(
|
|
192
204
|
"browse_esim_offers",
|
|
193
|
-
"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).",
|
|
194
217
|
{
|
|
195
|
-
country: z.string().optional().describe("ISO country code for
|
|
196
|
-
regions: z.enum(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
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`."),
|
|
224
|
+
q: z.string().optional().describe("Free-text filter e.g. \"10GB\", \"unlimited\""),
|
|
203
225
|
limit: z.number().optional().default(10),
|
|
204
226
|
offset: z.number().optional().default(0),
|
|
205
227
|
},
|
|
206
|
-
async ({ country, regions, regional, limit, offset }) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
|
|
406
|
+
const res = await freeApi.get("/esims", {
|
|
407
|
+
params: {
|
|
408
|
+
country,
|
|
409
|
+
regions: region,
|
|
410
|
+
regional: regionalOnly ? "true" : undefined,
|
|
411
|
+
q, limit, offset,
|
|
412
|
+
},
|
|
210
413
|
});
|
|
211
|
-
return
|
|
414
|
+
return asText(res.data);
|
|
212
415
|
}
|
|
213
416
|
);
|
|
214
417
|
|
|
215
|
-
|
|
216
|
-
"get_esim_offer",
|
|
217
|
-
"Get full details for a specific eSIM plan by ID — including exact price, data allowance, duration, coverage countries, and whether data is unlimited.",
|
|
218
|
-
{
|
|
219
|
-
offerId: z.string().describe("eSIM offer ID e.g. ESIM-ES-7D-10GB-NOROAM"),
|
|
220
|
-
},
|
|
221
|
-
async ({ offerId }) => {
|
|
222
|
-
const api = buildHttpClient();
|
|
223
|
-
const res = await api.get(`/esims/offers/${offerId}`);
|
|
224
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
225
|
-
}
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
server.tool(
|
|
229
|
-
"purchase_esim",
|
|
230
|
-
"Purchase an eSIM data plan. The x402 payment (USDC on Base) is handled automatically from the agent's 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.",
|
|
231
|
-
{
|
|
232
|
-
offerId: z.string().describe("eSIM offer ID from browse_esim_offers"),
|
|
233
|
-
iccid: z.string().optional().describe("Existing ICCID — only for top-up/recharge of an installed eSIM"),
|
|
234
|
-
},
|
|
235
|
-
async ({ offerId, iccid }) => {
|
|
236
|
-
const api = buildHttpClient();
|
|
237
|
-
const res = await api.post("/esims/purchase", {
|
|
238
|
-
offerId,
|
|
239
|
-
...(iccid && { iccid }),
|
|
240
|
-
});
|
|
241
|
-
return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
|
|
242
|
-
}
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
246
|
-
// ORDER POLLING TOOLS (free — no payment)
|
|
247
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
418
|
+
// ── Order polling & refunds (free — no payment) ────────────────────────────
|
|
248
419
|
|
|
249
420
|
server.tool(
|
|
250
421
|
"get_order",
|
|
@@ -254,7 +425,7 @@ function createMcpServer() {
|
|
|
254
425
|
},
|
|
255
426
|
async ({ orderId }) => {
|
|
256
427
|
const res = await axios.get(`${API_BASE}/orders/${orderId}`);
|
|
257
|
-
return
|
|
428
|
+
return asText(res.data);
|
|
258
429
|
}
|
|
259
430
|
);
|
|
260
431
|
|
|
@@ -266,7 +437,7 @@ function createMcpServer() {
|
|
|
266
437
|
},
|
|
267
438
|
async ({ txHash }) => {
|
|
268
439
|
const res = await axios.get(`${API_BASE}/orders`, { params: { txHash } });
|
|
269
|
-
return
|
|
440
|
+
return asText(res.data);
|
|
270
441
|
}
|
|
271
442
|
);
|
|
272
443
|
|
|
@@ -284,7 +455,124 @@ function createMcpServer() {
|
|
|
284
455
|
{ txHash },
|
|
285
456
|
{ headers: { "Idempotency-Key": idempotencyKey } }
|
|
286
457
|
);
|
|
287
|
-
return
|
|
458
|
+
return asText(res.data);
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
463
|
+
// PAID TOOLS — only registered when EVM_PRIVATE_KEY is set (self-host).
|
|
464
|
+
// These settle x402 payments from the operator's OWN wallet. They are NOT
|
|
465
|
+
// present on the public hosted server, so it can never spend.
|
|
466
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
467
|
+
|
|
468
|
+
if (!HAS_WALLET) {
|
|
469
|
+
return server;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Offer detail (paid $0.001 x402) ────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
server.tool(
|
|
475
|
+
"get_voucher_offer",
|
|
476
|
+
"Get full details for a specific voucher or gift card offer by ID — exact price, denomination, currency, priceType (FIXED or RANGE), and requiredFields for the recipient. Costs a small x402 fee from your wallet. Call before purchasing to confirm the exact payment amount.",
|
|
477
|
+
{
|
|
478
|
+
offerId: z.string().describe("Voucher offer ID e.g. 1-800-BASKETS_US_002_EGIFT"),
|
|
479
|
+
},
|
|
480
|
+
async ({ offerId }) => {
|
|
481
|
+
const api = buildPaidClient();
|
|
482
|
+
const res = await api.get(`/vouchers/offers/${offerId}`);
|
|
483
|
+
return asText(res.data);
|
|
484
|
+
}
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
server.tool(
|
|
488
|
+
"get_topup_offer",
|
|
489
|
+
"Get full details for a specific mobile top-up offer by ID — price, operator, country, and required recipient fields. Costs a small x402 fee from your wallet.",
|
|
490
|
+
{
|
|
491
|
+
offerId: z.string().describe("Topup offer ID e.g. AIRTELTIGO_GH_025"),
|
|
492
|
+
},
|
|
493
|
+
async ({ offerId }) => {
|
|
494
|
+
const api = buildPaidClient();
|
|
495
|
+
const res = await api.get(`/topups/offers/${offerId}`);
|
|
496
|
+
return asText(res.data);
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
server.tool(
|
|
501
|
+
"get_esim_offer",
|
|
502
|
+
"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
|
+
{
|
|
504
|
+
offerId: z.string().describe("eSIM offer ID e.g. ESIM-ES-7D-10GB-NOROAM"),
|
|
505
|
+
},
|
|
506
|
+
async ({ offerId }) => {
|
|
507
|
+
const api = buildPaidClient();
|
|
508
|
+
const res = await api.get(`/esims/offers/${offerId}`);
|
|
509
|
+
return asText(res.data);
|
|
510
|
+
}
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// ── Purchase (paid — product price + markup, x402) ─────────────────────────
|
|
514
|
+
|
|
515
|
+
server.tool(
|
|
516
|
+
"purchase_voucher",
|
|
517
|
+
"Purchase a gift card or voucher. The x402 payment (USDC on Base) settles automatically from YOUR wallet. Provide offerId from browse_voucher_offers. For RANGE priceType (open-value cards like Amazon), also supply value in USD. Returns orderId, txHash, and delivery (pinCode / redeemUrl) when available.",
|
|
518
|
+
{
|
|
519
|
+
offerId: z.string().describe("Voucher offer ID from browse_voucher_offers"),
|
|
520
|
+
firstName: z.string().describe("Recipient first name"),
|
|
521
|
+
lastName: z.string().describe("Recipient last name"),
|
|
522
|
+
email: z.string().optional().describe("Recipient email — required for some brands, check requiredFields on the offer"),
|
|
523
|
+
country: z.string().optional().describe("Recipient ISO country code — required for some brands"),
|
|
524
|
+
value: z.number().optional().describe("USD amount — RANGE priceType only (open-value cards). Omit for FIXED."),
|
|
525
|
+
},
|
|
526
|
+
async ({ offerId, firstName, lastName, email, country, value }) => {
|
|
527
|
+
const api = buildPaidClient();
|
|
528
|
+
const body = {
|
|
529
|
+
offerId,
|
|
530
|
+
recipient: { firstName, lastName, ...(email && { email }), ...(country && { country }) },
|
|
531
|
+
...(value !== undefined && { value }),
|
|
532
|
+
};
|
|
533
|
+
try {
|
|
534
|
+
const res = await api.post("/vouchers/purchase", body);
|
|
535
|
+
return asText(res.data);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.error("[purchase_voucher error]", err?.message);
|
|
538
|
+
console.error("[purchase_voucher status]", err?.response?.status);
|
|
539
|
+
console.error("[purchase_voucher data]", JSON.stringify(err?.response?.data));
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
server.tool(
|
|
546
|
+
"purchase_topup",
|
|
547
|
+
"Purchase a prepaid mobile airtime or data top-up. The x402 payment (USDC on Base) settles automatically from YOUR wallet. Provide offerId from browse_topup_offers and the recipient phone number in E.164 format. Delivered directly to the recipient's SIM. Returns orderId and txHash — poll get_order for delivery confirmation.",
|
|
548
|
+
{
|
|
549
|
+
offerId: z.string().describe("Topup offer ID from browse_topup_offers"),
|
|
550
|
+
msisdn: z.string().describe("Recipient phone number in E.164 format e.g. +233201234567"),
|
|
551
|
+
},
|
|
552
|
+
async ({ offerId, msisdn }) => {
|
|
553
|
+
const api = buildPaidClient();
|
|
554
|
+
const res = await api.post("/topups/purchase", {
|
|
555
|
+
offerId,
|
|
556
|
+
recipient: { msisdn },
|
|
557
|
+
});
|
|
558
|
+
return asText(res.data);
|
|
559
|
+
}
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
server.tool(
|
|
563
|
+
"purchase_esim",
|
|
564
|
+
"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.",
|
|
565
|
+
{
|
|
566
|
+
offerId: z.string().describe("eSIM offer ID from browse_esim_offers"),
|
|
567
|
+
iccid: z.string().optional().describe("Existing ICCID — only for top-up/recharge of an installed eSIM"),
|
|
568
|
+
},
|
|
569
|
+
async ({ offerId, iccid }) => {
|
|
570
|
+
const api = buildPaidClient();
|
|
571
|
+
const res = await api.post("/esims/purchase", {
|
|
572
|
+
offerId,
|
|
573
|
+
...(iccid && { iccid }),
|
|
574
|
+
});
|
|
575
|
+
return asText(res.data);
|
|
288
576
|
}
|
|
289
577
|
);
|
|
290
578
|
|
|
@@ -298,9 +586,19 @@ function createMcpServer() {
|
|
|
298
586
|
const app = express();
|
|
299
587
|
app.use(express.json());
|
|
300
588
|
|
|
589
|
+
// Optional bearer-token gate. No-op unless MCP_AUTH_TOKEN is set.
|
|
590
|
+
function authorized(req, res) {
|
|
591
|
+
if (!MCP_AUTH_TOKEN) return true;
|
|
592
|
+
const header = req.headers["authorization"] ?? "";
|
|
593
|
+
if (header === `Bearer ${MCP_AUTH_TOKEN}`) return true;
|
|
594
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
301
598
|
const sessions = new Map(); // sessionId → { server, transport }
|
|
302
599
|
|
|
303
600
|
app.post("/mcp", async (req, res) => {
|
|
601
|
+
if (!authorized(req, res)) return;
|
|
304
602
|
try {
|
|
305
603
|
const sessionId = req.headers["mcp-session-id"];
|
|
306
604
|
|
|
@@ -338,6 +636,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
338
636
|
|
|
339
637
|
// SSE notifications (GET /mcp)
|
|
340
638
|
app.get("/mcp", async (req, res) => {
|
|
639
|
+
if (!authorized(req, res)) return;
|
|
341
640
|
const sessionId = req.headers["mcp-session-id"];
|
|
342
641
|
if (!sessionId || !sessions.has(sessionId)) {
|
|
343
642
|
return res.status(400).json({ error: "Invalid or missing session ID" });
|
|
@@ -348,6 +647,7 @@ app.get("/mcp", async (req, res) => {
|
|
|
348
647
|
|
|
349
648
|
// Session termination (DELETE /mcp)
|
|
350
649
|
app.delete("/mcp", async (req, res) => {
|
|
650
|
+
if (!authorized(req, res)) return;
|
|
351
651
|
const sessionId = req.headers["mcp-session-id"];
|
|
352
652
|
if (!sessionId || !sessions.has(sessionId)) {
|
|
353
653
|
return res.status(404).json({ error: "Session not found" });
|
|
@@ -357,10 +657,42 @@ app.delete("/mcp", async (req, res) => {
|
|
|
357
657
|
sessions.delete(sessionId);
|
|
358
658
|
});
|
|
359
659
|
|
|
360
|
-
app.get("/health", (_req, res) =>
|
|
660
|
+
app.get("/health", (_req, res) =>
|
|
661
|
+
res.json({ status: "ok", name: "reloadpi-mcp", mode: HAS_WALLET ? "purchase" : "browse-only" })
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// In stdio mode we must NOT write to stdout (it's the MCP channel) — log to stderr.
|
|
665
|
+
function logModeToStderr(prefix) {
|
|
666
|
+
if (HAS_WALLET) {
|
|
667
|
+
try {
|
|
668
|
+
const addr = privateKeyToAccount(process.env.EVM_PRIVATE_KEY).address;
|
|
669
|
+
console.error(`${prefix}💳 Purchase mode — signing from ${addr}`);
|
|
670
|
+
} catch {
|
|
671
|
+
console.error(`${prefix}💳 Purchase mode — EVM_PRIVATE_KEY set`);
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
console.error(`${prefix}🔎 Browse-only mode — no wallet; purchase tools disabled`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
361
677
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
678
|
+
if (STDIO) {
|
|
679
|
+
// Local, client-spawned server (npx via Claude/Cursor `command`/`args`).
|
|
680
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
681
|
+
const server = createMcpServer();
|
|
682
|
+
await server.connect(new StdioServerTransport());
|
|
683
|
+
logModeToStderr("reloadpi-mcp (stdio) ready — ");
|
|
684
|
+
} else {
|
|
685
|
+
// Hosted / HTTP server.
|
|
686
|
+
app.listen(PORT, () => {
|
|
687
|
+
console.log(`🚀 Reloadpi MCP server listening on port ${PORT}`);
|
|
688
|
+
console.log(` MCP endpoint: http://localhost:${PORT}/mcp`);
|
|
689
|
+
if (HAS_WALLET) {
|
|
690
|
+
logModeToStderr(" ");
|
|
691
|
+
console.error(` ⚠️ Do NOT run this on a public host; anyone who connects could spend`);
|
|
692
|
+
console.error(` this wallet. Public hosting must omit EVM_PRIVATE_KEY.`);
|
|
693
|
+
} else {
|
|
694
|
+
console.log(` 🔎 Browse-only mode — no wallet; purchase tools disabled (safe for public hosting)`);
|
|
695
|
+
}
|
|
696
|
+
if (MCP_AUTH_TOKEN) console.log(` 🔒 /mcp requires Authorization: Bearer <MCP_AUTH_TOKEN>`);
|
|
697
|
+
});
|
|
698
|
+
}
|
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
|
],
|