reloadpi-mcp 1.1.3 → 1.1.4

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.
Files changed (3) hide show
  1. package/README.md +19 -8
  2. package/index.js +240 -161
  3. package/package.json +1 -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 **13 tools** across eSIMs, mobile top-ups, and gift vouchers all paid automatically
6
- in USDC on Base via the [x402 protocol](https://x402.org). No account needed. Agents bring their own funded wallet.
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,13 @@ 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. Purchase tools trigger an x402 USDC payment automatically.
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.
22
30
 
23
31
  ---
24
32
 
@@ -47,7 +55,7 @@ No cloning needed. Add to your MCP config and set your wallet key:
47
55
  "mcpServers": {
48
56
  "reloadpi": {
49
57
  "command": "npx",
50
- "args": ["-y", "reloadpi-mcp"],
58
+ "args": ["-y", "reloadpi-mcp", "--stdio"],
51
59
  "env": {
52
60
  "EVM_PRIVATE_KEY": "0x..."
53
61
  }
@@ -83,9 +91,12 @@ Then update your MCP config:
83
91
  ### Environment variables
84
92
 
85
93
  ```env
86
- EVM_PRIVATE_KEY=0x... # funded Base wallet agent pays USDC for purchases
87
- RELOADPI_API_BASE=https://api.reloadpi.com/api/catalog # optional override
88
- PORT=3100 # optional
94
+ EVM_PRIVATE_KEY=0x... # SELF-HOST ONLY — funded Base wallet; enables purchase tools.
95
+ # NEVER set on a public host. Unset = browse-only.
96
+ RELOADPI_API_BASE=https://api.reloadpi.com/api/catalog # optional — paid routes
97
+ RELOADPI_AI_BASE=https://api.reloadpi.com/ai # optional — free browse API
98
+ PORT=3100 # optional
99
+ MCP_AUTH_TOKEN= # optional — Bearer gate on /mcp
89
100
  ```
90
101
 
91
102
  You need USDC on Base mainnet. Get it at [Coinbase](https://coinbase.com) or bridge from another chain.
package/index.js CHANGED
@@ -1,9 +1,17 @@
1
1
  // mcp.reloadpi.com — Reloadpi MCP Server
2
- // Exposes Reloadpi catalog as MCP tools with automatic x402 payment.
3
- // Agents bring their own EVM wallet via EVM_PRIVATE_KEY env var.
2
+ // Exposes Reloadpi catalog as MCP tools.
4
3
  //
5
- // Transport: Streamable HTTP compatible with Claude Desktop 2025/2026
6
- // Deploy as a separate service on Render (same account as api.reloadpi.com)
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";
@@ -19,21 +27,40 @@ import { randomUUID } from "crypto";
19
27
 
20
28
  // ── Config ────────────────────────────────────────────────────────────────────
21
29
 
30
+ // Paid x402 routes (offer detail + purchase). Only used in purchase mode.
22
31
  const API_BASE = process.env.RELOADPI_API_BASE ?? "https://api.reloadpi.com/api/catalog";
32
+ // Free, walletless browse API. Used for all browse/filter tools in both modes.
33
+ const AI_BASE = process.env.RELOADPI_AI_BASE ?? "https://api.reloadpi.com/ai";
23
34
  const PORT = process.env.PORT ?? 3100;
24
- process.on('unhandledRejection', (reason) => {
25
- console.error('[unhandledRejection]', reason);
35
+
36
+ // Optional shared-secret gate for /mcp. OFF by default so the public browse
37
+ // server stays open to anonymous MCP clients. Set MCP_AUTH_TOKEN to require
38
+ // `Authorization: Bearer <token>` (useful for a private self-hosted instance).
39
+ const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN?.trim() || null;
40
+
41
+ const HAS_WALLET = Boolean(process.env.EVM_PRIVATE_KEY);
42
+
43
+ // Transport: default HTTP (for the hosted server). Pass --stdio (or MCP_STDIO=1)
44
+ // when an MCP client spawns this process locally via `command`/`args` — Claude
45
+ // Desktop and Cursor talk to spawned servers over stdio, not HTTP.
46
+ const STDIO = process.argv.includes("--stdio") || process.env.MCP_STDIO === "1";
47
+
48
+ process.on("unhandledRejection", (reason) => {
49
+ console.error("[unhandledRejection]", reason);
26
50
  });
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
51
 
32
- // ── x402 axios client ─────────────────────────────────────────────────────────
52
+ // ── HTTP clients ────────────────────────────────────────────────────────────
53
+
54
+ // Free browse client — no wallet, never pays.
55
+ const freeApi = axios.create({ baseURL: AI_BASE });
33
56
 
34
- function buildHttpClient() {
35
- if (!process.env.EVM_PRIVATE_KEY) {
36
- throw new Error("EVM_PRIVATE_KEY is required for purchase tools. Add it to your MCP config env block.");
57
+ // Paid x402 client — built lazily, only when a wallet is configured.
58
+ function buildPaidClient() {
59
+ if (!HAS_WALLET) {
60
+ throw new Error(
61
+ "This tool requires a funded wallet. Self-host reloadpi-mcp with EVM_PRIVATE_KEY " +
62
+ "in your MCP config env to enable offer-detail and purchase tools."
63
+ );
37
64
  }
38
65
  const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
39
66
  return wrapAxiosWithPaymentFromConfig(axios.create({ baseURL: API_BASE }), {
@@ -46,99 +73,55 @@ function buildHttpClient() {
46
73
  });
47
74
  }
48
75
 
76
+ const asText = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
77
+
49
78
  // ── MCP server factory (one per session) ─────────────────────────────────────
50
79
 
51
80
  function createMcpServer() {
52
81
  const server = new McpServer({
53
82
  name: "reloadpi",
54
- version: "1.0.0",
83
+ version: "1.1.4",
55
84
  });
56
85
 
57
- // ─────────────────────────────────────────────────────────────────────────────
58
- // VOUCHER TOOLS
59
- // ─────────────────────────────────────────────────────────────────────────────
86
+ // ═══════════════════════════════════════════════════════════════════════════
87
+ // FREE TOOLS — always available, no wallet. Backed by the /ai browse API.
88
+ // ═══════════════════════════════════════════════════════════════════════════
89
+
90
+ // ── Vouchers (browse) ──────────────────────────────────────────────────────
60
91
 
61
92
  server.tool(
62
93
  "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. Call this before purchasing to discover available offer IDs and prices.",
94
+ "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
95
  {
65
96
  brand: z.string().optional().describe("Brand name filter e.g. Amazon"),
66
97
  country: z.string().optional().describe("ISO country code e.g. US"),
67
98
  category: z.string().optional().describe("Category slug e.g. shopping, gaming, entertainment"),
68
- limit: z.number().optional().default(10).describe("Results per page"),
99
+ limit: z.number().optional().default(10).describe("Results per page (max 50)"),
69
100
  offset: z.number().optional().default(0).describe("Pagination offset"),
70
101
  },
71
102
  async ({ brand, country, category, limit, offset }) => {
72
- const api = buildHttpClient();
73
- const res = await api.get("/vouchers/offers", {
74
- params: { brand, country, category, limit, offset },
103
+ const res = await freeApi.get("/vouchers", {
104
+ params: { brand, country, subType: category, limit, offset },
75
105
  });
76
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
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) }] };
106
+ return asText(res.data);
90
107
  }
91
108
  );
92
109
 
93
110
  server.tool(
94
111
  "get_voucher_filters",
95
- "Get all valid filter values for the voucher catalog — available brand names, ISO country codes, and category slugs. Use before calling browse_voucher_offers to know what filter values are accepted.",
112
+ "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
113
  {},
97
114
  async () => {
98
- const api = buildHttpClient();
99
- const res = await api.get("/vouchers/filters");
100
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
115
+ const res = await freeApi.get("/capabilities");
116
+ return asText(res.data?.vouchers ?? res.data);
101
117
  }
102
118
  );
103
119
 
104
- server.tool(
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
- // ─────────────────────────────────────────────────────────────────────────────
120
+ // ── Topups (browse) ────────────────────────────────────────────────────────
138
121
 
139
122
  server.tool(
140
123
  "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. Call this before purchasing to discover available offer IDs and prices.",
124
+ "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
125
  {
143
126
  country: z.string().optional().describe("ISO country code e.g. GH, ES, NG"),
144
127
  operator: z.string().optional().describe("Operator name filter e.g. MTN, Airtel"),
@@ -146,51 +129,18 @@ function createMcpServer() {
146
129
  offset: z.number().optional().default(0),
147
130
  },
148
131
  async ({ country, operator, limit, offset }) => {
149
- const api = buildHttpClient();
150
- const res = await api.get("/topups/offers", {
151
- params: { country, operator, limit },
132
+ const res = await freeApi.get("/topups", {
133
+ params: { country, brand: operator, limit, offset },
152
134
  });
153
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
135
+ return asText(res.data);
154
136
  }
155
137
  );
156
138
 
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 },
182
- });
183
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
184
- }
185
- );
186
-
187
- // ─────────────────────────────────────────────────────────────────────────────
188
- // ESIM TOOLS
189
- // ─────────────────────────────────────────────────────────────────────────────
139
+ // ── eSIMs (browse) ─────────────────────────────────────────────────────────
190
140
 
191
141
  server.tool(
192
142
  "browse_esim_offers",
193
- "Browse eSIM data plans across 190+ countries and regions — including unlimited data plans, regional multi-country bundles, and global roaming. Filter by single country (e.g. ES) or by a multi-country region. Set regional:true to list ONLY multi-country regional bundles (e.g. ESIM-N-AMERICA-10D-UNLIMITED covering US+CA+MX); these have no single country code. Call this before purchasing to discover available offer IDs and prices.",
143
+ "Browse eSIM data plans across 190+ countries and regions — unlimited plans, regional multi-country bundles, and global roaming. Filter by single country (e.g. ES) or by a multi-country region. Set regional:true to list ONLY multi-country bundles (e.g. ESIM-N-AMERICA-10D-UNLIMITED covering US+CA+MX); these have no single country code. Free no payment. Returns offer IDs and prices; use them with purchase_esim (requires a self-hosted wallet).",
194
144
  {
195
145
  country: z.string().optional().describe("ISO country code for single-country plans, e.g. ES, US, JP. Omit when using regional/regions."),
196
146
  regions: z.enum([
@@ -200,51 +150,19 @@ function createMcpServer() {
200
150
  "Middle East and North Africa",
201
151
  ]).optional().describe("Multi-country region to filter by (exact Zendit enum value)."),
202
152
  regional: z.boolean().optional().describe("true → return ONLY multi-country regional bundles (country is empty). Combine with `regions` to scope to one region."),
153
+ q: z.string().optional().describe("Free-text filter e.g. \"10GB\", \"unlimited\""),
203
154
  limit: z.number().optional().default(10),
204
155
  offset: z.number().optional().default(0),
205
156
  },
206
- async ({ country, regions, regional, limit, offset }) => {
207
- const api = buildHttpClient();
208
- const res = await api.get("/esims/offers", {
209
- params: { country, regions, regional: regional ? "true" : undefined, limit, offset },
210
- });
211
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
212
- }
213
- );
214
-
215
- server.tool(
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 }),
157
+ async ({ country, regions, regional, q, limit, offset }) => {
158
+ const res = await freeApi.get("/esims", {
159
+ params: { country, regions, regional: regional ? "true" : undefined, q, limit, offset },
240
160
  });
241
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
161
+ return asText(res.data);
242
162
  }
243
163
  );
244
164
 
245
- // ─────────────────────────────────────────────────────────────────────────────
246
- // ORDER POLLING TOOLS (free — no payment)
247
- // ─────────────────────────────────────────────────────────────────────────────
165
+ // ── Order polling & refunds (free — no payment) ────────────────────────────
248
166
 
249
167
  server.tool(
250
168
  "get_order",
@@ -254,7 +172,7 @@ function createMcpServer() {
254
172
  },
255
173
  async ({ orderId }) => {
256
174
  const res = await axios.get(`${API_BASE}/orders/${orderId}`);
257
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
175
+ return asText(res.data);
258
176
  }
259
177
  );
260
178
 
@@ -266,7 +184,7 @@ function createMcpServer() {
266
184
  },
267
185
  async ({ txHash }) => {
268
186
  const res = await axios.get(`${API_BASE}/orders`, { params: { txHash } });
269
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
187
+ return asText(res.data);
270
188
  }
271
189
  );
272
190
 
@@ -284,7 +202,124 @@ function createMcpServer() {
284
202
  { txHash },
285
203
  { headers: { "Idempotency-Key": idempotencyKey } }
286
204
  );
287
- return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
205
+ return asText(res.data);
206
+ }
207
+ );
208
+
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+ // PAID TOOLS — only registered when EVM_PRIVATE_KEY is set (self-host).
211
+ // These settle x402 payments from the operator's OWN wallet. They are NOT
212
+ // present on the public hosted server, so it can never spend.
213
+ // ═══════════════════════════════════════════════════════════════════════════
214
+
215
+ if (!HAS_WALLET) {
216
+ return server;
217
+ }
218
+
219
+ // ── Offer detail (paid $0.001 x402) ────────────────────────────────────────
220
+
221
+ server.tool(
222
+ "get_voucher_offer",
223
+ "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.",
224
+ {
225
+ offerId: z.string().describe("Voucher offer ID e.g. 1-800-BASKETS_US_002_EGIFT"),
226
+ },
227
+ async ({ offerId }) => {
228
+ const api = buildPaidClient();
229
+ const res = await api.get(`/vouchers/offers/${offerId}`);
230
+ return asText(res.data);
231
+ }
232
+ );
233
+
234
+ server.tool(
235
+ "get_topup_offer",
236
+ "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.",
237
+ {
238
+ offerId: z.string().describe("Topup offer ID e.g. AIRTELTIGO_GH_025"),
239
+ },
240
+ async ({ offerId }) => {
241
+ const api = buildPaidClient();
242
+ const res = await api.get(`/topups/offers/${offerId}`);
243
+ return asText(res.data);
244
+ }
245
+ );
246
+
247
+ server.tool(
248
+ "get_esim_offer",
249
+ "Get full details for a specific eSIM plan by ID — exact price, data allowance, duration, coverage countries, and whether data is unlimited. Costs a small x402 fee from your wallet.",
250
+ {
251
+ offerId: z.string().describe("eSIM offer ID e.g. ESIM-ES-7D-10GB-NOROAM"),
252
+ },
253
+ async ({ offerId }) => {
254
+ const api = buildPaidClient();
255
+ const res = await api.get(`/esims/offers/${offerId}`);
256
+ return asText(res.data);
257
+ }
258
+ );
259
+
260
+ // ── Purchase (paid — product price + markup, x402) ─────────────────────────
261
+
262
+ server.tool(
263
+ "purchase_voucher",
264
+ "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.",
265
+ {
266
+ offerId: z.string().describe("Voucher offer ID from browse_voucher_offers"),
267
+ firstName: z.string().describe("Recipient first name"),
268
+ lastName: z.string().describe("Recipient last name"),
269
+ email: z.string().optional().describe("Recipient email — required for some brands, check requiredFields on the offer"),
270
+ country: z.string().optional().describe("Recipient ISO country code — required for some brands"),
271
+ value: z.number().optional().describe("USD amount — RANGE priceType only (open-value cards). Omit for FIXED."),
272
+ },
273
+ async ({ offerId, firstName, lastName, email, country, value }) => {
274
+ const api = buildPaidClient();
275
+ const body = {
276
+ offerId,
277
+ recipient: { firstName, lastName, ...(email && { email }), ...(country && { country }) },
278
+ ...(value !== undefined && { value }),
279
+ };
280
+ try {
281
+ const res = await api.post("/vouchers/purchase", body);
282
+ return asText(res.data);
283
+ } catch (err) {
284
+ console.error("[purchase_voucher error]", err?.message);
285
+ console.error("[purchase_voucher status]", err?.response?.status);
286
+ console.error("[purchase_voucher data]", JSON.stringify(err?.response?.data));
287
+ throw err;
288
+ }
289
+ }
290
+ );
291
+
292
+ server.tool(
293
+ "purchase_topup",
294
+ "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.",
295
+ {
296
+ offerId: z.string().describe("Topup offer ID from browse_topup_offers"),
297
+ msisdn: z.string().describe("Recipient phone number in E.164 format e.g. +233201234567"),
298
+ },
299
+ async ({ offerId, msisdn }) => {
300
+ const api = buildPaidClient();
301
+ const res = await api.post("/topups/purchase", {
302
+ offerId,
303
+ recipient: { msisdn },
304
+ });
305
+ return asText(res.data);
306
+ }
307
+ );
308
+
309
+ server.tool(
310
+ "purchase_esim",
311
+ "Purchase an eSIM data plan. The x402 payment (USDC on Base) settles automatically from YOUR wallet. Provide offerId from browse_esim_offers. Returns orderId, txHash, ICCID and QR code (base64 PNG) when ready. If QR is not immediately available, poll get_order with the returned orderId.",
312
+ {
313
+ offerId: z.string().describe("eSIM offer ID from browse_esim_offers"),
314
+ iccid: z.string().optional().describe("Existing ICCID — only for top-up/recharge of an installed eSIM"),
315
+ },
316
+ async ({ offerId, iccid }) => {
317
+ const api = buildPaidClient();
318
+ const res = await api.post("/esims/purchase", {
319
+ offerId,
320
+ ...(iccid && { iccid }),
321
+ });
322
+ return asText(res.data);
288
323
  }
289
324
  );
290
325
 
@@ -298,9 +333,19 @@ function createMcpServer() {
298
333
  const app = express();
299
334
  app.use(express.json());
300
335
 
336
+ // Optional bearer-token gate. No-op unless MCP_AUTH_TOKEN is set.
337
+ function authorized(req, res) {
338
+ if (!MCP_AUTH_TOKEN) return true;
339
+ const header = req.headers["authorization"] ?? "";
340
+ if (header === `Bearer ${MCP_AUTH_TOKEN}`) return true;
341
+ res.status(401).json({ error: "Unauthorized" });
342
+ return false;
343
+ }
344
+
301
345
  const sessions = new Map(); // sessionId → { server, transport }
302
346
 
303
347
  app.post("/mcp", async (req, res) => {
348
+ if (!authorized(req, res)) return;
304
349
  try {
305
350
  const sessionId = req.headers["mcp-session-id"];
306
351
 
@@ -338,6 +383,7 @@ app.post("/mcp", async (req, res) => {
338
383
 
339
384
  // SSE notifications (GET /mcp)
340
385
  app.get("/mcp", async (req, res) => {
386
+ if (!authorized(req, res)) return;
341
387
  const sessionId = req.headers["mcp-session-id"];
342
388
  if (!sessionId || !sessions.has(sessionId)) {
343
389
  return res.status(400).json({ error: "Invalid or missing session ID" });
@@ -348,6 +394,7 @@ app.get("/mcp", async (req, res) => {
348
394
 
349
395
  // Session termination (DELETE /mcp)
350
396
  app.delete("/mcp", async (req, res) => {
397
+ if (!authorized(req, res)) return;
351
398
  const sessionId = req.headers["mcp-session-id"];
352
399
  if (!sessionId || !sessions.has(sessionId)) {
353
400
  return res.status(404).json({ error: "Session not found" });
@@ -357,10 +404,42 @@ app.delete("/mcp", async (req, res) => {
357
404
  sessions.delete(sessionId);
358
405
  });
359
406
 
360
- app.get("/health", (_req, res) => res.json({ status: "ok", name: "reloadpi-mcp" }));
407
+ app.get("/health", (_req, res) =>
408
+ res.json({ status: "ok", name: "reloadpi-mcp", mode: HAS_WALLET ? "purchase" : "browse-only" })
409
+ );
410
+
411
+ // In stdio mode we must NOT write to stdout (it's the MCP channel) — log to stderr.
412
+ function logModeToStderr(prefix) {
413
+ if (HAS_WALLET) {
414
+ try {
415
+ const addr = privateKeyToAccount(process.env.EVM_PRIVATE_KEY).address;
416
+ console.error(`${prefix}💳 Purchase mode — signing from ${addr}`);
417
+ } catch {
418
+ console.error(`${prefix}💳 Purchase mode — EVM_PRIVATE_KEY set`);
419
+ }
420
+ } else {
421
+ console.error(`${prefix}🔎 Browse-only mode — no wallet; purchase tools disabled`);
422
+ }
423
+ }
361
424
 
362
- app.listen(PORT, () => {
363
- console.log(`🚀 Reloadpi MCP server listening on port ${PORT}`);
364
- console.log(` MCP endpoint: http://localhost:${PORT}/mcp`);
365
- console.log(` Production: https://mcp.reloadpi.com/mcp`);
366
- });
425
+ if (STDIO) {
426
+ // Local, client-spawned server (npx via Claude/Cursor `command`/`args`).
427
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
428
+ const server = createMcpServer();
429
+ await server.connect(new StdioServerTransport());
430
+ logModeToStderr("reloadpi-mcp (stdio) ready — ");
431
+ } else {
432
+ // Hosted / HTTP server.
433
+ app.listen(PORT, () => {
434
+ console.log(`🚀 Reloadpi MCP server listening on port ${PORT}`);
435
+ console.log(` MCP endpoint: http://localhost:${PORT}/mcp`);
436
+ if (HAS_WALLET) {
437
+ logModeToStderr(" ");
438
+ console.error(` ⚠️ Do NOT run this on a public host; anyone who connects could spend`);
439
+ console.error(` this wallet. Public hosting must omit EVM_PRIVATE_KEY.`);
440
+ } else {
441
+ console.log(` 🔎 Browse-only mode — no wallet; purchase tools disabled (safe for public hosting)`);
442
+ }
443
+ if (MCP_AUTH_TOKEN) console.log(` 🔒 /mcp requires Authorization: Bearer <MCP_AUTH_TOKEN>`);
444
+ });
445
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reloadpi-mcp",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
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",