diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..34974ea
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,45 @@
+name: Publish
+
+on:
+ push:
+ tags: [ 'v*.*.*' ]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write # for npm provenance
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ registry-url: "https://registry.npmjs.org"
+
+ - run: npm ci
+ - run: npm run version-sync
+ - run: npm run build
+ - run: npm test
+
+ - run: npm publish --access public --provenance
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ # MCP registry update. Confirm the actual PATCH endpoint shape against
+ # the current spec before flipping this on. If it fails, the npm publish
+ # still succeeded above.
+ - name: Sync MCP registry version
+ if: success()
+ env:
+ MCP_REGISTRY_TOKEN: ${{ secrets.MCP_REGISTRY_TOKEN }}
+ run: |
+ if [ -z "$MCP_REGISTRY_TOKEN" ]; then
+ echo "MCP_REGISTRY_TOKEN not set; skipping registry sync"
+ exit 0
+ fi
+ curl -sS -X PATCH "https://registry.modelcontextprotocol.io/v0/servers/io.github.voidmobcom/voidmob-mcp" \
+ -H "Authorization: Bearer $MCP_REGISTRY_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data @server.json
diff --git a/.gitignore b/.gitignore
index deed335..a7a5a6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
node_modules/
dist/
.env
+.mcpregistry_*
+.smithery/
+docs/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a849996
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 VoidMob
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 9f935e3..78510c0 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,26 @@
+[](https://mseep.ai/app/voidmobcom-voidmob-mcp)
+
# VoidMob MCP
-Mobile proxies, non-VoIP SMS verifications, and global eSIMs for AI agents and MCP clients.
+[](https://www.npmjs.com/package/@voidmob/mcp)
+[](LICENSE)
+[](https://nodejs.org)
+
+Mobile proxies, non-VoIP SMS verifications, and global eSIMs - exposed as 25 tools your AI agent can call directly.
```bash
npx -y @voidmob/mcp
```
-## Quick Start
+## Setup
-Add VoidMob to your MCP client. No auth, no config, no API key.
+1. Generate an API key at https://dashboard.voidmob.com/developers/api-keys (keys are 32-char secrets prefixed `vmk_live_`).
+2. Add the MCP to your client (snippets below). Provide the key as `VOIDMOB_API_KEY`.
### Claude Code
```bash
-claude mcp add voidmob -- npx -y @voidmob/mcp
+claude mcp add voidmob -- env VOIDMOB_API_KEY=vmk_live_... npx -y @voidmob/mcp
```
### Cursor
@@ -25,7 +32,8 @@ Add to `~/.cursor/mcp.json`:
"mcpServers": {
"voidmob": {
"command": "npx",
- "args": ["-y", "@voidmob/mcp"]
+ "args": ["-y", "@voidmob/mcp"],
+ "env": { "VOIDMOB_API_KEY": "vmk_live_..." }
}
}
}
@@ -33,121 +41,107 @@ Add to `~/.cursor/mcp.json`:
### Claude Desktop
-Add to your config file:
-
-- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
-- Linux: `~/.config/Claude/claude_desktop_config.json`
-- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
+Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\Claude\claude_desktop_config.json` (Windows), or `~/.config/Claude/claude_desktop_config.json` (Linux):
```json
{
"mcpServers": {
"voidmob": {
"command": "npx",
- "args": ["-y", "@voidmob/mcp"]
+ "args": ["-y", "@voidmob/mcp"],
+ "env": { "VOIDMOB_API_KEY": "vmk_live_..." }
}
}
}
```
-Restart your client. You now have 23 tools for mobile proxies, SMS verification, eSIM, and wallet operations.
+## Try without a key (sandbox)
-## Available Tools
+```bash
+VOIDMOB_SANDBOX=1 npx -y @voidmob/mcp
+```
-### Mobile Proxies (8 tools)
+Boots in-memory mocks with a $500 play-money balance. Every tool works against fake data. State resets on restart.
-| Tool | Description |
-|------|-------------|
-| `search_proxies` | Search mobile proxy products by country or type (shared, dedicated standard, dedicated premium) |
-| `purchase_proxy` | Purchase a mobile proxy and get connection credentials |
-| `get_proxy_status` | Check bandwidth usage, connection details, and IP |
-| `rotate_proxy` | Rotate to a new IP address (dedicated proxies only) |
-| `get_proxy_lists` | Get geo-targeted proxy lists for a shared proxy |
-| `create_proxy_list` | Create a new proxy list with location targeting |
-| `get_openvpn_config` | Get OpenVPN configuration file for a dedicated proxy |
-| `get_vless_config` | Get VLESS connection URI for a dedicated premium proxy |
-
-### SMS - US Non-VoIP (7 tools)
+## Configuration
-| Tool | Description |
-|------|-------------|
-| `search_sms_services` | Search US non-VoIP SMS services with verification, rental, and dedicated pricing |
-| `get_sms_price` | Get all pricing tiers for a specific service |
-| `rent_number` | Rent a US number - verification (20min), long-term rental (3-30 days), or dedicated (28 days) |
-| `get_messages` | Check for incoming SMS messages on a rented number |
-| `cancel_rental` | Cancel a rental (full refund for verification with no messages) |
-| `reuse_number` | Reuse a completed number to receive another SMS |
-| `toggle_auto_renew` | Toggle auto-renewal for long-term rentals and dedicated numbers |
+| Env var | Purpose | Required |
+|---|---|---|
+| `VOIDMOB_API_KEY` | Bearer key from the dashboard | Live mode |
+| `VOIDMOB_SANDBOX` | Set to `1` for mock-data mode | No |
+| `VOIDMOB_DEBUG` | Set to `1` to log requests to stderr | No |
+| `VOIDMOB_BASE_URL` | Override API host (advanced) | No |
-### eSIM (5 tools)
+## Tools
-| Tool | Description |
-|------|-------------|
-| `search_esim_plans` | Search eSIM data plans by country, data amount, duration, or features (5G, hotspot) |
-| `get_esim_plan_details` | Get full plan details including network type, speed, and activation policy |
-| `purchase_esim` | Purchase an eSIM plan and get QR code, activation code, and ICCID |
-| `get_esim_usage` | Check data usage and remaining balance for an active eSIM |
-| `topup_esim` | Browse available top-up products or purchase a top-up for an active eSIM |
+25 tools across five domains.
-### Wallet (2 tools)
+### Account (1)
| Tool | Description |
-|------|-------------|
-| `get_balance` | Get wallet balance in USD and cents |
-| `deposit` | Create a crypto deposit (BTC, ETH, SOL, USDT, USDC, LTC, DOGE) |
+|---|---|
+| `get_account` | Balance, rate limits, and account id |
-### General (1 tool)
+### SMS (7)
| Tool | Description |
-|------|-------------|
-| `list_orders` | List all orders across SMS, eSIM, and proxy services with filtering |
-
-## Example Conversations
-
-These are things you can ask Claude, Cursor, or any MCP client after adding VoidMob:
+|---|---|
+| `search_sms_services` | List services with prices |
+| `rent_number` | Rent a US number (verification / rental / dedicated) |
+| `get_rental` | Read status and received messages |
+| `cancel_rental` | Cancel a verification or long-term rental |
+| `reuse_number` | Free or paid reuse of a completed verification |
+| `re_rent_rental` | Extend a long-term rental for another period |
+| `toggle_auto_renew` | Turn auto-renewal on or off |
-> Set up a dedicated US mobile proxy and give me the credentials
+### eSIM (5)
-> Rotate my US dedicated proxy IP
-
-> Create a US-only proxy list on my shared proxy
-
-> Get the OpenVPN config for my US dedicated proxy
-
-> What VLESS connection options do I have for my premium proxy?
-
-> Rent me a dedicated US phone number that receives from all services
-
-> Set up a 7-day WhatsApp rental with auto-renew
-
-> Check if my verification code arrived
+| Tool | Description |
+|---|---|
+| `search_esim_plans` | Find global data plans |
+| `purchase_esim` | Buy a plan |
+| `get_esim_status` | Status and data usage |
+| `topup_esim` | Browse and buy top-ups |
+| `get_esim_qr` | Fetch the activation QR as an inline image |
-> Find eSIM plans that cover all of Europe with at least 5GB
+### Proxy (10)
-> Find the cheapest eSIM plan for 2 weeks in Japan with at least 5GB
+| Tool | Description |
+|---|---|
+| `search_proxies` | List available mobile proxy plans |
+| `purchase_proxy` | Buy a mobile proxy |
+| `get_proxy_status` | Status, usage, and gateway credentials |
+| `rotate_proxy_ip` | Rotate a dedicated proxy to a new IP |
+| `renew_proxy` | Extend expiry |
+| `topup_proxy` | Add data |
+| `regenerate_proxy_password` | Rotate the gateway password |
+| `list_proxy_lists` | List geo-targeted sub-pools |
+| `create_proxy_list` | Create a geo-targeted sub-pool |
+| `delete_proxy_list` | Remove a sub-pool |
+
+### Discovery + history (2)
-> Top up my Japan eSIM with more data
+| Tool | Description |
+|---|---|
+| `get_geo` | Cascading country/region/city/ISP for targeting |
+| `list_orders` | Active SMS / eSIM / proxy orders |
-> Show me all my active orders
+## Example prompts
-## Sandbox Mode
+> Rent me a US number for Telegram verification
-VoidMob MCP currently runs in **sandbox mode**. All 23 tools work, but the data is mock.
+> Find an eSIM plan that covers all of Europe with at least 5GB for two weeks
-- **$50 starting balance** - enough to try every tool
-- **Stateful** - renting a number deducts balance, messages appear after a few seconds, deposits auto-confirm
-- **Deposits auto-confirm** in ~5 seconds
-- **State resets** on server restart
-- **No auth required** - zero config, just `npx -y @voidmob/mcp`
+> Show me my active proxies
-The sandbox is designed to let you explore the full flow: deposit funds, rent a number, receive a verification code, check your balance. Everything behaves like the real API, just with mock data underneath.
+> Top up esim_xxx with 5GB
-## API Access
+## Sharing a key across processes
-Currently in sandbox mode. Join the waitlist at [voidmob.com](https://voidmob.com) for early API access.
+Multiple MCP clients running simultaneously (Claude Code + Cursor + Desktop) all share the same per-account rate limit. Heavy parallel usage may hit `RATE_LIMITED`; back off and retry.
---
- Website · GitHub · X (Twitter) · MIT License
+ Website · Docs · MCP · API reference · MIT License
diff --git a/package-lock.json b/package-lock.json
index 9aa5e0a..ac8fd38 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@voidmob/mcp",
- "version": "0.2.0",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@voidmob/mcp",
- "version": "0.2.0",
+ "version": "1.0.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -17,16 +17,51 @@
},
"devDependencies": {
"@types/node": "^25.5.0",
- "typescript": "^6.0.2"
+ "typescript": "^6.0.2",
+ "vitest": "^4.1.7"
},
"engines": {
"node": ">=20"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@hono/node-server": {
- "version": "1.19.9",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
- "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -35,6 +70,13 @@
"hono": "^4"
}
},
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -75,14 +117,463 @@
}
}
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+ "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
+ "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
+ "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
+ "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
+ "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
+ "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
+ "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
+ "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
+ "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
+ "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
+ "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
+ "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
+ "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
+ "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
+ "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
+ "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
- "version": "25.5.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
- "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
+ "version": "25.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+ "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "@vitest/spy": "4.1.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+ "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+ "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.7",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+ "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+ "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+ "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
"node_modules/accepts": {
@@ -99,9 +590,9 @@
}
},
"node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -131,6 +622,16 @@
}
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -193,10 +694,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/content-disposition": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
- "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -215,6 +726,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -290,6 +808,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -337,6 +865,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -355,6 +890,16 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -377,14 +922,24 @@
}
},
"node_modules/eventsource-parser": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
- "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -429,12 +984,12 @@
}
},
"node_modules/express-rate-limit": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
- "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
- "ip-address": "10.0.1"
+ "ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -453,9 +1008,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -468,6 +1023,24 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -507,6 +1080,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -578,9 +1166,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -590,9 +1178,9 @@
}
},
"node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "version": "4.12.21",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
+ "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -641,56 +1229,327 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
- "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
- "node_modules/ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "license": "MIT",
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">= 0.10"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/is-promise": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
- "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
- "license": "MIT"
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "license": "ISC"
- },
- "node_modules/jose": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
- "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/panva"
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "license": "MIT"
- },
- "node_modules/json-schema-typed": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
- "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
- "license": "BSD-2-Clause"
- },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -752,6 +1611,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -782,6 +1660,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -822,15 +1711,42 @@
}
},
"node_modules/path-to-regexp": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
- "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -840,6 +1756,35 @@
"node": ">=16.20.0"
}
},
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -854,9 +1799,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.2",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
- "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -901,6 +1846,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rolldown": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
+ "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.132.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.2",
+ "@rolldown/binding-darwin-arm64": "1.0.2",
+ "@rolldown/binding-darwin-x64": "1.0.2",
+ "@rolldown/binding-freebsd-x64": "1.0.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.2",
+ "@rolldown/binding-linux-arm64-musl": "1.0.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-musl": "1.0.2",
+ "@rolldown/binding-openharmony-arm64": "1.0.2",
+ "@rolldown/binding-wasm32-wasi": "1.0.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.2",
+ "@rolldown/binding-win32-x64-msvc": "1.0.2"
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1015,13 +1994,13 @@
}
},
"node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
+ "object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -1067,6 +2046,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1076,6 +2079,57 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1085,24 +2139,49 @@
"node": ">=0.6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/type-is": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
- "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
- "content-type": "^1.0.5",
+ "content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
- "node": ">= 0.6"
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/typescript": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
- "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1114,9 +2193,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
@@ -1138,6 +2217,174 @@
"node": ">= 0.8"
}
},
+ "node_modules/vite": {
+ "version": "8.0.14",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.15",
+ "rolldown": "1.0.2",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.7",
+ "@vitest/mocker": "4.1.7",
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/runner": "4.1.7",
+ "@vitest/snapshot": "4.1.7",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.7",
+ "@vitest/browser-preview": "4.1.7",
+ "@vitest/browser-webdriverio": "4.1.7",
+ "@vitest/coverage-istanbul": "4.1.7",
+ "@vitest/coverage-v8": "4.1.7",
+ "@vitest/ui": "4.1.7",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -1153,6 +2400,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1160,21 +2424,21 @@
"license": "ISC"
},
"node_modules/zod": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
- "version": "3.25.1",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
- "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
- "zod": "^3.25 || ^4"
+ "zod": "^3.25.28 || ^4"
}
}
}
diff --git a/package.json b/package.json
index 22831b8..15a6af1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@voidmob/mcp",
- "version": "0.2.0",
+ "version": "1.0.0",
"description": "Mobile proxies, non-VoIP SMS verifications, and global eSIMs for AI agents and MCP clients.",
"mcpName": "io.github.voidmobcom/voidmob-mcp",
"type": "module",
@@ -9,7 +9,6 @@
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
- "module": "./src/index.ts",
"files": [
"dist",
"README.md"
@@ -19,7 +18,10 @@
"postbuild": "chmod +x dist/index.js",
"dev": "tsc --watch",
"start": "node dist/index.js",
- "prepublishOnly": "npm run build"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "version-sync": "node scripts/check-version-sync.mjs",
+ "prepublishOnly": "npm run version-sync && npm run build && npm test"
},
"keywords": [
"mcp",
@@ -39,7 +41,7 @@
"access": "public"
},
"engines": {
- "node": ">=20"
+ "node": ">=22"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -47,6 +49,7 @@
},
"devDependencies": {
"@types/node": "^25.5.0",
- "typescript": "^6.0.2"
+ "typescript": "^6.0.2",
+ "vitest": "^4.1.7"
}
}
diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs
new file mode 100644
index 0000000..8f26907
--- /dev/null
+++ b/scripts/check-version-sync.mjs
@@ -0,0 +1,9 @@
+// scripts/check-version-sync.mjs
+import { readFileSync } from "node:fs";
+const pkg = JSON.parse(readFileSync("package.json", "utf8"));
+const srv = JSON.parse(readFileSync("server.json", "utf8"));
+if (pkg.version !== srv.version) {
+ console.error(`Version mismatch: package.json=${pkg.version} server.json=${srv.version}`);
+ process.exit(1);
+}
+console.log(`Versions in sync: ${pkg.version}`);
diff --git a/scripts/integration-smoke.mjs b/scripts/integration-smoke.mjs
new file mode 100644
index 0000000..ebb3291
--- /dev/null
+++ b/scripts/integration-smoke.mjs
@@ -0,0 +1,191 @@
+#!/usr/bin/env node
+/**
+ * Live integration smoke against the real VoidMob API.
+ *
+ * Drives the SAME production tool handlers the MCP registers, through a real
+ * HTTP client, against dashboard.voidmob.com. This is the only test layer that
+ * validates the hand-written Zod schemas against real API responses - the
+ * vitest suite runs entirely against mocks.
+ *
+ * Read-only by default (zero spend). Pass --with-purchases to run ONE SMS
+ * verification (cancelled for refund where possible) and ONE 1GB shared proxy
+ * purchase (real, mostly non-refundable spend).
+ *
+ * VOIDMOB_API_KEY=vmk_live_... node scripts/integration-smoke.mjs
+ * VOIDMOB_API_KEY=vmk_live_... node scripts/integration-smoke.mjs --with-purchases
+ *
+ * A Zod parse failure throws out of the handler (wrapToolErrors only catches
+ * HttpError/NetworkError) - the harness reports those as SCHEMA MISMATCH.
+ */
+import { createHttpClient } from "../dist/client/http.js";
+import { getAccountHandler } from "../dist/tools/account.js";
+import {
+ searchSmsServicesHandler,
+ rentNumberHandler,
+ getRentalHandler,
+ cancelRentalHandler,
+} from "../dist/tools/sms.js";
+import { searchEsimPlansHandler } from "../dist/tools/esim.js";
+import {
+ searchProxiesHandler,
+ purchaseProxyHandler,
+ getProxyStatusHandler,
+ createProxyListHandler,
+ listProxyListsHandler,
+ deleteProxyListHandler,
+} from "../dist/tools/proxy.js";
+import { getGeoHandler } from "../dist/tools/geo.js";
+import { listOrdersHandler } from "../dist/tools/orders.js";
+
+const KEY = process.env.VOIDMOB_API_KEY;
+if (!KEY) {
+ console.error("Set VOIDMOB_API_KEY");
+ process.exit(1);
+}
+const BASE = process.env.VOIDMOB_BASE_URL ?? "https://dashboard.voidmob.com/api";
+const WITH_PURCHASES = process.argv.includes("--with-purchases");
+
+const http = createHttpClient({
+ apiKey: KEY,
+ baseUrl: BASE,
+ debug: process.env.VOIDMOB_DEBUG === "1",
+ userAgent: "voidmob-mcp-integration/smoke",
+});
+
+let pass = 0;
+let fail = 0;
+const failures = [];
+
+function textOf(res) {
+ const t = res?.content?.find((c) => c.type === "text");
+ return t ? t.text : "(no text block)";
+}
+
+async function check(name, fn) {
+ try {
+ const res = await fn();
+ if (res.isError) {
+ console.log(` FAIL ${name}: tool error -> ${textOf(res).split("\n")[0]}`);
+ failures.push(`${name}: ${textOf(res).split("\n")[0]}`);
+ fail++;
+ return null;
+ }
+ const keys = res.structuredContent ? Object.keys(res.structuredContent).join(", ") : "(none)";
+ console.log(` ok ${name} [structured: ${keys}]`);
+ pass++;
+ return res;
+ } catch (e) {
+ const kind = e?.name === "ZodError" ? "SCHEMA MISMATCH" : `THREW ${e?.name}`;
+ const detail = e?.name === "ZodError"
+ ? JSON.stringify(e.issues?.slice(0, 4))
+ : (e?.message ?? String(e)).slice(0, 300);
+ console.log(` FAIL ${name}: ${kind} -> ${detail}`);
+ failures.push(`${name}: ${kind} ${detail}`);
+ fail++;
+ return null;
+ }
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function readOnly() {
+ console.log("\n== Read-only (zero spend) ==");
+ await check("get_account", () => getAccountHandler(http)({}));
+ await check("search_sms_services", () => searchSmsServicesHandler(http)({}));
+ await check("search_esim_plans", () => searchEsimPlansHandler(http)({ limit: 5 }));
+ await check("search_proxies", () => searchProxiesHandler(http)({}));
+ await check("get_geo (countries)", () => getGeoHandler(http)({}));
+ await check("get_geo (regions)", () => getGeoHandler(http)({ country: "US" }));
+ await check("list_orders", () => listOrdersHandler(http)({ limit: 10 }));
+}
+
+async function purchases() {
+ console.log("\n== Money ops (real spend) ==");
+
+ // --- SMS verification (cheapest service), then cancel for refund ---
+ const svcRes = await check("search_sms_services (pick cheapest)", () =>
+ searchSmsServicesHandler(http)({}),
+ );
+ if (svcRes?.structuredContent?.services?.length) {
+ const services = svcRes.structuredContent.services;
+ const cheapest = services.reduce((m, s) =>
+ s.quoted_price_cents < m.quoted_price_cents ? s : m,
+ );
+ console.log(` -> cheapest service: ${cheapest.name} (${cheapest.id}) @ ${cheapest.quoted_price_cents}c`);
+
+ const rented = await check(`rent_number(${cheapest.id}, verification)`, () =>
+ rentNumberHandler(http)({ service_id: cheapest.id, kind: "verification" }),
+ );
+ const verId = rented?.structuredContent?.verification?.id;
+ if (verId) {
+ console.log(` -> rented ${verId}`);
+ await check(`get_rental(${verId})`, () => getRentalHandler(http)({ rental_id: verId }));
+ // Cancel for refund. May hit CANCEL_WINDOW_NOT_OPEN cooldown; report either way.
+ await check(`cancel_rental(${verId})`, () => cancelRentalHandler(http)({ rental_id: verId }));
+ }
+ }
+
+ // --- Shared proxy, smallest GB, then list create/list/delete ---
+ const proxRes = await check("search_proxies (shared)", () =>
+ searchProxiesHandler(http)({ type: "shared" }),
+ );
+ if (proxRes?.structuredContent?.proxy_plans?.length) {
+ const plans = proxRes.structuredContent.proxy_plans;
+ // Prefer a 1GB plan; otherwise smallest data_gb; otherwise cheapest.
+ const oneGb = plans.find((p) => p.data_gb === 1);
+ const target = oneGb ?? plans
+ .filter((p) => p.data_gb != null)
+ .reduce((m, p) => (p.data_gb < m.data_gb ? p : m), plans[0]);
+ console.log(` -> target plan: ${target.name} (${target.id}) ${target.data_gb}GB @ ${target.quoted_price_cents}c`);
+
+ const bought = await check(`purchase_proxy(${target.id})`, () =>
+ purchaseProxyHandler(http)({ plan_id: target.id }),
+ );
+ const proxyId = bought?.structuredContent?.proxy?.id;
+ if (proxyId) {
+ console.log(` -> bought ${proxyId}, polling for active (up to ~2min)...`);
+ let active = false;
+ for (let i = 0; i < 12; i++) {
+ await sleep(10_000);
+ const st = await getProxyStatusHandler(http)({ proxy_id: proxyId });
+ const status = st?.structuredContent?.proxy?.status;
+ console.log(` poll ${i + 1}: status=${status}`);
+ if (status === "active") { active = true; break; }
+ if (status === "refunded" || status === "expired") break;
+ }
+ await check(`get_proxy_status(${proxyId})`, () => getProxyStatusHandler(http)({ proxy_id: proxyId }));
+ if (active) {
+ const created = await check(`create_proxy_list(${proxyId})`, () =>
+ createProxyListHandler(http)({
+ proxy_id: proxyId,
+ name: "smoke-test",
+ location_preset: "world_mix",
+ rotation_period: 0,
+ }),
+ );
+ await check(`list_proxy_lists(${proxyId})`, () => listProxyListsHandler(http)({ proxy_id: proxyId }));
+ const listId = created?.structuredContent?.list?.id;
+ if (listId) {
+ await check(`delete_proxy_list(${proxyId}, ${listId})`, () =>
+ deleteProxyListHandler(http)({ proxy_id: proxyId, list_id: listId }),
+ );
+ }
+ } else {
+ console.log(" (proxy never went active - skipping list create/delete)");
+ }
+ }
+ }
+}
+
+(async () => {
+ console.log(`Integration smoke against ${BASE}`);
+ console.log(`Mode: ${WITH_PURCHASES ? "READ-ONLY + PURCHASES" : "READ-ONLY"}`);
+ await readOnly();
+ if (WITH_PURCHASES) await purchases();
+ console.log(`\n== Result: ${pass} passed, ${fail} failed ==`);
+ if (failures.length) {
+ console.log("Failures:");
+ for (const f of failures) console.log(` - ${f}`);
+ }
+ process.exit(fail > 0 ? 1 : 0);
+})();
diff --git a/server.json b/server.json
index c945d65..607d0ab 100644
--- a/server.json
+++ b/server.json
@@ -7,12 +7,12 @@
"url": "https://github.com/voidmobcom/voidmob-mcp",
"source": "github"
},
- "version": "0.2.0",
+ "version": "1.0.0",
"packages": [
{
"registryType": "npm",
"identifier": "@voidmob/mcp",
- "version": "0.2.0",
+ "version": "1.0.0",
"transport": {
"type": "stdio"
}
diff --git a/src/client/call-api.ts b/src/client/call-api.ts
new file mode 100644
index 0000000..a927bd9
--- /dev/null
+++ b/src/client/call-api.ts
@@ -0,0 +1,37 @@
+import { HttpClient, HttpError } from "./http.js";
+
+interface SuccessEnvelope { success: true; data: T }
+interface ErrorEnvelope {
+ success: false;
+ error: {
+ code: string;
+ message: string;
+ request_id: string;
+ details?: Record;
+ };
+}
+type ApiEnvelope = SuccessEnvelope | ErrorEnvelope;
+
+export async function callApi(
+ http: HttpClient,
+ method: string,
+ path: string,
+ opts?: { body?: unknown; idempotencyKey?: string },
+): Promise {
+ const res = await http.request(method, path, opts);
+ // 204 No Content (e.g. DELETE proxy list) is a success with no body.
+ if (res.status === 204) return undefined as T;
+ const env = res.body as ApiEnvelope;
+ if (env && env.success === true) return env.data;
+ // Handles mock test clients and 2xx responses carrying success:false.
+ if (env && env.success === false) {
+ throw new HttpError(
+ res.status,
+ env.error.code,
+ env.error.request_id,
+ env.error.details,
+ env.error.message ?? env.error.code,
+ );
+ }
+ throw new HttpError(res.status, "UNKNOWN_ERROR", "", undefined, "Unexpected response shape");
+}
diff --git a/src/client/debug.ts b/src/client/debug.ts
new file mode 100644
index 0000000..82670a2
--- /dev/null
+++ b/src/client/debug.ts
@@ -0,0 +1,6 @@
+export function makeDebugLogger(enabled: boolean) {
+ if (!enabled) return () => {};
+ return (line: string) => {
+ process.stderr.write(`[voidmob-mcp] ${line}\n`);
+ };
+}
diff --git a/src/client/errors.ts b/src/client/errors.ts
new file mode 100644
index 0000000..cce4df7
--- /dev/null
+++ b/src/client/errors.ts
@@ -0,0 +1,57 @@
+import { HttpError, NetworkError } from "./http.js";
+import { formatUsd } from "../utils/format.js";
+
+const SETUP_URL = "https://dashboard.voidmob.com/developers/api-keys";
+const WALLET_URL = "https://dashboard.voidmob.com/wallet";
+
+export function mapApiError(err: unknown): string {
+ if (err instanceof NetworkError) {
+ return "Could not reach dashboard.voidmob.com. Check your connection and retry.";
+ }
+ if (!(err instanceof HttpError)) {
+ return `Unexpected error: ${(err as Error)?.message ?? String(err)}`;
+ }
+
+ const reqLine = err.requestId ? ` (request_id: ${err.requestId})` : "";
+
+ switch (err.code) {
+ case "UNAUTHENTICATED":
+ return `Your VOIDMOB_API_KEY is invalid or revoked. Generate a new key at ${SETUP_URL}${reqLine}`;
+ case "IP_NOT_ALLOWED":
+ return `This API key has an IP allowlist; the current IP is blocked. Update or remove the allowlist in the dashboard${reqLine}`;
+ case "RATE_LIMITED":
+ return `Rate limit hit. Retry shortly${reqLine}`;
+ case "INSUFFICIENT_BALANCE":
+ return `Insufficient balance. Top up at ${WALLET_URL}${reqLine}`;
+ case "DAILY_SPEND_CAP_EXCEEDED":
+ return `This API key's daily spend cap is reached. Raise the cap in the dashboard or wait for reset${reqLine}`;
+ case "PRICE_OVER_CAP": {
+ const max = err.details?.max_price_cents as number | undefined;
+ const avail = err.details?.available_price_cents as number | undefined;
+ if (max !== undefined && avail !== undefined) {
+ return `Price moved from ${formatUsd(max)} to ${formatUsd(avail)} between quote and purchase. Re-run the tool to accept the new price${reqLine}`;
+ }
+ // eSIM emits only available_price_cents (no max). Still surface the
+ // concrete current price rather than a vague "above your cap".
+ if (avail !== undefined) {
+ return `Price moved above your quote (now ${formatUsd(avail)}). Re-run the tool to accept the new price${reqLine}`;
+ }
+ return `Price moved above your cap. Re-run the tool to accept the new price${reqLine}`;
+ }
+ case "SERVICE_OUT_OF_STOCK":
+ case "OUT_OF_STOCK_AT_PRICE":
+ return `No stock available right now. Try again in a few minutes or pick a different service${reqLine}`;
+ case "CANCEL_WINDOW_NOT_OPEN":
+ return `Cancellation cooldown active. Try again in ~30s${reqLine}`;
+ case "PROVIDER_TIMEOUT":
+ case "PROVISIONING_FAILED":
+ return `Provider request timed out. Your funds were refunded. Retry${reqLine}`;
+ case "PROVIDER_ERROR":
+ return `Service error. Please retry shortly${reqLine}`;
+ case "INTERNAL_ERROR":
+ return `Unexpected error. Please retry; if this persists, contact support${reqLine}`;
+ default:
+ // Pass the API's white-labeled message through unchanged
+ return `${err.message ?? err.code}${reqLine}`;
+ }
+}
diff --git a/src/client/http.ts b/src/client/http.ts
new file mode 100644
index 0000000..a4be7fb
--- /dev/null
+++ b/src/client/http.ts
@@ -0,0 +1,164 @@
+import { makeDebugLogger } from "./debug.js";
+
+const GET_TIMEOUT_MS = 10_000;
+const WRITE_TIMEOUT_MS = 30_000;
+const GET_RETRIES = 2;
+const RETRY_DELAY_MS = 250;
+
+export interface HttpResponse {
+ status: number;
+ body?: unknown;
+ binary?: Buffer;
+ headers: Headers;
+}
+
+export interface HttpRequestOpts {
+ body?: unknown;
+ idempotencyKey?: string;
+ headers?: Record;
+ expectBinary?: boolean;
+}
+
+export interface HttpClient {
+ request(
+ method: string,
+ path: string,
+ opts?: HttpRequestOpts,
+ ): Promise;
+}
+
+export class HttpError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ public requestId: string,
+ public details?: Record,
+ message?: string,
+ ) {
+ super(message ?? `${code} (status ${status})`);
+ this.name = "HttpError";
+ }
+}
+
+export class NetworkError extends Error {
+ constructor(public cause: unknown) {
+ super("Network error reaching the VoidMob API");
+ this.name = "NetworkError";
+ }
+}
+
+interface ClientOpts {
+ apiKey: string;
+ baseUrl: string;
+ debug: boolean;
+ userAgent: string;
+}
+
+function jitter(): number {
+ return Math.random() * 100;
+}
+
+export function createHttpClient(opts: ClientOpts): HttpClient {
+ const dbg = makeDebugLogger(opts.debug);
+
+ async function doOnce(method: string, path: string, ropts: HttpRequestOpts): Promise {
+ const ac = new AbortController();
+ const timeoutMs = method.toUpperCase() === "GET" ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS;
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ const headers = new Headers({
+ Authorization: `Bearer ${opts.apiKey}`,
+ "User-Agent": opts.userAgent,
+ "Content-Type": "application/json",
+ ...(ropts.headers ?? {}),
+ });
+ if (ropts.idempotencyKey) headers.set("Idempotency-Key", ropts.idempotencyKey);
+
+ const startMs = Date.now();
+ let res: Response;
+ try {
+ res = (await fetch(`${opts.baseUrl}${path}`, {
+ method,
+ headers,
+ body: ropts.body !== undefined ? JSON.stringify(ropts.body) : undefined,
+ signal: ac.signal,
+ })) as Response;
+ } catch (e) {
+ clearTimeout(timer);
+ throw new NetworkError(e);
+ }
+ clearTimeout(timer);
+
+ const elapsed = Date.now() - startMs;
+
+ if (ropts.expectBinary && res.status >= 200 && res.status < 300) {
+ let ab: ArrayBuffer;
+ try {
+ ab = await res.arrayBuffer();
+ } catch (e) {
+ throw new NetworkError(e);
+ }
+ dbg(`${method} ${path} ${res.status} (${elapsed}ms) [binary ${ab.byteLength}b]`);
+ return { status: res.status, binary: Buffer.from(ab), headers: res.headers };
+ }
+
+ let body: unknown;
+ try {
+ body = await res.json();
+ } catch {
+ body = undefined;
+ }
+
+ const idem = ropts.idempotencyKey ? ` idem=${ropts.idempotencyKey.slice(0, 8)}...` : "";
+ const errObj =
+ body && typeof body === "object" && "error" in body
+ ? (body as Record).error
+ : undefined;
+ const codeOrEmpty =
+ errObj && typeof errObj === "object" && "code" in errObj &&
+ typeof (errObj as Record).code === "string"
+ ? ` ${(errObj as { code: string }).code}`
+ : "";
+ dbg(`${method} ${path}${idem} ${res.status}${codeOrEmpty} (${elapsed}ms)`);
+
+ if (res.status >= 200 && res.status < 300) {
+ return { status: res.status, body, headers: res.headers };
+ }
+
+ const errBody = (body as {
+ error?: { code: string; message: string; request_id: string; details?: Record };
+ } | undefined)?.error;
+ throw new HttpError(
+ res.status,
+ errBody?.code ?? "UNKNOWN_ERROR",
+ errBody?.request_id ?? "",
+ errBody?.details,
+ errBody?.message,
+ );
+ }
+
+ return {
+ async request(method, path, ropts = {}) {
+ const isGet = method.toUpperCase() === "GET";
+ const maxAttempts = isGet ? 1 + GET_RETRIES : 1;
+ let lastErr: unknown;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ return await doOnce(method, path, ropts);
+ } catch (e) {
+ lastErr = e;
+ if (!isGet) throw e;
+ if (e instanceof HttpError) {
+ // Retry only on 5xx; never on 429 or 4xx
+ if (e.status < 500) throw e;
+ } else if (!(e instanceof NetworkError)) {
+ throw e;
+ }
+ if (attempt < maxAttempts) {
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS + jitter()));
+ }
+ }
+ }
+ throw lastErr;
+ },
+ };
+}
diff --git a/src/client/idempotency.ts b/src/client/idempotency.ts
new file mode 100644
index 0000000..91e8a71
--- /dev/null
+++ b/src/client/idempotency.ts
@@ -0,0 +1,5 @@
+import { randomUUID } from "node:crypto";
+
+export function newIdempotencyKey(): string {
+ return randomUUID();
+}
diff --git a/src/client/types.ts b/src/client/types.ts
new file mode 100644
index 0000000..b90a3e9
--- /dev/null
+++ b/src/client/types.ts
@@ -0,0 +1,268 @@
+import { z } from "zod";
+
+// ── Common envelopes ────────────────────────────────────────────────────────
+
+export const SuccessEnvelope = (inner: T) =>
+ z.object({ success: z.literal(true), data: inner });
+
+// ── /v1/me ──────────────────────────────────────────────────────────────────
+
+export const MePayload = z.object({
+ id: z.string(),
+ balance: z.object({
+ amount_cents: z.number().int(),
+ currency: z.literal("USD"),
+ formatted: z.string(),
+ }),
+ rate_limits: z.record(
+ z.string(),
+ z.object({ limit: z.number(), window_seconds: z.number() }),
+ ),
+ created_at: z.string(),
+});
+export type MePayload = z.infer;
+
+// ── /v1/services ────────────────────────────────────────────────────────────
+
+export const SmsService = z.object({
+ id: z.string(),
+ name: z.string(),
+ icon_url: z.string().nullable().optional(),
+ quoted_price_cents: z.number().int(),
+ base_price_cents: z.number().int().optional(),
+ price_ceiling_cents: z.number().int().optional(),
+ available: z.boolean().optional(),
+ // Long-term rental tiers are flat fields. `0` means the tier is not offered.
+ ltr_3d_price_cents: z.number().int().optional(),
+ ltr_7d_price_cents: z.number().int().optional(),
+ ltr_14d_price_cents: z.number().int().optional(),
+ ltr_30d_price_cents: z.number().int().optional(),
+ // Only present on the svc_dedicated_28d row (the 28-day dedicated number tier).
+ ltr_28d_price_cents: z.number().int().optional(),
+});
+export type SmsService = z.infer;
+
+export const ServicesResponse = z.object({
+ country: z.string().optional(),
+ services: z.array(SmsService),
+ fetched_at: z.string().optional(),
+});
+
+// ── Verifications ────────────────────────────────────────────────────────────
+
+export const Verification = z.object({
+ id: z.string(),
+ display_id: z.string().nullable().optional(),
+ status: z.enum(["waiting_for_code", "code_received", "cancelled", "expired", "failed"]),
+ phone_number: z.string(),
+ service_id: z.string().nullable(),
+ service_name: z.string(),
+ charged_price_cents: z.number().int(),
+ expires_at: z.string(),
+ can_cancel: z.boolean(),
+ created_at: z.string(),
+ // The received SMS code (present only when status = code_received).
+ code: z.string().optional(),
+ code_received_at: z.string().optional(),
+ reuse_counter: z.number().int(),
+ allow_reuse: z.boolean(),
+ allow_paid_reuse: z.boolean(),
+ paid_reuse_price_cents: z.number().int(),
+ charged_reuse_cents: z.number().int().optional(),
+ refunded_cents: z.number().int().optional(),
+});
+export type Verification = z.infer;
+
+// Terminal-action responses (cancel) return a slim verification object, not
+// the full resource: { id, status, refunded_cents }.
+export const VerificationCancelResult = z.object({
+ id: z.string(),
+ status: z.string(),
+ refunded_cents: z.number().int().optional(),
+});
+export type VerificationCancelResult = z.infer;
+
+// ── Rentals (long-term + 28-day dedicated) ──────────────────────────────────
+
+export const RentalMessage = z.object({
+ id: z.string(),
+ code: z.string().nullable().optional(),
+ text: z.string(),
+ received_at: z.string(),
+});
+
+export const Rental = z.object({
+ id: z.string(),
+ display_id: z.string().nullable().optional(),
+ status: z.enum(["active", "expired", "cancelled"]),
+ phone_number: z.string(),
+ service_id: z.string().nullable(),
+ service_name: z.string(),
+ country: z.string(),
+ duration: z.string(),
+ rental_type: z.literal("rental"),
+ charged_price_cents: z.number().int(),
+ auto_renew: z.boolean(),
+ next_renewal_price_cents: z.number().int(),
+ re_rent_available: z.boolean(),
+ re_rent_price_cents: z.number().int().nullable(),
+ re_rent_blocked_at: z.string().nullable(),
+ created_at: z.string(),
+ paid_until: z.string(),
+ expires_at: z.string(),
+ can_cancel: z.boolean(),
+ cancel_window_expires_at: z.string().nullable().optional(),
+ messages: z.array(RentalMessage).optional(),
+});
+export type Rental = z.infer;
+
+// ── eSIM products + orders ──────────────────────────────────────────────────
+
+export const EsimProductFeatures = z.object({
+ has_5g: z.boolean(),
+ has_hotspot: z.boolean(),
+ has_calls: z.boolean(),
+ has_sms: z.boolean(),
+ supports_topup: z.boolean(),
+});
+
+export const EsimProduct = z.object({
+ id: z.string(),
+ title: z.string(),
+ countries: z.array(z.string()),
+ region: z.string().nullable(),
+ country_count: z.number().int(),
+ routing_location: z.string().nullable(),
+ data_limit_gb: z.number().nullable(),
+ data_unlimited: z.boolean(),
+ validity_days: z.number().int(),
+ features: EsimProductFeatures,
+ price_cents: z.number().int(),
+ currency: z.literal("USD"),
+});
+export type EsimProduct = z.infer;
+
+export const Esim = z.object({
+ id: z.string(),
+ status: z.enum(["processing", "completed", "cancelled", "refunded", "expired"]),
+ product_id: z.string().nullable(),
+ is_topup: z.boolean(),
+ parent_order_id: z.string().nullable(),
+ iccid: z.string().nullable(),
+ activation_code: z.string().nullable(),
+ qr_code_url: z.string().nullable(),
+ smdp_address: z.string().nullable(),
+ data_limit_gb: z.number().nullable(),
+ data_unlimited: z.boolean(),
+ validity_days: z.number().int(),
+ countries: z.array(z.string()),
+ routing_location: z.string().nullable(),
+ charged_price_cents: z.number().int(),
+ currency: z.literal("USD"),
+ created_at: z.string(),
+ completed_at: z.string().nullable(),
+ expires_at: z.string().nullable(),
+});
+export type Esim = z.infer;
+
+export const EsimUsagePackage = z.object({
+ name: z.string(),
+ total_mb: z.number(),
+ total_gb: z.number(),
+ used_mb: z.number(),
+ used_gb: z.number(),
+ remaining_mb: z.number(),
+ remaining_gb: z.number(),
+ percent_used: z.number(),
+ activation_date: z.string().nullable(),
+ expiration_date: z.string().nullable(),
+});
+
+export const EsimUsage = z.object({
+ esim_id: z.string(),
+ esim_status: z.string(),
+ packages: z.array(EsimUsagePackage),
+});
+export type EsimUsage = z.infer;
+
+// ── Proxies ──────────────────────────────────────────────────────────────────
+
+export const ProxyGateway = z.object({
+ host: z.string(),
+ port: z.number().int(),
+ protocol: z.enum(["http", "socks5"]),
+ username: z.string(),
+ password: z.string(),
+ username_geo_hint: z.string().optional(),
+});
+
+export const ProxyCredentials = z.object({
+ host: z.string(),
+ port: z.number().int(),
+ protocol: z.string(),
+ username: z.string(),
+ password: z.string(),
+});
+
+export const ProxyList = z.object({
+ id: z.string(),
+ proxy_id: z.string(),
+ name: z.string(),
+ country: z.string().nullable().optional(),
+ countries: z.array(z.string()).nullable().optional(),
+ region: z.string().nullable().optional(),
+ city: z.string().nullable().optional(),
+ isp: z.string().nullable().optional(),
+ zip: z.string().nullable().optional(),
+ rotation_period_seconds: z.number().int(),
+ rotation_mode: z.string(),
+ format: z.string(),
+ credentials: ProxyCredentials.nullable(),
+ entries: z.array(z.string()),
+ activation_note: z.string(),
+ created_at: z.string(),
+});
+export type ProxyList = z.infer;
+
+export const Proxy = z.object({
+ id: z.string(),
+ status: z.enum(["provisioning", "active", "expired", "refunded", "exhausted"]),
+ plan_id: z.string().nullable().optional(),
+ data_gb_total: z.number().int(),
+ data_bytes_used: z.number().int(),
+ charged_price_cents: z.number().int(),
+ expires_at: z.string(),
+ gateway: ProxyGateway.nullable(),
+ lists: z.array(ProxyList).default([]),
+ rotation_url: z.string().nullable().optional(),
+ created_at: z.string().optional(),
+});
+export type Proxy = z.infer;
+
+export const ProxyPlan = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: z.enum(["shared"]),
+ country: z.string().nullable(),
+ country_name: z.string().nullable().optional(),
+ data_gb: z.number().int(),
+ duration_days: z.number().int(),
+ period: z.enum(["daily", "weekly", "monthly"]).optional(),
+ quoted_price_cents: z.number().int(),
+ available: z.boolean().optional(),
+});
+export type ProxyPlan = z.infer;
+
+// ── Geo ─────────────────────────────────────────────────────────────────────
+
+export const GeoCountry = z.object({
+ code: z.string(),
+ name: z.string(),
+ available_nodes: z.number().int(),
+});
+
+// Regions, cities, and ISPs are all GeoNode shape: { name, available_nodes }.
+// No `code` field (countries are the only geo level that carry a code).
+export const GeoRegion = z.object({ name: z.string(), available_nodes: z.number().int() });
+export const GeoCity = GeoRegion;
+export const GeoIsp = GeoRegion;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..97b7b8d
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,45 @@
+export class ConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigError";
+ }
+}
+
+export interface Config {
+ sandbox: boolean;
+ apiKey: string | null;
+ baseUrl: string;
+ debug: boolean;
+}
+
+const DEFAULT_BASE_URL = "https://dashboard.voidmob.com/api";
+const SETUP_URL = "https://dashboard.voidmob.com/developers/api-keys";
+
+const KEY_RE = /^vmk_(live|test)_[A-Za-z0-9]{32}$/;
+
+export function parseEnv(env: NodeJS.ProcessEnv | Record = process.env): Config {
+ const sandbox = env.VOIDMOB_SANDBOX === "1";
+ const debug = env.VOIDMOB_DEBUG === "1";
+ const baseUrl = env.VOIDMOB_BASE_URL ?? DEFAULT_BASE_URL;
+ const rawKey = env.VOIDMOB_API_KEY?.trim() || null;
+
+ if (sandbox) {
+ return { sandbox: true, apiKey: null, baseUrl, debug };
+ }
+
+ if (!rawKey) {
+ throw new ConfigError(
+ `Set VOIDMOB_API_KEY=vmk_live_... or VOIDMOB_SANDBOX=1.\n` +
+ `Generate a key at ${SETUP_URL}`,
+ );
+ }
+
+ if (!KEY_RE.test(rawKey)) {
+ throw new ConfigError(
+ `VOIDMOB_API_KEY format is invalid. Expected vmk_live_ or vmk_test_ followed by 32 alphanumeric characters.\n` +
+ `Generate a key at ${SETUP_URL}`,
+ );
+ }
+
+ return { sandbox: false, apiKey: rawKey, baseUrl, debug };
+}
diff --git a/src/constants/rental-id.ts b/src/constants/rental-id.ts
new file mode 100644
index 0000000..3a92775
--- /dev/null
+++ b/src/constants/rental-id.ts
@@ -0,0 +1,8 @@
+export const VER_PREFIX = "ver_";
+export const REN_PREFIX = "ren_";
+
+export const isVerificationId = (id: string): boolean => id.startsWith(VER_PREFIX);
+export const isRentalId = (id: string): boolean => id.startsWith(REN_PREFIX);
+
+export const INVALID_RENTAL_ID = (id: string): string =>
+ `Invalid rental_id '${id}'. Expected ${VER_PREFIX}xxx (verification) or ${REN_PREFIX}xxx (long-term/dedicated).`;
diff --git a/src/index.ts b/src/index.ts
index 12ae29e..6d89cd1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,35 +1,30 @@
#!/usr/bin/env node
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { registerWalletTools } from "./tools/wallet.js";
-import { registerSmsTools } from "./tools/sms.js";
-import { registerEsimTools } from "./tools/esim.js";
-import { registerProxyTools } from "./tools/proxy.js";
-import { registerOrdersTools } from "./tools/orders.js";
-
-function createServer() {
- const server = new McpServer({
- name: "@voidmob/mcp",
- version: "0.2.0",
- });
-
- registerWalletTools(server);
- registerSmsTools(server);
- registerEsimTools(server);
- registerProxyTools(server);
- registerOrdersTools(server);
-
- return server;
-}
-
-export function createSandboxServer() {
- return createServer();
-}
+import { parseEnv, ConfigError } from "./config.js";
+import { buildLiveServer } from "./modes/live.js";
+import { buildSandboxServer } from "./modes/sandbox.js";
async function main() {
- const server = createServer();
+ let cfg;
+ try {
+ cfg = parseEnv();
+ } catch (e) {
+ if (e instanceof ConfigError) {
+ process.stderr.write(`[voidmob-mcp] config error: ${e.message}\n`);
+ process.exit(1);
+ }
+ throw e;
+ }
+
+ const server = cfg.sandbox ? buildSandboxServer() : buildLiveServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
}
-main().catch(console.error);
+main().catch((err) => {
+ process.stderr.write(`[voidmob-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
+ process.exit(1);
+});
+
+export { buildSandboxServer } from "./modes/sandbox.js";
+export { buildLiveServer } from "./modes/live.js";
diff --git a/src/mock-data/esim.ts b/src/mock-data/esim.ts
deleted file mode 100644
index ae69253..0000000
--- a/src/mock-data/esim.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-export interface EsimPlan {
- id: string;
- title: string;
- countries: string[];
- dataLimitGb: number | null;
- dataUnlimited: boolean;
- validityDays: number;
- retailPriceUsd: number;
- has5g: boolean;
- hasHotspot: boolean;
- supportsTopup: boolean;
- routingLocation: string;
- networkType: string;
- speed: string;
- activationPolicy: string;
- tags: string[];
-}
-
-export interface EsimTopupProduct {
- id: string;
- parentPlanId: string;
- title: string;
- dataLimitGb: number;
- validityDays: number;
- retailPriceUsd: number;
-}
-
-export const esimPlans: EsimPlan[] = [
- { id: "esim_jp_3g_7d", title: "Japan 3GB / 7 Days", countries: ["JP"], dataLimitGb: 3, dataUnlimited: false, validityDays: 7, retailPriceUsd: 4.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Budget"] },
- { id: "esim_jp_5g_14d", title: "Japan 5GB / 14 Days", countries: ["JP"], dataLimitGb: 5, dataUnlimited: false, validityDays: 14, retailPriceUsd: 7.50, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Popular"] },
- { id: "esim_jp_10g_30d", title: "Japan 10GB / 30 Days", countries: ["JP"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 12.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] },
- { id: "esim_jp_unl_30d", title: "Japan Unlimited / 30 Days", countries: ["JP"], dataLimitGb: null, dataUnlimited: true, validityDays: 30, retailPriceUsd: 22.00, has5g: true, hasHotspot: false, supportsTopup: false, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Unlimited"] },
- { id: "esim_us_5g_7d", title: "USA 5GB / 7 Days", countries: ["US"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 6.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: [] },
- { id: "esim_us_10g_30d", title: "USA 10GB / 30 Days", countries: ["US"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 11.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: ["Popular"] },
- { id: "esim_us_20g_30d", title: "USA 20GB / 30 Days", countries: ["US"], dataLimitGb: 20, dataUnlimited: false, validityDays: 30, retailPriceUsd: 18.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] },
- { id: "esim_gb_5g_7d", title: "UK 5GB / 7 Days", countries: ["GB"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.50, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United Kingdom", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: [] },
- { id: "esim_gb_10g_30d", title: "UK 10GB / 30 Days", countries: ["GB"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 10.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United Kingdom", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Popular"] },
- { id: "esim_de_5g_7d", title: "Germany 5GB / 7 Days", countries: ["DE"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] },
- { id: "esim_de_10g_30d", title: "Germany 10GB / 30 Days", countries: ["DE"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 9.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] },
- { id: "esim_th_5g_7d", title: "Thailand 5GB / 7 Days", countries: ["TH"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 3.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Thailand", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Budget"] },
- { id: "esim_th_15g_30d", title: "Thailand 15GB / 30 Days", countries: ["TH"], dataLimitGb: 15, dataUnlimited: false, validityDays: 30, retailPriceUsd: 8.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Thailand", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] },
- { id: "esim_tr_5g_7d", title: "Turkey 5GB / 7 Days", countries: ["TR"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 4.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Turkey", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] },
- { id: "esim_br_5g_7d", title: "Brazil 5GB / 7 Days", countries: ["BR"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Brazil", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] },
- { id: "esim_eu_5g_7d", title: "Europe 30 Countries / 5GB / 7 Days", countries: ["GB", "DE", "FR", "IT", "ES", "NL", "BE", "AT", "CH", "PT", "SE", "NO", "DK", "FI", "IE", "PL", "CZ", "RO", "HU", "GR", "HR", "BG", "SK", "SI", "LT", "LV", "EE", "CY", "MT", "LU"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 8.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional", "Popular"] },
- { id: "esim_eu_10g_30d", title: "Europe 30 Countries / 10GB / 30 Days", countries: ["GB", "DE", "FR", "IT", "ES", "NL", "BE", "AT", "CH", "PT", "SE", "NO", "DK", "FI", "IE", "PL", "CZ", "RO", "HU", "GR", "HR", "BG", "SK", "SI", "LT", "LV", "EE", "CY", "MT", "LU"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 14.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional", "Best Value"] },
- { id: "esim_sea_5g_7d", title: "Southeast Asia 8 Countries / 5GB / 7 Days", countries: ["TH", "VN", "MY", "SG", "ID", "PH", "KH", "LA"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 6.00, has5g: false, hasHotspot: true, supportsTopup: false, routingLocation: "Singapore", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional"] },
-];
-
-export const esimTopupProducts: EsimTopupProduct[] = [
- { id: "topup_1g_7d", parentPlanId: "*", title: "1GB Top-up / 7 Days", dataLimitGb: 1, validityDays: 7, retailPriceUsd: 2.00 },
- { id: "topup_3g_30d", parentPlanId: "*", title: "3GB Top-up / 30 Days", dataLimitGb: 3, validityDays: 30, retailPriceUsd: 4.50 },
- { id: "topup_5g_30d", parentPlanId: "*", title: "5GB Top-up / 30 Days", dataLimitGb: 5, validityDays: 30, retailPriceUsd: 7.00 },
- { id: "topup_10g_30d", parentPlanId: "*", title: "10GB Top-up / 30 Days", dataLimitGb: 10, validityDays: 30, retailPriceUsd: 12.00 },
-];
-
-export function searchPlans(opts: {
- country?: string; duration?: number; dataAmount?: number;
- has5g?: boolean; hasHotspot?: boolean; search?: string;
- limit?: number;
-}): EsimPlan[] {
- let results = esimPlans;
- if (opts.country) {
- const c = opts.country.toUpperCase();
- results = results.filter((p) => p.countries.includes(c));
- }
- if (opts.duration) results = results.filter((p) => p.validityDays >= opts.duration!);
- if (opts.dataAmount) results = results.filter((p) => p.dataUnlimited || (p.dataLimitGb !== null && p.dataLimitGb >= opts.dataAmount!));
- if (opts.has5g) results = results.filter((p) => p.has5g);
- if (opts.hasHotspot) results = results.filter((p) => p.hasHotspot);
- if (opts.search) {
- const q = opts.search.toLowerCase();
- results = results.filter((p) => p.title.toLowerCase().includes(q));
- }
- results = results.sort((a, b) => a.retailPriceUsd - b.retailPriceUsd);
- return results.slice(0, opts.limit ?? 20);
-}
-
-export function getPlan(planId: string): EsimPlan | undefined {
- return esimPlans.find((p) => p.id === planId);
-}
-
-export function getTopupProducts(planId: string): EsimTopupProduct[] {
- const plan = getPlan(planId);
- if (!plan || !plan.supportsTopup) return [];
- return esimTopupProducts.filter((t) => t.parentPlanId === "*" || t.parentPlanId === planId);
-}
-
-export function getTopupProduct(topupId: string): EsimTopupProduct | undefined {
- return esimTopupProducts.find((t) => t.id === topupId);
-}
diff --git a/src/mock-data/proxy.ts b/src/mock-data/proxy.ts
deleted file mode 100644
index 31b1390..0000000
--- a/src/mock-data/proxy.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { ProxyType } from "../sandbox/state.js";
-
-export interface ProxyProduct {
- id: string;
- type: ProxyType;
- name: string;
- country: string;
- countryName: string;
- carrier: string;
- carrierName: string;
- dataGb: number | null;
- durationDays: number;
- period: string;
- priceCents: number;
- features: string[];
-}
-
-export const proxyProducts: ProxyProduct[] = [
- { id: "prx_sh_us_1g", type: "shared", name: "US Shared 1GB", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 299, features: [] },
- { id: "prx_sh_us_5g", type: "shared", name: "US Shared 5GB", country: "US", countryName: "United States", carrier: "tmobile", carrierName: "T-Mobile", dataGb: 5, durationDays: 30, period: "monthly", priceCents: 1249, features: [] },
- { id: "prx_sh_gb_1g", type: "shared", name: "UK Shared 1GB", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 299, features: [] },
- { id: "prx_sh_de_1g", type: "shared", name: "DE Shared 1GB", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 329, features: [] },
- { id: "prx_sh_br_1g", type: "shared", name: "BR Shared 1GB", country: "BR", countryName: "Brazil", carrier: "claro", carrierName: "Claro", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 199, features: [] },
- { id: "prx_sh_in_1g", type: "shared", name: "IN Shared 1GB", country: "IN", countryName: "India", carrier: "jio", carrierName: "Jio", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 99, features: [] },
- { id: "prx_ds_us_mo", type: "dedicated_standard", name: "US Dedicated Standard", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: null, durationDays: 30, period: "monthly", priceCents: 8000, features: ["socks5", "rotation"] },
- { id: "prx_ds_gb_mo", type: "dedicated_standard", name: "UK Dedicated Standard", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: null, durationDays: 30, period: "monthly", priceCents: 8500, features: ["socks5", "rotation"] },
- { id: "prx_ds_de_mo", type: "dedicated_standard", name: "DE Dedicated Standard", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: null, durationDays: 30, period: "monthly", priceCents: 9000, features: ["socks5", "rotation"] },
- { id: "prx_ds_nl_mo", type: "dedicated_standard", name: "NL Dedicated Standard", country: "NL", countryName: "Netherlands", carrier: "kpn", carrierName: "KPN", dataGb: null, durationDays: 30, period: "monthly", priceCents: 7500, features: ["socks5", "rotation"] },
- { id: "prx_dp_us_mo", type: "dedicated_premium", name: "US Dedicated Premium", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: null, durationDays: 30, period: "monthly", priceCents: 12000, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] },
- { id: "prx_dp_gb_mo", type: "dedicated_premium", name: "UK Dedicated Premium", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: null, durationDays: 30, period: "monthly", priceCents: 13000, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] },
- { id: "prx_dp_de_mo", type: "dedicated_premium", name: "DE Dedicated Premium", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: null, durationDays: 30, period: "monthly", priceCents: 13500, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] },
-];
-
-export function searchProducts(opts?: {
- country?: string;
- type?: ProxyType;
-}): ProxyProduct[] {
- let results = proxyProducts;
- if (opts?.country) {
- const c = opts.country.toUpperCase();
- results = results.filter((p) => p.country === c);
- }
- if (opts?.type) results = results.filter((p) => p.type === opts.type);
- return results;
-}
-
-export function getProduct(productId: string): ProxyProduct | undefined {
- return proxyProducts.find((p) => p.id === productId);
-}
diff --git a/src/mock-data/sms.ts b/src/mock-data/sms.ts
deleted file mode 100644
index 1bb9315..0000000
--- a/src/mock-data/sms.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-export interface SmsService {
- id: string;
- service: string;
- serviceName: string;
- available: boolean;
- priceUsdCents: number;
- ltr3PriceCents: number;
- ltr7PriceCents: number;
- ltr14PriceCents: number;
- ltr30PriceCents: number;
- dedicatedPriceCents: number;
- hasIcon: boolean;
-}
-
-export const smsServices: SmsService[] = [
- { id: "wa", service: "wa", serviceName: "WhatsApp", available: true, priceUsdCents: 250, ltr3PriceCents: 550, ltr7PriceCents: 900, ltr14PriceCents: 1500, ltr30PriceCents: 2500, dedicatedPriceCents: 3000, hasIcon: true },
- { id: "tg", service: "tg", serviceName: "Telegram", available: true, priceUsdCents: 150, ltr3PriceCents: 350, ltr7PriceCents: 600, ltr14PriceCents: 1000, ltr30PriceCents: 1700, dedicatedPriceCents: 2200, hasIcon: true },
- { id: "go", service: "go", serviceName: "Google / Gmail", available: true, priceUsdCents: 180, ltr3PriceCents: 400, ltr7PriceCents: 700, ltr14PriceCents: 1200, ltr30PriceCents: 2000, dedicatedPriceCents: 2500, hasIcon: true },
- { id: "tw", service: "tw", serviceName: "Twitter / X", available: true, priceUsdCents: 200, ltr3PriceCents: 450, ltr7PriceCents: 750, ltr14PriceCents: 1300, ltr30PriceCents: 2200, dedicatedPriceCents: 2700, hasIcon: true },
- { id: "ig", service: "ig", serviceName: "Instagram", available: true, priceUsdCents: 220, ltr3PriceCents: 500, ltr7PriceCents: 850, ltr14PriceCents: 1400, ltr30PriceCents: 2400, dedicatedPriceCents: 2900, hasIcon: true },
- { id: "ds", service: "ds", serviceName: "Discord", available: true, priceUsdCents: 120, ltr3PriceCents: 280, ltr7PriceCents: 480, ltr14PriceCents: 800, ltr30PriceCents: 1400, dedicatedPriceCents: 1800, hasIcon: true },
- { id: "tk", service: "tk", serviceName: "TikTok", available: true, priceUsdCents: 250, ltr3PriceCents: 550, ltr7PriceCents: 900, ltr14PriceCents: 1500, ltr30PriceCents: 2500, dedicatedPriceCents: 3000, hasIcon: true },
- { id: "fb", service: "fb", serviceName: "Facebook", available: true, priceUsdCents: 180, ltr3PriceCents: 400, ltr7PriceCents: 700, ltr14PriceCents: 1200, ltr30PriceCents: 2000, dedicatedPriceCents: 2500, hasIcon: true },
- { id: "ub", service: "ub", serviceName: "Uber", available: true, priceUsdCents: 200, ltr3PriceCents: 450, ltr7PriceCents: 750, ltr14PriceCents: 1300, ltr30PriceCents: 2200, dedicatedPriceCents: 2700, hasIcon: true },
- { id: "oa", service: "oa", serviceName: "OpenAI / ChatGPT", available: true, priceUsdCents: 300, ltr3PriceCents: 650, ltr7PriceCents: 1100, ltr14PriceCents: 1800, ltr30PriceCents: 3000, dedicatedPriceCents: 3500, hasIcon: true },
-];
-
-export function searchServices(query?: string): SmsService[] {
- if (!query) return smsServices.filter((s) => s.available);
- const q = query.toLowerCase();
- return smsServices.filter(
- (s) => s.available && (
- s.serviceName.toLowerCase().includes(q) ||
- s.id.toLowerCase().includes(q) ||
- s.service.toLowerCase().includes(q)
- )
- );
-}
-
-export function getService(serviceId: string): SmsService | undefined {
- return smsServices.find((s) => s.id === serviceId || s.service === serviceId);
-}
diff --git a/src/modes/live.ts b/src/modes/live.ts
new file mode 100644
index 0000000..aecf8f6
--- /dev/null
+++ b/src/modes/live.ts
@@ -0,0 +1,35 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { Config } from "../config.js";
+import { createHttpClient } from "../client/http.js";
+import { registerAccountTools } from "../tools/account.js";
+import { registerSmsTools } from "../tools/sms.js";
+import { registerEsimTools } from "../tools/esim.js";
+import { registerProxyTools } from "../tools/proxy.js";
+import { registerGeoTools } from "../tools/geo.js";
+import { registerOrdersTools } from "../tools/orders.js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) as { version: string };
+
+export function buildLiveServer(cfg: Config): McpServer {
+ if (!cfg.apiKey) throw new Error("buildLiveServer requires an API key");
+ const http = createHttpClient({
+ apiKey: cfg.apiKey,
+ baseUrl: cfg.baseUrl,
+ debug: cfg.debug,
+ userAgent: `voidmob-mcp/${pkg.version} node/${process.version}`,
+ });
+ const server = new McpServer({ name: "@voidmob/mcp", version: pkg.version });
+
+ registerAccountTools(server, http);
+ registerSmsTools(server, http);
+ registerEsimTools(server, http);
+ registerProxyTools(server, http);
+ registerGeoTools(server, http);
+ registerOrdersTools(server, http);
+
+ return server;
+}
diff --git a/src/modes/sandbox.ts b/src/modes/sandbox.ts
new file mode 100644
index 0000000..ec52bb3
--- /dev/null
+++ b/src/modes/sandbox.ts
@@ -0,0 +1,31 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { createSandboxHttpClient } from "../sandbox/mock-http.js";
+import { registerAccountTools } from "../tools/account.js";
+import { registerSmsTools } from "../tools/sms.js";
+import { registerEsimTools } from "../tools/esim.js";
+import { registerProxyTools } from "../tools/proxy.js";
+import { registerGeoTools } from "../tools/geo.js";
+import { registerOrdersTools } from "../tools/orders.js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) as { version: string };
+
+// Sandbox mode registers the exact same live tools, but injects an in-memory
+// mock HttpClient instead of the real one. The tool surface is therefore
+// identical to live by construction - it cannot drift.
+export function buildSandboxServer(): McpServer {
+ const http = createSandboxHttpClient();
+ const server = new McpServer({ name: "@voidmob/mcp", version: pkg.version });
+
+ registerAccountTools(server, http);
+ registerSmsTools(server, http);
+ registerEsimTools(server, http);
+ registerProxyTools(server, http);
+ registerGeoTools(server, http);
+ registerOrdersTools(server, http);
+
+ return server;
+}
diff --git a/src/sandbox/mock-http.ts b/src/sandbox/mock-http.ts
new file mode 100644
index 0000000..abee7b7
--- /dev/null
+++ b/src/sandbox/mock-http.ts
@@ -0,0 +1,560 @@
+import type { HttpClient, HttpResponse, HttpRequestOpts } from "../client/http.js";
+import type {
+ MePayload,
+ SmsService,
+ Verification,
+ Rental,
+ EsimProduct,
+ Esim,
+ EsimUsage,
+ Proxy,
+ ProxyList,
+ ProxyPlan,
+} from "../client/types.js";
+import { formatUsd } from "../utils/format.js";
+
+// In-memory mock of the VoidMob v1 API. The sandbox registers the SAME live
+// tools (src/tools/*) but with this client injected instead of the real HTTP
+// one, so the sandbox tool surface is always identical to live by construction.
+// Everything here is fake data generated at runtime; nothing leaves the process
+// and state resets when the server restarts.
+
+// ── tiny generators ──────────────────────────────────────────────────────────
+
+let seq = 0;
+const uid = (prefix: string): string => `${prefix}${Date.now().toString(36)}${(seq++).toString(36)}`;
+const rnd = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min;
+const hex = (n: number): string => Array.from({ length: n }, () => rnd(0, 15).toString(16)).join("");
+const alnum = (n: number): string => {
+ const c = "abcdefghijkmnpqrstuvwxyz23456789";
+ return Array.from({ length: n }, () => c[rnd(0, c.length - 1)]).join("");
+};
+const phone = (): string => `+1${rnd(200, 989)}${rnd(200, 989)}${rnd(1000, 9999)}`;
+const smsCode = (): string => String(rnd(100000, 999999));
+const ip = (): string => `${rnd(11, 223)}.${rnd(1, 254)}.${rnd(1, 254)}.${rnd(1, 254)}`;
+const iso = (offsetMs = 0): string => new Date(Date.now() + offsetMs).toISOString();
+const DAY = 86_400_000;
+
+// Time (ms) before a verification's code "arrives" / a proxy goes active, so the
+// poll-until-ready flow the tools describe is demonstrable without a long wait.
+const READY_AFTER_MS = 1_500;
+
+// ── catalog (static, shaped to the live Zod schemas) ─────────────────────────
+
+const SERVICES: SmsService[] = [
+ { id: "svc_whatsapp", name: "WhatsApp", quoted_price_cents: 250, available: true, ltr_3d_price_cents: 550, ltr_7d_price_cents: 900, ltr_14d_price_cents: 1500, ltr_30d_price_cents: 2500 },
+ { id: "svc_telegram", name: "Telegram", quoted_price_cents: 150, available: true, ltr_3d_price_cents: 350, ltr_7d_price_cents: 600, ltr_14d_price_cents: 1000, ltr_30d_price_cents: 1700 },
+ { id: "svc_google", name: "Google", quoted_price_cents: 180, available: true, ltr_3d_price_cents: 400, ltr_7d_price_cents: 700, ltr_14d_price_cents: 1200, ltr_30d_price_cents: 2000 },
+ { id: "svc_twitter", name: "Twitter / X", quoted_price_cents: 200, available: true, ltr_7d_price_cents: 750, ltr_30d_price_cents: 2200 },
+ { id: "svc_instagram", name: "Instagram", quoted_price_cents: 220, available: true, ltr_7d_price_cents: 850, ltr_30d_price_cents: 2400 },
+ { id: "svc_discord", name: "Discord", quoted_price_cents: 120, available: true, ltr_7d_price_cents: 480, ltr_30d_price_cents: 1400 },
+ { id: "svc_tiktok", name: "TikTok", quoted_price_cents: 250, available: true, ltr_7d_price_cents: 900 },
+ { id: "svc_openai", name: "OpenAI", quoted_price_cents: 300, available: true, ltr_7d_price_cents: 1100, ltr_30d_price_cents: 3000 },
+ { id: "svc_dedicated_28d", name: "Dedicated (all services)", quoted_price_cents: 3500, available: true, ltr_28d_price_cents: 3500 },
+];
+
+const esimFeatures = (over: Partial = {}): EsimProduct["features"] => ({
+ has_5g: true,
+ has_hotspot: true,
+ has_calls: false,
+ has_sms: false,
+ supports_topup: true,
+ ...over,
+});
+
+const ESIM_PRODUCTS: EsimProduct[] = [
+ { id: "prod_us_5gb_30d", title: "USA 5GB 30 days", countries: ["US"], region: null, country_count: 1, routing_location: "US", data_limit_gb: 5, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 1500, currency: "USD" },
+ { id: "prod_eu_10gb_30d", title: "Europe 10GB 30 days", countries: ["FR", "DE", "ES", "IT", "NL", "PT", "BE", "AT", "IE", "SE"], region: "Europe", country_count: 10, routing_location: "DE", data_limit_gb: 10, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 2600, currency: "USD" },
+ { id: "prod_jp_3gb_15d", title: "Japan 3GB 15 days", countries: ["JP"], region: null, country_count: 1, routing_location: "JP", data_limit_gb: 3, data_unlimited: false, validity_days: 15, features: esimFeatures({ has_hotspot: false }), price_cents: 1100, currency: "USD" },
+ { id: "prod_global_unl_7d", title: "Global Unlimited 7 days", countries: ["US", "GB", "FR", "DE", "JP", "AU", "BR", "ZA"], region: "Global", country_count: 8, routing_location: null, data_limit_gb: null, data_unlimited: true, validity_days: 7, features: esimFeatures(), price_cents: 3200, currency: "USD" },
+ { id: "prod_topup_5gb", title: "Top-up 5GB", countries: [], region: null, country_count: 0, routing_location: null, data_limit_gb: 5, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 1400, currency: "USD" },
+];
+
+const PROXY_PLANS: ProxyPlan[] = [
+ { id: "pplan_us_5gb_30d", name: "US Mobile 5GB", type: "shared", country: "US", country_name: "United States", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 1800, available: true },
+ { id: "pplan_us_10gb_30d", name: "US Mobile 10GB", type: "shared", country: "US", country_name: "United States", data_gb: 10, duration_days: 30, period: "monthly", quoted_price_cents: 3000, available: true },
+ { id: "pplan_gb_5gb_30d", name: "UK Mobile 5GB", type: "shared", country: "GB", country_name: "United Kingdom", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 2000, available: true },
+ { id: "pplan_de_5gb_30d", name: "Germany Mobile 5GB", type: "shared", country: "DE", country_name: "Germany", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 2100, available: true },
+];
+
+const GEO: Record = {
+ countries: [
+ { code: "US", name: "United States", available_nodes: 4200 },
+ { code: "GB", name: "United Kingdom", available_nodes: 1800 },
+ { code: "DE", name: "Germany", available_nodes: 1500 },
+ ],
+ regions: [
+ { name: "California", available_nodes: 920 },
+ { name: "New York", available_nodes: 740 },
+ { name: "Texas", available_nodes: 610 },
+ ],
+ cities: [
+ { name: "Los Angeles", available_nodes: 410 },
+ { name: "San Francisco", available_nodes: 300 },
+ ],
+ isps: [
+ { name: "Carrier A", available_nodes: 210 },
+ { name: "Carrier B", available_nodes: 160 },
+ ],
+};
+
+// ── state ────────────────────────────────────────────────────────────────────
+
+class Store {
+ balanceCents = 50000; // $500 play-money balance (sandbox has no deposit tool, so spend-only)
+ verifications = new Map();
+ rentals = new Map();
+ esims = new Map();
+ proxies = new Map();
+ createdAtMs = new Map(); // entity id -> creation epoch ms
+}
+
+// ── response envelope helpers ─────────────────────────────────────────────────
+
+const ok = (data: T, status = 200): HttpResponse => ({ status, body: { success: true, data }, headers: new Headers() });
+const noContent = (): HttpResponse => ({ status: 204, headers: new Headers() });
+const fail = (status: number, code: string, message: string, details?: Record): HttpResponse => ({
+ status,
+ body: { success: false, error: { code, message, request_id: uid("req_"), details } },
+ headers: new Headers(),
+});
+
+const PNG_1x1 = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
+ "base64",
+);
+
+// ── entity builders ────────────────────────────────────────────────────────────
+
+function makeGateway(geoHint = "us"): NonNullable {
+ return {
+ host: `${geoHint}.gw.voidmob.com`,
+ port: 10000 + rnd(0, 4999),
+ protocol: "http",
+ username: `vm_${alnum(6)}`,
+ password: alnum(12),
+ username_geo_hint: geoHint,
+ };
+}
+
+export function createSandboxHttpClient(): HttpClient {
+ const db = new Store();
+
+ // True once READY_AFTER_MS has elapsed since the entity was (re)armed.
+ const isReady = (id: string): boolean => Date.now() - (db.createdAtMs.get(id) ?? 0) >= READY_AFTER_MS;
+
+ // Charge the wallet, or return a 402 response if the balance can't cover it.
+ const charge = (cents: number): HttpResponse | null =>
+ db.balanceCents < cents ? fail(402, "INSUFFICIENT_BALANCE", "Insufficient balance.") : ((db.balanceCents -= cents), null);
+
+ // Lazily flip a just-created verification to code_received once the code has
+ // "arrived", mirroring the real "poll get_rental until the code lands" flow.
+ const settleVerification = (v: Verification): Verification => {
+ if (v.status !== "waiting_for_code" || !isReady(v.id)) return v;
+ v.status = "code_received";
+ v.code = smsCode();
+ v.code_received_at = iso();
+ v.can_cancel = false;
+ v.allow_reuse = true;
+ return v;
+ };
+
+ // Proxies provision asynchronously; flip to active once ready.
+ const settleProxy = (p: Proxy): Proxy => {
+ if (p.status === "provisioning" && isReady(p.id)) p.status = "active";
+ return p;
+ };
+
+ function route(method: string, rawPath: string, query: URLSearchParams, opts: HttpRequestOpts): HttpResponse {
+ const body = (opts.body ?? {}) as Record;
+ const seg = rawPath.split("/").filter(Boolean); // e.g. ["v1","proxies","prx_1","lists"]
+
+ // ── account ──
+ if (method === "GET" && rawPath === "/v1/me") {
+ const me: MePayload = {
+ id: "acct_sandbox",
+ balance: { amount_cents: db.balanceCents, currency: "USD", formatted: formatUsd(db.balanceCents) },
+ rate_limits: {
+ default: { limit: 120, window_seconds: 60 },
+ purchases: { limit: 30, window_seconds: 60 },
+ },
+ created_at: iso(-90 * DAY),
+ };
+ return ok(me);
+ }
+
+ // ── SMS services ──
+ if (method === "GET" && rawPath === "/v1/services") {
+ return ok({ services: SERVICES });
+ }
+
+ // ── verifications ──
+ if (method === "POST" && rawPath === "/v1/verifications") {
+ const svc = SERVICES.find((s) => s.id === body.service_id);
+ if (!svc) return fail(404, "NOT_FOUND", "Service not found.");
+ const paid = charge(svc.quoted_price_cents);
+ if (paid) return paid;
+ const id = uid("ver_");
+ const v: Verification = {
+ id,
+ display_id: id.slice(0, 12),
+ status: "waiting_for_code",
+ phone_number: phone(),
+ service_id: svc.id,
+ service_name: svc.name,
+ charged_price_cents: svc.quoted_price_cents,
+ expires_at: iso(20 * 60_000),
+ can_cancel: true,
+ created_at: iso(),
+ reuse_counter: 0,
+ allow_reuse: false,
+ allow_paid_reuse: true,
+ paid_reuse_price_cents: 50,
+ };
+ db.verifications.set(id, v);
+ db.createdAtMs.set(id, Date.now());
+ return ok({ verification: v }, 201);
+ }
+ if (seg[1] === "verifications" && seg[2]) {
+ const v = db.verifications.get(seg[2]);
+ if (!v) return fail(404, "NOT_FOUND", "Verification not found.");
+ if (method === "GET" && !seg[3]) return ok({ verification: settleVerification(v) });
+ if (method === "POST" && seg[3] === "cancel") {
+ // Refund only if the code hasn't landed yet. settleVerification reflects
+ // elapsed time, so eligibility doesn't depend on whether the client polled.
+ const refund = settleVerification(v).status === "waiting_for_code" ? v.charged_price_cents : 0;
+ if (refund > 0) db.balanceCents += refund;
+ v.status = "cancelled";
+ return ok({ verification: { id: v.id, status: v.status, refunded_cents: refund } });
+ }
+ // paid reuse (/reuse/paid) is more specific than free reuse (/reuse) - match it first
+ if (method === "POST" && seg[3] === "reuse" && seg[4] === "paid") {
+ const paid = charge(v.paid_reuse_price_cents);
+ if (paid) return paid;
+ v.reuse_counter += 1;
+ v.charged_reuse_cents = v.paid_reuse_price_cents;
+ v.status = "waiting_for_code";
+ v.code = undefined;
+ v.code_received_at = undefined;
+ db.createdAtMs.set(v.id, Date.now());
+ return ok({ verification: v });
+ }
+ if (method === "POST" && seg[3] === "reuse") {
+ v.reuse_counter += 1;
+ v.status = "waiting_for_code";
+ v.code = undefined;
+ v.code_received_at = undefined;
+ db.createdAtMs.set(v.id, Date.now());
+ return ok({ verification: v });
+ }
+ }
+
+ // ── rentals (long-term + dedicated) ──
+ if (rawPath === "/v1/rentals" && method === "GET") {
+ return ok([...db.rentals.values()]);
+ }
+ if (rawPath === "/v1/rentals" && method === "POST") {
+ const svc = SERVICES.find((s) => s.id === body.service_id);
+ if (!svc) return fail(404, "NOT_FOUND", "Service not found.");
+ const price = Number(body.max_price_cents ?? svc.quoted_price_cents);
+ const paid = charge(price);
+ if (paid) return paid;
+ const id = uid("ren_");
+ const duration = String(body.duration ?? "7D");
+ const days = parseInt(duration, 10) || 7;
+ const r: Rental = {
+ id,
+ display_id: id.slice(0, 12),
+ status: "active",
+ phone_number: phone(),
+ service_id: svc.id,
+ service_name: svc.name,
+ country: "US",
+ duration,
+ rental_type: "rental",
+ charged_price_cents: price,
+ auto_renew: false,
+ next_renewal_price_cents: price,
+ re_rent_available: false,
+ re_rent_price_cents: null,
+ re_rent_blocked_at: null,
+ created_at: iso(),
+ paid_until: iso(days * DAY),
+ expires_at: iso(days * DAY),
+ can_cancel: true,
+ cancel_window_expires_at: iso(30_000),
+ messages: [],
+ };
+ db.rentals.set(id, r);
+ db.createdAtMs.set(id, Date.now());
+ return ok(r, 201);
+ }
+ if (seg[1] === "rentals" && seg[2]) {
+ const r = db.rentals.get(seg[2]);
+ if (!r) return fail(404, "NOT_FOUND", "Rental not found.");
+ if (method === "GET" && !seg[3]) return ok(r);
+ if (method === "DELETE") {
+ r.status = "cancelled";
+ return ok(r);
+ }
+ if (method === "POST" && seg[3] === "re_rent") {
+ const paid = charge(r.charged_price_cents);
+ if (paid) return paid;
+ const days = parseInt(r.duration, 10) || 7;
+ r.status = "active";
+ r.paid_until = iso(days * DAY);
+ r.expires_at = iso(days * DAY);
+ return ok(r);
+ }
+ if (method === "POST" && seg[3] === "auto_renew") {
+ r.auto_renew = Boolean(body.auto_renew);
+ return ok(r);
+ }
+ }
+
+ // ── eSIM products ──
+ if (rawPath === "/v1/esim_products" && method === "GET") {
+ let products = ESIM_PRODUCTS.filter((p) => !p.id.startsWith("prod_topup"));
+ const country = query.get("country");
+ const minGb = query.get("min_data_gb");
+ const minDays = query.get("min_validity_days");
+ const has5g = query.get("has_5g");
+ const search = query.get("search");
+ if (country) products = products.filter((p) => p.countries.includes(country.toUpperCase()));
+ if (minGb) products = products.filter((p) => p.data_unlimited || (p.data_limit_gb ?? 0) >= Number(minGb));
+ if (minDays) products = products.filter((p) => p.validity_days >= Number(minDays));
+ if (has5g) products = products.filter((p) => p.features.has_5g === (has5g === "true"));
+ if (search) products = products.filter((p) => p.title.toLowerCase().includes(search.toLowerCase()));
+ return ok({ products, next_cursor: null });
+ }
+ if (seg[1] === "esim_products" && seg[2] && method === "GET") {
+ const product = ESIM_PRODUCTS.find((p) => p.id === seg[2]);
+ if (!product) return fail(404, "NOT_FOUND", "Product not found.");
+ return ok({ product });
+ }
+
+ // ── eSIMs ──
+ if (rawPath === "/v1/esims" && method === "GET") {
+ return ok({ esims: [...db.esims.values()] });
+ }
+ if (rawPath === "/v1/esims" && method === "POST") {
+ const product = ESIM_PRODUCTS.find((p) => p.id === body.product_id);
+ if (!product) return fail(404, "NOT_FOUND", "Product not found.");
+ const paid = charge(product.price_cents);
+ if (paid) return paid;
+ const id = uid("esim_");
+ const esim: Esim = {
+ id,
+ status: "completed",
+ product_id: product.id,
+ is_topup: false,
+ parent_order_id: null,
+ iccid: `8910${rnd(10, 99)}${hex(14)}`,
+ activation_code: `LPA:1$smdp.voidmob.com$${hex(32).toUpperCase()}`,
+ qr_code_url: `/v1/esims/${id}/qr.png`,
+ smdp_address: "smdp.voidmob.com",
+ data_limit_gb: product.data_limit_gb,
+ data_unlimited: product.data_unlimited,
+ validity_days: product.validity_days,
+ countries: product.countries,
+ routing_location: product.routing_location,
+ charged_price_cents: product.price_cents,
+ currency: "USD",
+ created_at: iso(),
+ completed_at: iso(),
+ expires_at: iso(product.validity_days * DAY),
+ };
+ db.esims.set(id, esim);
+ return ok({ esim }, 201);
+ }
+ if (seg[1] === "esims" && seg[2]) {
+ const esim = db.esims.get(seg[2]);
+ if (seg[3] === "qr.png" && method === "GET") {
+ if (!esim) return fail(404, "NOT_FOUND", "eSIM not found.");
+ return { status: 200, binary: PNG_1x1, headers: new Headers({ "content-type": "image/png" }) };
+ }
+ if (!esim) return fail(404, "NOT_FOUND", "eSIM not found.");
+ if (method === "GET" && !seg[3]) return ok({ esim });
+ if (seg[3] === "usage" && method === "GET") {
+ const totalGb = esim.data_unlimited ? 50 : esim.data_limit_gb ?? 0;
+ const totalMb = totalGb * 1024;
+ const usedMb = Math.min(totalMb, rnd(0, Math.floor(totalMb * 0.6)));
+ const usage: EsimUsage = {
+ esim_id: esim.id,
+ esim_status: esim.status,
+ packages: [
+ {
+ name: "Primary",
+ total_mb: totalMb,
+ total_gb: totalGb,
+ used_mb: usedMb,
+ used_gb: Number((usedMb / 1024).toFixed(2)),
+ remaining_mb: totalMb - usedMb,
+ remaining_gb: Number(((totalMb - usedMb) / 1024).toFixed(2)),
+ percent_used: totalMb ? Math.round((usedMb / totalMb) * 100) : 0,
+ activation_date: esim.completed_at,
+ expiration_date: esim.expires_at,
+ },
+ ],
+ };
+ return ok({ usage });
+ }
+ if (seg[3] === "topups" && method === "GET") {
+ const topups = ESIM_PRODUCTS.filter((p) => p.id.startsWith("prod_topup"));
+ return ok({ supports_topup: true, topups });
+ }
+ if (seg[3] === "topups" && method === "POST") {
+ const product = ESIM_PRODUCTS.find((p) => p.id === body.product_id);
+ if (!product) return fail(404, "NOT_FOUND", "Top-up product not found.");
+ const paid = charge(product.price_cents);
+ if (paid) return paid;
+ const id = uid("esim_");
+ const topup: Esim = {
+ id,
+ status: "completed",
+ product_id: product.id,
+ is_topup: true,
+ parent_order_id: esim.id,
+ iccid: esim.iccid,
+ activation_code: esim.activation_code,
+ qr_code_url: esim.qr_code_url,
+ smdp_address: esim.smdp_address,
+ data_limit_gb: product.data_limit_gb,
+ data_unlimited: product.data_unlimited,
+ validity_days: product.validity_days,
+ countries: esim.countries,
+ routing_location: esim.routing_location,
+ charged_price_cents: product.price_cents,
+ currency: "USD",
+ created_at: iso(),
+ completed_at: iso(),
+ expires_at: esim.expires_at,
+ };
+ db.esims.set(id, topup);
+ return ok({ esim: topup }, 201);
+ }
+ }
+
+ // ── proxy plans ──
+ if (rawPath === "/v1/proxy_plans" && method === "GET") {
+ let plans = PROXY_PLANS;
+ const country = query.get("country");
+ const minGb = query.get("min_gb");
+ if (country) plans = plans.filter((p) => p.country === country.toUpperCase());
+ if (minGb) plans = plans.filter((p) => p.data_gb >= Number(minGb));
+ return ok({ plans });
+ }
+
+ // ── proxies ──
+ if (rawPath === "/v1/proxies" && method === "GET") {
+ return ok({ proxies: [...db.proxies.values()].map(settleProxy) });
+ }
+ if (rawPath === "/v1/proxies" && method === "POST") {
+ const plan = PROXY_PLANS.find((p) => p.id === body.plan_id);
+ if (!plan) return fail(404, "NOT_FOUND", "Plan not found.");
+ const paid = charge(plan.quoted_price_cents);
+ if (paid) return paid;
+ const id = uid("prx_");
+ const proxy: Proxy = {
+ id,
+ status: "provisioning",
+ plan_id: plan.id,
+ data_gb_total: plan.data_gb,
+ data_bytes_used: 0,
+ charged_price_cents: plan.quoted_price_cents,
+ expires_at: iso(plan.duration_days * DAY),
+ gateway: null,
+ lists: [],
+ rotation_url: null,
+ created_at: iso(),
+ };
+ db.proxies.set(id, proxy);
+ db.createdAtMs.set(id, Date.now());
+ return ok({ proxy }, 202);
+ }
+ if (seg[1] === "proxies" && seg[2]) {
+ const proxy = db.proxies.get(seg[2]);
+ if (!proxy) return fail(404, "NOT_FOUND", "Proxy not found.");
+ const geoHint = (PROXY_PLANS.find((p) => p.id === proxy.plan_id)?.country ?? "us").toLowerCase();
+
+ if (method === "GET" && !seg[3]) return ok({ proxy: settleProxy(proxy) });
+ if (seg[3] === "usage" && method === "GET") {
+ return ok({ usage: { total_gb: proxy.data_gb_total, used_gb: Number((proxy.data_bytes_used / 1024 ** 3).toFixed(2)) } });
+ }
+ if (seg[3] === "nolist_credentials" && method === "POST") {
+ settleProxy(proxy);
+ if (proxy.status === "active" && !proxy.gateway) proxy.gateway = makeGateway(geoHint);
+ return ok({ proxy });
+ }
+ if (seg[3] === "rotate_ip" && method === "POST") {
+ return ok({ proxy_id: proxy.id, rotated_at: iso(), current_ip: ip() });
+ }
+ if (seg[3] === "renew" && method === "POST") {
+ const paid = charge(Number(body.max_price_cents ?? proxy.charged_price_cents));
+ if (paid) return paid;
+ const days = PROXY_PLANS.find((p) => p.id === proxy.plan_id)?.duration_days ?? 30;
+ proxy.expires_at = iso(days * DAY);
+ return ok({ proxy });
+ }
+ if (seg[3] === "topup" && method === "POST") {
+ const paid = charge(Number(body.max_price_cents ?? 0));
+ if (paid) return paid;
+ proxy.data_gb_total += Number(body.additional_gb ?? 0);
+ return ok({ proxy });
+ }
+ if (seg[3] === "regenerate_password" && method === "POST") {
+ proxy.gateway = makeGateway(geoHint);
+ return ok({ proxy });
+ }
+ if (seg[3] === "lists" && !seg[4] && method === "POST") {
+ const id = uid("plist_");
+ const single = typeof body.country === "string" ? body.country : null;
+ const gw = makeGateway(geoHint);
+ const list: ProxyList = {
+ id,
+ proxy_id: proxy.id,
+ name: String(body.name ?? "list"),
+ country: single,
+ countries: Array.isArray(body.countries) ? (body.countries as string[]) : null,
+ region: (body.region as string) ?? null,
+ city: (body.city as string) ?? null,
+ isp: (body.isp as string) ?? null,
+ zip: (body.zip as string) ?? null,
+ rotation_period_seconds: Number(body.rotation_period_seconds ?? 0),
+ rotation_mode: String(body.rotation_mode ?? "instant"),
+ format: String(body.format ?? "login_pass_host_port"),
+ credentials: { host: gw.host, port: gw.port, protocol: gw.protocol, username: gw.username, password: gw.password },
+ entries: [`${gw.host}:${gw.port}:${gw.username}:${gw.password}`],
+ activation_note: "Active within 1-2 minutes.",
+ created_at: iso(),
+ };
+ proxy.lists.push(list);
+ return ok({ list }, 201);
+ }
+ if (seg[3] === "lists" && seg[4] && method === "DELETE") {
+ proxy.lists = proxy.lists.filter((l) => l.id !== seg[4]);
+ return noContent();
+ }
+ }
+
+ // ── geo (cascading) ──
+ if (rawPath === "/v1/geo" && method === "GET") {
+ const country = query.get("country");
+ const region = query.get("region");
+ const city = query.get("city");
+ if (city) return ok({ isps: GEO.isps });
+ if (region) return ok({ cities: GEO.cities });
+ if (country) return ok({ regions: GEO.regions });
+ return ok({ countries: GEO.countries });
+ }
+
+ return fail(404, "NOT_FOUND", `No sandbox route for ${method} ${rawPath}.`);
+ }
+
+ return {
+ async request(method, path, opts = {}): Promise {
+ const [rawPath, queryStr = ""] = path.split("?");
+ return route(method.toUpperCase(), rawPath, new URLSearchParams(queryStr), opts);
+ },
+ };
+}
diff --git a/src/sandbox/state.ts b/src/sandbox/state.ts
deleted file mode 100644
index eda9a50..0000000
--- a/src/sandbox/state.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-import { generateId } from "../utils/validation.js";
-
-// ── SMS ──
-
-export interface SmsMessage {
- id: string;
- message_text: string;
- code: string | null;
- received_at: number;
- read_at: number | null;
-}
-
-export interface SmsRental {
- id: string;
- displayId: string;
- phoneNumber: string;
- service: string;
- serviceName: string;
- rentalType: "verification" | "rental" | "dedicated";
- duration: string | null;
- autoRenew: boolean;
- paidUntil: number | null;
- status: "active" | "completed" | "cancelled" | "expired";
- messages: SmsMessage[];
- expiresAt: number;
- createdAt: number;
- priceCents: number;
- reuseCounter: number;
-}
-
-// ── eSIM ──
-
-export interface EsimOrder {
- id: string;
- displayId: string;
- planId: string;
- planTitle: string;
- countries: string[];
- dataLimitGb: number | null;
- dataUnlimited: boolean;
- validityDays: number;
- dataUsedMb: number;
- status: "active" | "completed" | "expired";
- retailPriceUsd: number;
- qrCodeData: string;
- activationCode: string;
- iccid: string;
- isTopup: boolean;
- parentOrderId: string | null;
- supportsTopup: boolean;
- expiresAt: number;
- createdAt: number;
-}
-
-// ── Proxy ──
-
-export type ProxyType = "shared" | "dedicated_standard" | "dedicated_premium";
-
-export interface ProxyEntry {
- id: string;
- displayId: string;
- type: ProxyType;
- status: "active" | "expired";
- proxyHost: string;
- proxyPort: number;
- socksPort: number | null;
- proxyUsername: string;
- proxyPassword: string;
- protocol: "http" | "socks5" | "vless";
- country: string;
- countryName: string;
- carrier: string;
- carrierName: string;
- currentIp: string;
- isOnline: boolean;
- dataTotal: number | null;
- dataUsed: number | null;
- rotationInterval: number | null;
- lastRotatedAt: number | null;
- autoRenew: boolean;
- expiresAt: number;
- createdAt: number;
- priceCents: number;
- features: string[];
- lists: ProxyList[];
-}
-
-export interface ProxyList {
- id: string;
- name: string;
- login: string;
- password: string;
- country: string | null;
- region: string | null;
- city: string | null;
- isp: string | null;
- locationPreset: string;
- countries: string[] | null;
- rotationPeriod: number;
-}
-
-// ── Wallet ──
-
-export interface Deposit {
- invoiceId: string;
- amount: number;
- currency: string;
- walletAddress: string;
- qrCodeUrl: string;
- cryptoAmount: string;
- status: "pending" | "completed";
- expiresAt: number;
- createdAt: number;
-}
-
-export type TransactionType =
- | "deposit"
- | "sms_verification"
- | "sms_rental"
- | "sms_dedicated"
- | "sms_reuse"
- | "esim_purchase"
- | "esim_topup"
- | "proxy_purchase"
- | "refund";
-
-export interface Transaction {
- id: string;
- type: TransactionType;
- amountCents: number;
- description: string;
- createdAt: number;
-}
-
-// ── State ──
-
-class SandboxState {
- balanceCents = 5000;
- transactions: Transaction[] = [];
- smsRentals = new Map();
- esimOrders = new Map();
- proxies = new Map();
- deposits = new Map();
-
- deductBalance(cents: number, type: TransactionType, description: string): boolean {
- if (this.balanceCents < cents) return false;
- this.balanceCents -= cents;
- this.transactions.push({
- id: generateId("tx"),
- type,
- amountCents: -cents,
- description,
- createdAt: Date.now(),
- });
- return true;
- }
-
- addBalance(cents: number, type: TransactionType, description: string): void {
- this.balanceCents += cents;
- this.transactions.push({
- id: generateId("tx"),
- type,
- amountCents: cents,
- description,
- createdAt: Date.now(),
- });
- }
-
- resolvePendingDeposits(): void {
- const now = Date.now();
- for (const deposit of this.deposits.values()) {
- if (deposit.status === "pending" && now - deposit.createdAt > 5000) {
- deposit.status = "completed";
- const cents = Math.round(deposit.amount * 100);
- this.addBalance(cents, "deposit", `Crypto deposit (${deposit.currency})`);
- }
- }
- }
-}
-
-export const state = new SandboxState();
diff --git a/src/tools/account.ts b/src/tools/account.ts
new file mode 100644
index 0000000..647b436
--- /dev/null
+++ b/src/tools/account.ts
@@ -0,0 +1,35 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { HttpClient } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
+import { MePayload } from "../client/types.js";
+import { structuredOk, wrapToolErrors, type ToolResult } from "../utils/render.js";
+
+// Exported as a factory for test direct-invocation. registerAccountTools wires
+// it onto the McpServer; tests can call getAccountHandler(mockHttp)() without
+// going through the SDK private internals.
+export const getAccountHandler = (http: HttpClient) =>
+ wrapToolErrors(async (): Promise => {
+ const raw = await callApi(http, "GET", "/v1/me");
+ const me = MePayload.parse(raw);
+ const text = [
+ `Account ${me.id}`,
+ ``,
+ ` Balance: ${me.balance.formatted}`,
+ ` Created: ${me.created_at.slice(0, 10)}`,
+ ``,
+ ` Rate limits (per 60s):`,
+ ...Object.entries(me.rate_limits).map(
+ ([g, l]) => ` ${g.padEnd(20)} ${l.limit}/min`,
+ ),
+ ].join("\n");
+ return structuredOk(text, { account: me });
+ });
+
+export function registerAccountTools(server: McpServer, http: HttpClient) {
+ server.tool(
+ "get_account",
+ "Get the authenticated account: id, USD wallet balance, and per-endpoint-group rate limits. Use this before money-touching tool calls to confirm sufficient funds.",
+ {},
+ getAccountHandler(http),
+ );
+}
diff --git a/src/tools/esim.ts b/src/tools/esim.ts
index 93a85fd..877858a 100644
--- a/src/tools/esim.ts
+++ b/src/tools/esim.ts
@@ -1,396 +1,242 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { state, EsimOrder } from "../sandbox/state.js";
-import { ToolError, generateId } from "../utils/validation.js";
-import {
- formatUsd,
- formatMb,
- formatData,
- formatTimeRemaining,
- generateDisplayId,
- generateIccid,
- generateActivationCode,
-} from "../utils/format.js";
-import { errorResponse, textResponse } from "../utils/response.js";
-import {
- searchPlans,
- getPlan,
- getTopupProducts,
- getTopupProduct,
-} from "../mock-data/esim.js";
-
-function featureBadges(has5g: boolean, hasHotspot: boolean): string {
- const badges: string[] = [];
- if (has5g) badges.push("5G");
- if (hasHotspot) badges.push("Hotspot");
- return badges.length > 0 ? badges.join(", ") : "None";
-}
-
-export function registerEsimTools(server: McpServer) {
- server.tool(
- "search_esim_plans",
- "Search available eSIM data plans by country, data, duration, or features.",
- {
- country: z
- .string()
- .optional()
- .describe("ISO 3166-1 alpha-2 country code (e.g., JP, US, GB)"),
- duration: z
- .number()
- .min(1)
- .optional()
- .describe("Minimum plan validity in days"),
- dataAmount: z
- .number()
- .min(1)
- .optional()
- .describe("Minimum data amount in GB"),
- has5g: z.boolean().optional().describe("Filter for 5G-capable plans"),
- hasHotspot: z
- .boolean()
- .optional()
- .describe("Filter for plans with hotspot/tethering"),
- search: z
- .string()
- .optional()
- .describe("Text search in plan title (e.g., 'Japan', 'Europe')"),
- limit: z
- .number()
- .min(1)
- .max(50)
- .default(20)
- .describe("Maximum results to return (default: 20)"),
- },
- async ({ country, duration, dataAmount, has5g, hasHotspot, search, limit }) => {
- try {
- const results = searchPlans({
- country,
- duration,
- dataAmount,
- has5g,
- hasHotspot,
- search,
- limit,
- });
-
- if (results.length === 0) {
- return errorResponse(
- "No eSIM plans found matching your criteria. Try different filters or omit them to browse all plans."
- );
- }
-
- let text = `Found ${results.length} eSIM plan(s):\n\n`;
-
- for (const plan of results) {
- text += ` ${plan.title} (${plan.id})\n`;
- text += ` Countries: ${plan.countries.join(", ")}\n`;
- text += ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}\n`;
- text += ` Duration: ${plan.validityDays} days\n`;
- text += ` Price: $${plan.retailPriceUsd.toFixed(2)}\n`;
- text += ` Features: ${featureBadges(plan.has5g, plan.hasHotspot)}\n`;
- text += ` Routing: ${plan.routingLocation}\n`;
- text += ` Top-up: ${plan.supportsTopup ? "Yes" : "No"}\n\n`;
- }
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
+import { HttpClient, HttpError, NetworkError } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
+import { newIdempotencyKey } from "../client/idempotency.js";
+import { EsimProduct, Esim, EsimUsage } from "../client/types.js";
+import { structuredOk, structuredWithImage, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js";
+import { formatUsd, formatData } from "../utils/format.js";
+
+// ── search_esim_plans ───────────────────────────────────────────────────────
+
+export const searchEsimPlansHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: {
+ country?: string;
+ min_data_gb?: number;
+ min_days?: number;
+ has_5g?: boolean;
+ has_hotspot?: boolean;
+ query?: string;
+ limit?: number;
+ cursor?: string;
+ }): Promise => {
+ const q = new URLSearchParams();
+ if (args.country) q.set("country", args.country);
+ if (args.min_data_gb !== undefined) q.set("min_data_gb", String(args.min_data_gb));
+ // API param is min_validity_days (not min_days); has_5g is server-side.
+ if (args.min_days !== undefined) q.set("min_validity_days", String(args.min_days));
+ if (args.has_5g !== undefined) q.set("has_5g", String(args.has_5g));
+ if (args.query) q.set("search", args.query);
+ q.set("limit", String(args.limit ?? 20));
+ if (args.cursor) q.set("cursor", args.cursor);
+
+ const path = `/v1/esim_products?${q.toString()}`;
+ const data = await callApi<{ products: unknown[]; next_cursor: string | null }>(
+ http,
+ "GET",
+ path,
+ );
+ let products = z.array(EsimProduct).parse(data.products);
+ // has_hotspot is not a server-side filter, so apply it to the returned page
+ // (rather than silently ignoring it as the query param did before).
+ if (args.has_hotspot !== undefined) {
+ products = products.filter((p) => p.features.has_hotspot === args.has_hotspot);
}
- );
-
- server.tool(
- "get_esim_plan_details",
- "Get full details for a specific eSIM plan.",
- {
- planId: z.string().describe("Plan ID (e.g., 'esim_jp_3g_7d')"),
- },
- async ({ planId }) => {
- const plan = getPlan(planId);
-
- if (!plan) {
- return errorResponse(
- `Plan not found: ${planId}. Use search_esim_plans to browse available plans.`
- );
+ if (products.length === 0) return toolError("No eSIM plans matched your filters.");
+ const text = [
+ `Found ${products.length} eSIM plan(s)${data.next_cursor ? " (more available - pass cursor to paginate)" : ""}:`,
+ ``,
+ ...products.map((p) =>
+ [
+ ` ${p.title} (${p.id})`,
+ ` Countries: ${p.countries.join(", ")}`,
+ ` Data: ${formatData(p.data_limit_gb, p.data_unlimited)}`,
+ ` Validity: ${p.validity_days} days`,
+ ` Price: ${formatUsd(p.price_cents)}`,
+ ` 5G/Hotspot: ${p.features.has_5g ? "yes" : "no"} / ${p.features.has_hotspot ? "yes" : "no"}`,
+ ].join("\n"),
+ ),
+ ].join("\n\n");
+ return structuredOk(text, { esim_plans: products, next_cursor: data.next_cursor });
+ });
+
+// ── purchase_esim ───────────────────────────────────────────────────────────
+
+export const purchaseEsimHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { plan_id: string }): Promise => {
+ const productResp = await callApi<{ product: unknown }>(http, "GET", `/v1/esim_products/${args.plan_id}`);
+ const product = EsimProduct.parse(productResp.product);
+ const out = await callApi<{ esim: unknown }>(http, "POST", "/v1/esims", {
+ body: { product_id: args.plan_id, max_price_cents: product.price_cents },
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const esim = Esim.parse(out.esim);
+ const text = [
+ `eSIM purchased: ${esim.id}`,
+ ``,
+ ` Title: ${product.title}`,
+ ` Countries: ${esim.countries.join(", ")}`,
+ ` Data: ${formatData(esim.data_limit_gb, esim.data_unlimited)}`,
+ ` Validity: ${esim.validity_days} days`,
+ ` Charged: ${formatUsd(esim.charged_price_cents)}`,
+ ` Activation: ${esim.activation_code ?? "(pending)"}`,
+ ` ICCID: ${esim.iccid ?? "(pending)"}`,
+ ``,
+ `Use get_esim_qr(esim_id="${esim.id}") to fetch the QR code as an image.`,
+ ].join("\n");
+ return structuredOk(text, { esim });
+ });
+
+// ── get_esim_status ─────────────────────────────────────────────────────────
+
+export const getEsimStatusHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { esim_id: string }): Promise => {
+ // Usage is best-effort enrichment on a read path: degrade to null on ANY
+ // API/network error so a transient usage-subservice failure never sinks the
+ // core eSIM status read. Non-API throws (bugs) still propagate.
+ const [esimRaw, usageRaw] = await Promise.all([
+ callApi<{ esim: unknown }>(http, "GET", `/v1/esims/${args.esim_id}`),
+ callApi<{ usage: unknown }>(http, "GET", `/v1/esims/${args.esim_id}/usage`).catch((e) => {
+ if (e instanceof HttpError || e instanceof NetworkError) return null;
+ throw e;
+ }),
+ ]);
+ const esim = Esim.parse(esimRaw.esim);
+ const usage = usageRaw ? EsimUsage.parse(usageRaw.usage) : null;
+ const primaryPkg = usage?.packages[0] ?? null;
+ const text = [
+ `eSIM ${esim.id}`,
+ ``,
+ ` Countries: ${esim.countries.join(", ")}`,
+ ` Data: ${formatData(esim.data_limit_gb, esim.data_unlimited)}`,
+ ` Status: ${esim.status}`,
+ ` Validity: ${esim.validity_days} days`,
+ ` Expires: ${esim.expires_at ?? "(not yet activated)"}`,
+ primaryPkg
+ ? ` Usage: ${primaryPkg.used_mb.toFixed(0)} MB / ${primaryPkg.total_mb.toFixed(0)} MB (${primaryPkg.percent_used}%)`
+ : ` Usage: (not yet available)`,
+ ].join("\n");
+ return structuredOk(text, { esim, usage });
+ });
+
+// ── topup_esim ──────────────────────────────────────────────────────────────
+
+export const topupEsimHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { esim_id: string; topup_product_id?: string }): Promise => {
+ if (!args.topup_product_id) {
+ // Browse path
+ const out = await callApi<{ supports_topup: boolean; topups: unknown[] }>(
+ http,
+ "GET",
+ `/v1/esims/${args.esim_id}/topups`,
+ );
+ if (!out.supports_topup || out.topups.length === 0) {
+ return toolError(`No top-up products available for ${args.esim_id}.`);
}
-
+ const topups = z.array(EsimProduct).parse(out.topups);
const text = [
- `${plan.title}`,
+ `Available top-ups for ${args.esim_id}:`,
``,
- ` Plan ID: ${plan.id}`,
- ` Countries: ${plan.countries.join(", ")}`,
- ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}`,
- ` Duration: ${plan.validityDays} days`,
- ` Price: $${plan.retailPriceUsd.toFixed(2)}`,
- ` Network: ${plan.networkType}`,
- ` Speed: ${plan.speed}`,
- ` 5G: ${plan.has5g ? "Yes" : "No"}`,
- ` Hotspot: ${plan.hasHotspot ? "Yes" : "No"}`,
- ` Activation: ${plan.activationPolicy}`,
- ` Routing: ${plan.routingLocation}`,
- ` Supports top-up: ${plan.supportsTopup ? "Yes" : "No"}`,
- ` Tags: ${plan.tags.length > 0 ? plan.tags.join(", ") : "None"}`,
+ ...topups.map(
+ (t) =>
+ ` ${t.title} (${t.id}) - ${formatData(t.data_limit_gb, t.data_unlimited)}, ${t.validity_days} days, ${formatUsd(t.price_cents)}`,
+ ),
+ ``,
+ `Re-run topup_esim with topup_product_id to purchase.`,
].join("\n");
-
- return textResponse(text);
+ return structuredOk(text, { topups });
}
- );
-
+ // Purchase path: quote-then-commit
+ const productResp = await callApi<{ product: unknown }>(
+ http,
+ "GET",
+ `/v1/esim_products/${args.topup_product_id}`,
+ );
+ const product = EsimProduct.parse(productResp.product);
+ const created = await callApi<{ esim: unknown }>(
+ http,
+ "POST",
+ `/v1/esims/${args.esim_id}/topups`,
+ {
+ body: { product_id: args.topup_product_id, max_price_cents: product.price_cents },
+ idempotencyKey: newIdempotencyKey(),
+ },
+ );
+ const esim = Esim.parse(created.esim);
+ const text = [
+ `Top-up ${esim.id} purchased on ${args.esim_id}.`,
+ ``,
+ ` Title: ${product.title}`,
+ ` Data: ${formatData(product.data_limit_gb, product.data_unlimited)}`,
+ ` Validity: ${product.validity_days} days`,
+ ` Charged: ${formatUsd(esim.charged_price_cents)}`,
+ ].join("\n");
+ return structuredOk(text, { esim });
+ });
+
+// ── get_esim_qr ─────────────────────────────────────────────────────────────
+
+export const getEsimQrHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { esim_id: string }): Promise => {
+ const res = await http.request("GET", `/v1/esims/${args.esim_id}/qr.png`, {
+ expectBinary: true,
+ });
+ if (!res.binary) return toolError("QR fetch returned no binary payload.");
+ const base64 = res.binary.toString("base64");
+ return structuredWithImage(
+ `QR code for eSIM ${args.esim_id}. Scan with your device camera to install.`,
+ { esim_id: args.esim_id },
+ { mimeType: "image/png", base64 },
+ );
+ });
+
+// ── registration ────────────────────────────────────────────────────────────
+
+export function registerEsimTools(server: McpServer, http: HttpClient) {
server.tool(
- "purchase_esim",
- "Purchase an eSIM plan. Returns QR code and activation details.",
+ "search_esim_plans",
+ "Search global eSIM data plans. Each result includes the full plan shape (countries, region, data limit, validity, routing location, 5G/hotspot/calls/SMS/topup features) so a separate plan-details tool is unnecessary.",
{
- planId: z.string().describe("Plan ID to purchase (e.g., 'esim_jp_3g_7d')"),
+ country: z.string().optional().describe("ISO-3166 country code (e.g. 'JP')"),
+ min_data_gb: z.number().optional(),
+ min_days: z.number().optional(),
+ has_5g: z.boolean().optional(),
+ has_hotspot: z.boolean().optional(),
+ query: z.string().optional().describe("Substring search on plan title"),
+ limit: z.number().min(1).max(50).default(20),
+ cursor: z.string().optional(),
},
- async ({ planId }) => {
- const plan = getPlan(planId);
-
- if (!plan) {
- return errorResponse(
- `Plan not found: ${planId}. Use search_esim_plans to browse available plans.`
- );
- }
-
- const costCents = Math.round(plan.retailPriceUsd * 100);
-
- if (!state.deductBalance(costCents, "esim_purchase", `eSIM: ${plan.title}`)) {
- return errorResponse(
- `Insufficient balance. Need ${formatUsd(costCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.`
- );
- }
-
- const now = Date.now();
- const orderId = generateId("esm");
- const displayId = generateDisplayId("ESM");
-
- const order: EsimOrder = {
- id: orderId,
- displayId,
- planId: plan.id,
- planTitle: plan.title,
- countries: plan.countries,
- dataLimitGb: plan.dataLimitGb,
- dataUnlimited: plan.dataUnlimited,
- validityDays: plan.validityDays,
- dataUsedMb: 0,
- status: "active",
- retailPriceUsd: plan.retailPriceUsd,
- qrCodeData: `https://sandbox.voidmob.com/esim/qr/${orderId}`,
- activationCode: generateActivationCode(),
- iccid: generateIccid(),
- isTopup: false,
- parentOrderId: null,
- supportsTopup: plan.supportsTopup,
- expiresAt: now + plan.validityDays * 86400000,
- createdAt: now,
- };
-
- state.esimOrders.set(orderId, order);
-
- const lines = [
- `eSIM purchased!`,
- ``,
- ` Order ID: ${orderId}`,
- ` Display ID: ${displayId}`,
- ` Plan: ${plan.title}`,
- ` Countries: ${plan.countries.join(", ")}`,
- ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}`,
- ` Duration: ${plan.validityDays} days`,
- ` Cost: $${plan.retailPriceUsd.toFixed(2)}`,
- ` Balance: ${formatUsd(state.balanceCents)}`,
- ``,
- ` QR Code: ${order.qrCodeData}`,
- ` Activation Code: ${order.activationCode}`,
- ` ICCID: ${order.iccid}`,
- ``,
- `Setup steps:`,
- ` 1. Scan the QR code or enter the activation code in your device settings`,
- ` 2. Select the eSIM line and enable data roaming`,
- ` 3. The plan activates on first data usage`,
- ``,
- `Use get_esim_usage with order ID "${orderId}" to check data consumption.`,
- ];
-
- return textResponse(lines.join("\n"));
- }
+ searchEsimPlansHandler(http),
);
server.tool(
- "get_esim_usage",
- "Check data usage and status for an eSIM order.",
- {
- orderId: z.string().describe("Order ID returned from purchase_esim"),
- },
- async ({ orderId }) => {
- const order = state.esimOrders.get(orderId);
-
- if (!order) {
- return errorResponse(`Order not found: ${orderId}`);
- }
-
- const now = Date.now();
-
- if (order.status === "active" && now >= order.expiresAt) {
- order.status = "expired";
- }
-
- // Simulate usage: ~0.1 GB/hour = ~102.4 MB/hour, cap at 95% of total
- const hoursElapsed = (now - order.createdAt) / 3600000;
- const simulatedUsageMb = hoursElapsed * 102.4;
-
- if (order.dataUnlimited || order.dataLimitGb === null) {
- order.dataUsedMb = Math.round(simulatedUsageMb * 100) / 100;
- } else {
- const totalMb = order.dataLimitGb * 1024;
- const maxUsageMb = totalMb * 0.95;
- order.dataUsedMb = Math.round(Math.min(simulatedUsageMb, maxUsageMb) * 100) / 100;
- }
-
- const esimStatus = order.status === "active" ? "active" : "expired";
-
- const lines = [
- `eSIM Usage`,
- ``,
- ` esimStatus: ${esimStatus}`,
- ``,
- ` Package: ${order.planTitle}`,
- ` Used: ${formatMb(order.dataUsedMb)}`,
- ];
-
- if (!order.dataUnlimited && order.dataLimitGb !== null) {
- const totalMb = order.dataLimitGb * 1024;
- const remainingMb = Math.max(0, totalMb - order.dataUsedMb);
- const pct = Math.min(100, (order.dataUsedMb / totalMb) * 100);
-
- lines[lines.length - 1] = ` Used: ${formatMb(order.dataUsedMb)} / ${formatMb(totalMb)} (${pct.toFixed(1)}%)`;
- lines.push(` Remaining: ${formatMb(remainingMb)}`);
- }
-
- lines.push(
- ` Expires: ${order.status === "expired" ? "Expired" : new Date(order.expiresAt).toISOString().slice(0, 16)}`,
- );
-
- if (order.status === "active") {
- lines.push(` Time left: ${formatTimeRemaining(order.expiresAt)}`);
- }
+ "purchase_esim",
+ "Purchase an eSIM plan. Quote-then-commit: the tool fetches the live price and ties max_price_cents to it so you never pay above what you saw.",
+ { plan_id: z.string().describe("prod_xxx from search_esim_plans") },
+ purchaseEsimHandler(http),
+ );
- return textResponse(lines.join("\n"));
- }
+ server.tool(
+ "get_esim_status",
+ "Read an eSIM's current status, plan info, and data usage. Combines GET /v1/esims/:id + /usage in one parallel call.",
+ { esim_id: z.string() },
+ getEsimStatusHandler(http),
);
server.tool(
"topup_esim",
- "Browse available top-up products or purchase a top-up for an active eSIM.",
+ "Browse top-up products (omit topup_product_id) or purchase a specific top-up (supply topup_product_id) for an active eSIM.",
{
- orderId: z.string().describe("Order ID of the eSIM to top up"),
- topupProductId: z
- .string()
- .optional()
- .describe("Top-up product ID to purchase. Omit to browse available top-ups."),
+ esim_id: z.string(),
+ topup_product_id: z.string().optional(),
},
- async ({ orderId, topupProductId }) => {
- const order = state.esimOrders.get(orderId);
-
- if (!order) {
- return errorResponse(`Order not found: ${orderId}`);
- }
-
- if (order.status !== "active") {
- return errorResponse(
- `Order ${orderId} is ${order.status}. Only active orders can be topped up.`
- );
- }
-
- if (!order.supportsTopup) {
- return errorResponse(
- `Order ${orderId} (${order.planTitle}) does not support top-ups.`
- );
- }
-
- if (!topupProductId) {
- const products = getTopupProducts(order.planId);
-
- if (products.length === 0) {
- return errorResponse(
- `No top-up products available for plan ${order.planTitle}.`
- );
- }
-
- let text = `Available top-ups for ${order.planTitle} (order ${orderId}):\n\n`;
-
- for (const p of products) {
- text += ` ${p.title} (${p.id})\n`;
- text += ` Data: ${p.dataLimitGb} GB\n`;
- text += ` Duration: ${p.validityDays} days\n`;
- text += ` Price: $${p.retailPriceUsd.toFixed(2)}\n\n`;
- }
-
- text += `Use topup_esim with orderId and topupProductId to purchase.`;
-
- return textResponse(text);
- }
-
- const product = getTopupProduct(topupProductId);
-
- if (!product) {
- return errorResponse(
- `Top-up product not found: ${topupProductId}. Use topup_esim without topupProductId to browse available options.`
- );
- }
-
- const costCents = Math.round(product.retailPriceUsd * 100);
-
- if (!state.deductBalance(costCents, "esim_topup", `eSIM top-up: ${product.title} on ${orderId}`)) {
- return errorResponse(
- `Insufficient balance. Need ${formatUsd(costCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.`
- );
- }
-
- const now = Date.now();
- const newOrderId = generateId("esm");
- const displayId = generateDisplayId("ESM");
-
- const topupOrder: EsimOrder = {
- id: newOrderId,
- displayId,
- planId: order.planId,
- planTitle: product.title,
- countries: order.countries,
- dataLimitGb: product.dataLimitGb,
- dataUnlimited: false,
- validityDays: product.validityDays,
- dataUsedMb: 0,
- status: "active",
- retailPriceUsd: product.retailPriceUsd,
- qrCodeData: `https://sandbox.voidmob.com/esim/qr/${newOrderId}`,
- activationCode: generateActivationCode(),
- iccid: order.iccid,
- isTopup: true,
- parentOrderId: orderId,
- supportsTopup: false,
- expiresAt: now + product.validityDays * 86400000,
- createdAt: now,
- };
-
- state.esimOrders.set(newOrderId, topupOrder);
-
- const text = [
- `Top-up purchased!`,
- ``,
- ` Top-up order: ${newOrderId}`,
- ` Display ID: ${displayId}`,
- ` Product: ${product.title}`,
- ` Data: ${product.dataLimitGb} GB`,
- ` Duration: ${product.validityDays} days`,
- ` Cost: $${product.retailPriceUsd.toFixed(2)}`,
- ` Parent order: ${orderId}`,
- ` Balance: ${formatUsd(state.balanceCents)}`,
- ``,
- `The top-up data has been added to your eSIM.`,
- ].join("\n");
+ topupEsimHandler(http),
+ );
- return textResponse(text);
- }
+ server.tool(
+ "get_esim_qr",
+ "Fetch the activation QR code for an eSIM as an image. Most MCP clients render the image inline so the user can scan it directly.",
+ { esim_id: z.string() },
+ getEsimQrHandler(http),
);
}
diff --git a/src/tools/geo.ts b/src/tools/geo.ts
new file mode 100644
index 0000000..4782a61
--- /dev/null
+++ b/src/tools/geo.ts
@@ -0,0 +1,74 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { HttpClient } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
+import { GeoCountry, GeoRegion, GeoCity, GeoIsp } from "../client/types.js";
+import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js";
+
+interface GeoKind {
+ key: "isps" | "cities" | "regions" | "countries";
+ schema: z.ZodTypeAny;
+ label: string;
+ fmt: (item: never) => string;
+}
+
+const KINDS: ReadonlyArray = [
+ {
+ key: "isps",
+ schema: GeoIsp,
+ label: "ISP(s)",
+ fmt: ((i: z.infer) => ` ${i.name} (${i.available_nodes} nodes)`) as (item: never) => string,
+ },
+ {
+ key: "cities",
+ schema: GeoCity,
+ label: "cities",
+ fmt: ((c: z.infer) => ` ${c.name} (${c.available_nodes} nodes)`) as (item: never) => string,
+ },
+ {
+ key: "regions",
+ schema: GeoRegion,
+ label: "regions",
+ fmt: ((r: z.infer) => ` ${r.name} (${r.available_nodes} nodes)`) as (item: never) => string,
+ },
+ {
+ key: "countries",
+ schema: GeoCountry,
+ label: "countries",
+ fmt: ((c: z.infer) => ` ${c.name} (${c.code}) ${c.available_nodes} nodes`) as (item: never) => string,
+ },
+];
+
+export const getGeoHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { country?: string; region?: string; city?: string }): Promise => {
+ const q = new URLSearchParams();
+ if (args.country) q.set("country", args.country);
+ if (args.region) q.set("region", args.region);
+ if (args.city) q.set("city", args.city);
+ const path = `/v1/geo${q.toString() ? `?${q}` : ""}`;
+ const data = await callApi>(http, "GET", path);
+
+ for (const kind of KINDS) {
+ if (data[kind.key]) {
+ const items = z.array(kind.schema).parse(data[kind.key]);
+ return structuredOk(
+ `${items.length} ${kind.label}:\n${items.map((i) => kind.fmt(i as never)).join("\n")}`,
+ { [kind.key]: items },
+ );
+ }
+ }
+ return toolError("Unexpected geo response shape.");
+ });
+
+export function registerGeoTools(server: McpServer, http: HttpClient) {
+ server.tool(
+ "get_geo",
+ "Cascading geo discovery for proxy list targeting. No params -> countries; country=US -> regions; country=US®ion=California -> cities; +city=Los%20Angeles -> ISPs.",
+ {
+ country: z.string().optional().describe("ISO 3166-1 alpha-2 (e.g., US)"),
+ region: z.string().optional(),
+ city: z.string().optional(),
+ },
+ getGeoHandler(http),
+ );
+}
diff --git a/src/tools/orders.ts b/src/tools/orders.ts
index d49f003..a282416 100644
--- a/src/tools/orders.ts
+++ b/src/tools/orders.ts
@@ -1,179 +1,109 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { state } from "../sandbox/state.js";
+import { HttpClient } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
+import { Rental, Esim, Proxy } from "../client/types.js";
+import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js";
import { formatUsd } from "../utils/format.js";
-import { errorResponse, textResponse } from "../utils/response.js";
-interface OrderDetails {
+interface OrderRow {
+ kind: "sms" | "esim" | "proxy";
id: string;
- displayId: string;
- type: "sms" | "esim" | "proxy";
status: string;
- priceCents: number;
- createdAt: number;
- details: Record;
+ charged_price_cents: number;
+ created_at: string;
+ summary: string;
}
-export function registerOrdersTools(server: McpServer) {
- server.tool(
- "list_orders",
- "List all orders across SMS, eSIM, and proxy services.",
- {
- type: z
- .enum(["sms", "esim", "proxy"])
- .optional()
- .describe("Filter by service type"),
- status: z
- .enum(["active", "completed", "cancelled", "expired"])
- .optional()
- .describe("Filter by status"),
- limit: z
- .number()
- .min(1)
- .max(50)
- .default(20)
- .describe("Maximum number of orders to return (default: 20)"),
- },
- async ({ type, status, limit }) => {
- const now = Date.now();
- const orders: OrderDetails[] = [];
-
- if (!type || type === "sms") {
- for (const rental of state.smsRentals.values()) {
- if (rental.status === "active" && rental.expiresAt < now) {
- rental.status = "expired";
- }
- if (status && rental.status !== status) continue;
- orders.push({
- id: rental.id,
- displayId: rental.displayId,
- type: "sms",
- status: rental.status,
- priceCents: rental.priceCents,
- createdAt: rental.createdAt,
- details: {
- phoneNumber: rental.phoneNumber,
- service: rental.service,
- serviceName: rental.serviceName,
- rentalType: rental.rentalType,
- duration: rental.duration,
- messageCount: rental.messages.length,
- },
- });
- }
- }
-
- if (!type || type === "esim") {
- for (const order of state.esimOrders.values()) {
- if (order.status === "active" && order.expiresAt < now) {
- order.status = "expired";
- }
- if (status && order.status !== status) continue;
- orders.push({
- id: order.id,
- displayId: order.displayId,
- type: "esim",
- status: order.status,
- priceCents: Math.round(order.retailPriceUsd * 100),
- createdAt: order.createdAt,
- details: {
- planTitle: order.planTitle,
- dataLimitGb: order.dataLimitGb,
- validityDays: order.validityDays,
- countries: order.countries.join(", "),
- },
- });
- }
+export const listOrdersHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { kind?: "sms" | "esim" | "proxy"; limit?: number }): Promise => {
+ const limit = args.limit ?? 20;
+ const tasks: Promise[] = [];
+ if (!args.kind || args.kind === "sms") tasks.push(fetchRentals(http));
+ if (!args.kind || args.kind === "esim") tasks.push(fetchEsims(http));
+ if (!args.kind || args.kind === "proxy") tasks.push(fetchProxies(http));
+ const settled = await Promise.allSettled(tasks);
+ const rows: OrderRow[] = [];
+ const warnings: string[] = [];
+ for (const r of settled) {
+ if (r.status === "fulfilled") rows.push(...r.value);
+ else {
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
+ warnings.push(`(partial: ${msg})`);
}
-
- if (!type || type === "proxy") {
- for (const proxy of state.proxies.values()) {
- if (proxy.status === "active" && proxy.expiresAt < now) {
- proxy.status = "expired";
- }
- if (status && proxy.status !== status) continue;
- orders.push({
- id: proxy.id,
- displayId: proxy.displayId,
- type: "proxy",
- status: proxy.status,
- priceCents: proxy.priceCents,
- createdAt: proxy.createdAt,
- details: {
- type: proxy.type,
- country: proxy.country,
- countryName: proxy.countryName,
- carrier: proxy.carrier,
- carrierName: proxy.carrierName,
- protocol: proxy.protocol,
- },
- });
- }
- }
-
- if (orders.length === 0) {
- return errorResponse("No orders found.");
+ }
+ rows.sort((a, b) => b.created_at.localeCompare(a.created_at));
+ const page = rows.slice(0, limit);
+ if (page.length === 0) {
+ // Distinguish a genuinely empty account from a fan-out where every
+ // branch failed (e.g. a schema mismatch throwing out of every fetch).
+ // Surfacing the warnings avoids the misleading "No orders found."
+ if (warnings.length > 0) {
+ return toolError(`Could not load orders. ${warnings.join(" ")}`);
}
+ return toolError("No orders found.");
+ }
+ const text = [
+ `${rows.length} order(s)${rows.length > limit ? ` (showing ${limit})` : ""}:`,
+ ``,
+ ...page.map(
+ (r) =>
+ ` [${r.kind.toUpperCase().padEnd(5)}] ${r.id.padEnd(20)} ${r.status.padEnd(14)} ${formatUsd(r.charged_price_cents).padStart(8)} ${r.created_at.slice(0, 16)} ${r.summary}`,
+ ),
+ warnings.length ? `\n${warnings.join("\n")}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+ return structuredOk(text, { orders: page });
+ });
- orders.sort((a, b) => b.createdAt - a.createdAt);
- const limited = orders.slice(0, limit);
-
- const typeBadge: Record = {
- sms: "[SMS]",
- esim: "[eSIM]",
- proxy: "[Proxy]",
- };
-
- let text = `${orders.length} order(s)${orders.length > limit ? ` (showing ${limit})` : ""}:\n`;
-
- for (const o of limited) {
- const date = new Date(o.createdAt).toISOString().slice(0, 16).replace("T", " ");
- const badge = typeBadge[o.type];
-
- text += `\n ${badge} ${o.displayId}`;
- text += `\n ID: ${o.id}`;
- text += `\n Status: ${o.status} | Price: ${formatUsd(o.priceCents)}`;
+async function fetchRentals(http: HttpClient): Promise {
+ const data = await callApi(http, "GET", "/v1/rentals");
+ const items = z.array(Rental).parse(data);
+ return items.map((r) => ({
+ kind: "sms" as const,
+ id: r.id,
+ status: r.status,
+ charged_price_cents: r.charged_price_cents,
+ created_at: r.created_at,
+ summary: `${r.service_name} ${r.phone_number} ${r.duration ?? ""}`,
+ }));
+}
- if (o.type === "sms") {
- const d = o.details as {
- phoneNumber: string;
- service: string;
- serviceName: string;
- rentalType: string;
- duration: string | null;
- messageCount: number;
- };
- text += `\n Service: ${d.serviceName} (${d.service})`;
- text += `\n Phone: ${d.phoneNumber} | Type: ${d.rentalType}`;
- if (d.duration) text += ` | Duration: ${d.duration}`;
- text += `\n Messages: ${d.messageCount}`;
- } else if (o.type === "esim") {
- const d = o.details as {
- planTitle: string;
- dataLimitGb: number | null;
- validityDays: number;
- countries: string;
- };
- text += `\n Plan: ${d.planTitle}`;
- text += `\n Data: ${d.dataLimitGb !== null ? `${d.dataLimitGb} GB` : "Unlimited"} | Validity: ${d.validityDays} days`;
- text += `\n Countries: ${d.countries}`;
- } else if (o.type === "proxy") {
- const d = o.details as {
- type: string;
- country: string;
- countryName: string;
- carrier: string;
- carrierName: string;
- protocol: string;
- };
- text += `\n Type: ${d.type} | Protocol: ${d.protocol}`;
- text += `\n Location: ${d.countryName} (${d.country}) | Carrier: ${d.carrierName}`;
- }
+async function fetchEsims(http: HttpClient): Promise {
+ const data = await callApi<{ esims: unknown[] }>(http, "GET", "/v1/esims");
+ const items = z.array(Esim).parse(data.esims);
+ return items.map((e) => ({
+ kind: "esim" as const,
+ id: e.id,
+ status: e.status,
+ charged_price_cents: e.charged_price_cents,
+ created_at: e.created_at,
+ summary: `${e.countries.join(",")} ${e.data_unlimited || e.data_limit_gb == null ? "unlim" : `${e.data_limit_gb}GB`}`,
+ }));
+}
- text += `\n Created: ${date}\n`;
- }
+async function fetchProxies(http: HttpClient): Promise {
+ const data = await callApi<{ proxies: unknown[] }>(http, "GET", "/v1/proxies");
+ const items = z.array(Proxy).parse(data.proxies);
+ return items.map((p) => ({
+ kind: "proxy" as const,
+ id: p.id,
+ status: p.status,
+ charged_price_cents: p.charged_price_cents,
+ created_at: p.created_at ?? "",
+ summary: `${p.data_gb_total}GB ${p.lists.length} list(s)`,
+ }));
+}
- return textResponse(text);
- }
+export function registerOrdersTools(server: McpServer, http: HttpClient) {
+ server.tool(
+ "list_orders",
+ "List the user's active and past orders across SMS rentals, eSIMs, and proxies. Note: ephemeral verifications (20-min single-SMS) are NOT listable - the rental id you got from rent_number is your handle to them.",
+ {
+ kind: z.enum(["sms", "esim", "proxy"]).optional().describe("Filter by kind"),
+ limit: z.number().min(1).max(100).default(20),
+ },
+ listOrdersHandler(http),
);
}
diff --git a/src/tools/proxy.ts b/src/tools/proxy.ts
index dadf3a1..f9a9f3e 100644
--- a/src/tools/proxy.ts
+++ b/src/tools/proxy.ts
@@ -1,617 +1,400 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { state } from "../sandbox/state.js";
-import type { ProxyList } from "../sandbox/state.js";
-import { validateCountry, ToolError, generateId } from "../utils/validation.js";
-import {
- formatUsd,
- formatGb,
- formatTimeRemaining,
- generateDisplayId,
- generateProxyCredentials,
- generateIp,
- generateConnectionString,
- generateOpenvpnConfig,
- generateVlessUri,
-} from "../utils/format.js";
-import { errorResponse, textResponse } from "../utils/response.js";
-import { searchProducts, getProduct } from "../mock-data/proxy.js";
-
-export function registerProxyTools(server: McpServer) {
+import { HttpClient, HttpError, NetworkError } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
+import { newIdempotencyKey } from "../client/idempotency.js";
+import { Proxy, ProxyPlan, ProxyList, type ProxyPlan as ProxyPlanT } from "../client/types.js";
+import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js";
+import { formatUsd } from "../utils/format.js";
+
+async function fetchProxyPlan(http: HttpClient, planId: string): Promise {
+ const plansData = await callApi<{ plans: unknown[] }>(http, "GET", `/v1/proxy_plans`);
+ const plans = z.array(ProxyPlan).parse(plansData.plans);
+ return plans.find((p) => p.id === planId) ?? null;
+}
+
+// ── search_proxies ──────────────────────────────────────────────────────────
+
+export const searchProxiesHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: {
+ country?: string;
+ min_data_gb?: number;
+ }): Promise => {
+ const q = new URLSearchParams();
+ if (args.country) q.set("country", args.country);
+ if (args.min_data_gb !== undefined) q.set("min_gb", String(args.min_data_gb));
+ const path = `/v1/proxy_plans${q.toString() ? `?${q}` : ""}`;
+ const data = await callApi<{ plans: unknown[] }>(http, "GET", path);
+ const plans = z.array(ProxyPlan).parse(data.plans);
+ if (plans.length === 0) return toolError("No proxy plans matched your filters.");
+ const text = [
+ `Found ${plans.length} proxy plan(s):`,
+ ``,
+ ...plans.map((p) =>
+ ` ${p.name.padEnd(36)} ${p.id.padEnd(20)} ${p.type.padEnd(10)} ${(p.country_name ?? p.country ?? "global").padEnd(16)} ${p.data_gb}GB ${p.duration_days}d ${formatUsd(p.quoted_price_cents)}`,
+ ),
+ ].join("\n");
+ return structuredOk(text, { proxy_plans: plans });
+ });
+
+// ── purchase_proxy ──────────────────────────────────────────────────────────
+
+export const purchaseProxyHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { plan_id: string }): Promise => {
+ const plan = await fetchProxyPlan(http, args.plan_id);
+ if (!plan) {
+ return toolError(
+ `Plan '${args.plan_id}' not found. Use search_proxies to list available plans.`,
+ );
+ }
+ const out = await callApi<{ proxy: unknown }>(http, "POST", "/v1/proxies", {
+ body: { plan_id: args.plan_id, max_price_cents: plan.quoted_price_cents },
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const proxy = Proxy.parse(out.proxy);
+ return structuredOk(
+ `Proxy ${proxy.id} provisioning. Status: ${proxy.status}. Poll get_proxy_status until active.`,
+ { proxy },
+ );
+ });
+
+// ── get_proxy_status ────────────────────────────────────────────────────────
+
+export const getProxyStatusHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string }): Promise => {
+ // The core GET is the source of truth (and enforces auth/ownership). Usage
+ // and the NoList gateway are best-effort enrichment: degrade either to null
+ // on ANY API/network error so a transient secondary failure never sinks a
+ // status read. Non-API throws (bugs) still propagate.
+ const degradeToNull = (e: unknown) => {
+ if (e instanceof HttpError || e instanceof NetworkError) return null;
+ throw e;
+ };
+ const [coreRaw, usageRaw, nolistRaw] = await Promise.all([
+ callApi<{ proxy: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}`),
+ callApi<{ usage: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}/usage`).catch(degradeToNull),
+ // Idempotent get-or-create for the package-level NoList gateway. A stable
+ // per-proxy key (not a fresh UUID) means concurrent/repeat status polls
+ // dedup to a single provisioning instead of racing to overwrite the
+ // gateway password.
+ callApi<{ proxy: unknown }>(http, "POST", `/v1/proxies/${args.proxy_id}/nolist_credentials`, {
+ idempotencyKey: `nolist-${args.proxy_id}`,
+ }).catch(degradeToNull),
+ ]);
+ // Prefer the nolist response (gateway populated once active) over the core
+ // GET, whose gateway is null until nolist credentials are provisioned.
+ const proxy = Proxy.parse((nolistRaw?.proxy as unknown) ?? coreRaw.proxy);
+ const lines = [
+ `Proxy ${proxy.id}`,
+ ``,
+ ` Status: ${proxy.status}`,
+ ` Data: ${(proxy.data_bytes_used / 1024 / 1024 / 1024).toFixed(2)} GB / ${proxy.data_gb_total} GB`,
+ ` Expires: ${proxy.expires_at}`,
+ ];
+ if (proxy.rotation_url) lines.push(` Rotation URL: ${proxy.rotation_url}`);
+ if (proxy.gateway) {
+ lines.push(
+ ``,
+ ` Gateway:`,
+ ` Host: ${proxy.gateway.host}`,
+ ` Port: ${proxy.gateway.port}`,
+ ` Protocol: ${proxy.gateway.protocol}`,
+ ` User: ${proxy.gateway.username}`,
+ ` Password: ${proxy.gateway.password}`,
+ );
+ } else {
+ lines.push(``, ` Gateway: (not yet provisioned)`);
+ }
+ return structuredOk(lines.join("\n"), {
+ proxy,
+ usage: usageRaw?.usage ?? null,
+ nolist_credentials: proxy.gateway,
+ });
+ });
+
+// ── rotate_proxy_ip ─────────────────────────────────────────────────────────
+
+export const rotateProxyIpHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string }): Promise => {
+ const out = await callApi<{ proxy_id: string; rotated_at: string; current_ip: string | null }>(
+ http,
+ "POST",
+ `/v1/proxies/${args.proxy_id}/rotate_ip`,
+ { idempotencyKey: newIdempotencyKey() },
+ );
+ return structuredOk(
+ `Rotated ${out.proxy_id} at ${out.rotated_at}. New IP: ${out.current_ip ?? "(unknown)"}`,
+ { proxy_id: out.proxy_id, rotated_at: out.rotated_at, current_ip: out.current_ip },
+ );
+ });
+
+// ── renew_proxy ─────────────────────────────────────────────────────────────
+
+export const renewProxyHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string }): Promise => {
+ const coreRaw = await callApi<{ proxy: unknown }>(
+ http,
+ "GET",
+ `/v1/proxies/${args.proxy_id}`,
+ );
+ const proxy = Proxy.parse(coreRaw.proxy);
+ if (!proxy.plan_id) {
+ return toolError(`Proxy ${args.proxy_id} has no plan_id; renewal not available.`);
+ }
+ const plan = await fetchProxyPlan(http, proxy.plan_id);
+ if (!plan) return toolError(`Original plan ${proxy.plan_id} no longer available.`);
+ const out = await callApi<{ proxy: unknown }>(
+ http,
+ "POST",
+ `/v1/proxies/${args.proxy_id}/renew`,
+ {
+ body: { max_price_cents: plan.quoted_price_cents },
+ idempotencyKey: newIdempotencyKey(),
+ },
+ );
+ return structuredOk(`Proxy ${args.proxy_id} renewed.`, { proxy: Proxy.parse(out.proxy) });
+ });
+
+// ── topup_proxy ─────────────────────────────────────────────────────────────
+
+export const topupProxyHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string; additional_gb: number }): Promise => {
+ // Quote: derive per-GB price from the proxy's original plan, then tie
+ // max_price_cents to (perGb * additional_gb) so we never pay above quote.
+ const coreRaw = await callApi<{ proxy: unknown }>(
+ http,
+ "GET",
+ `/v1/proxies/${args.proxy_id}`,
+ );
+ const proxy = Proxy.parse(coreRaw.proxy);
+ if (!proxy.plan_id) {
+ return toolError(`Proxy ${args.proxy_id} has no plan_id; top-up not available.`);
+ }
+ const plan = await fetchProxyPlan(http, proxy.plan_id);
+ if (!plan) return toolError(`Original plan ${proxy.plan_id} no longer available.`);
+ if (!plan.data_gb || plan.data_gb <= 0) {
+ return toolError(`Plan ${plan.id} has no GB allowance; top-up not available.`);
+ }
+ const maxPriceCents = Math.round((plan.quoted_price_cents / plan.data_gb) * args.additional_gb);
+ const out = await callApi<{ proxy: unknown }>(
+ http,
+ "POST",
+ `/v1/proxies/${args.proxy_id}/topup`,
+ {
+ body: { additional_gb: args.additional_gb, max_price_cents: maxPriceCents },
+ idempotencyKey: newIdempotencyKey(),
+ },
+ );
+ // The topup response body carries only the refreshed proxy (no per-topup
+ // charged amount), so report the quoted ceiling we tied - the strict-tie
+ // contract guarantees the actual charge did not exceed it.
+ const refreshed = Proxy.parse(out.proxy);
+ return structuredOk(
+ `Topped up ${args.proxy_id} by ${args.additional_gb} GB (up to ${formatUsd(maxPriceCents)}).`,
+ { proxy: refreshed },
+ );
+ });
+
+// ── regenerate_proxy_password ───────────────────────────────────────────────
+
+export const regenerateProxyPasswordHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string }): Promise => {
+ const out = await callApi<{ proxy: unknown }>(
+ http,
+ "POST",
+ `/v1/proxies/${args.proxy_id}/regenerate_password`,
+ { idempotencyKey: newIdempotencyKey() },
+ );
+ const proxy = Proxy.parse(out.proxy);
+ const password = proxy.gateway?.password ?? "(no gateway)";
+ return structuredOk(`New password for ${args.proxy_id}: ${password}`, { proxy });
+ });
+
+// ── list_proxy_lists ────────────────────────────────────────────────────────
+
+export const listProxyListsHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string }): Promise => {
+ const coreRaw = await callApi<{ proxy: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}`);
+ const proxy = Proxy.parse(coreRaw.proxy);
+ const lists = proxy.lists;
+ if (lists.length === 0) return toolError(`No proxy lists on ${args.proxy_id}.`);
+ const text = [
+ `Lists for ${args.proxy_id}:`,
+ ``,
+ ...lists.map((l) => {
+ const geo = l.countries?.length
+ ? l.countries.join(",")
+ : [l.country, l.region, l.city, l.isp].filter(Boolean).join("/") || "world";
+ const rot =
+ l.rotation_period_seconds === 0
+ ? "per-request"
+ : l.rotation_period_seconds === -1
+ ? "sticky"
+ : `${l.rotation_period_seconds}s`;
+ return ` ${l.name} (${l.id}) geo=${geo} rotation=${rot} mode=${l.rotation_mode}`;
+ }),
+ ].join("\n");
+ return structuredOk(text, { lists });
+ });
+
+// ── create_proxy_list ───────────────────────────────────────────────────────
+
+export const createProxyListHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: {
+ proxy_id: string;
+ name: string;
+ country?: string;
+ countries?: string[];
+ region?: string;
+ city?: string;
+ isp?: string;
+ zip?: string;
+ rotation_period_seconds?: number;
+ rotation_mode?: "instant" | "delayed_5s" | "no_rotation_on_fail";
+ format?: string;
+ }): Promise => {
+ // Geo: country (single) XOR countries (2-30). region/city/isp/zip only
+ // valid with a single country. Mirror the API's validation locally to
+ // fail fast without a wasted round-trip.
+ const hasCountry = !!args.country;
+ const hasCountries = !!args.countries && args.countries.length > 0;
+ if (!hasCountry && !hasCountries) {
+ return toolError("Provide either country (single) or countries (2-30).");
+ }
+ if (hasCountry && hasCountries) {
+ return toolError("country and countries are mutually exclusive.");
+ }
+ if (hasCountries && (args.region || args.city || args.isp || args.zip)) {
+ return toolError("region/city/isp/zip are only valid with a single country, not countries.");
+ }
+ const body: Record = {
+ name: args.name,
+ rotation_period_seconds: args.rotation_period_seconds ?? 0,
+ rotation_mode: args.rotation_mode ?? "instant",
+ format: args.format ?? "login_pass_host_port",
+ };
+ if (hasCountry) {
+ body.country = args.country;
+ if (args.region) body.region = args.region;
+ if (args.city) body.city = args.city;
+ if (args.isp) body.isp = args.isp;
+ if (args.zip) body.zip = args.zip;
+ } else {
+ body.countries = args.countries;
+ }
+ const out = await callApi<{ list: unknown }>(http, "POST", `/v1/proxies/${args.proxy_id}/lists`, {
+ body,
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const list = ProxyList.parse(out.list);
+ const credLines = list.credentials
+ ? [` Username: ${list.credentials.username}`, ` Password: ${list.credentials.password}`]
+ : [` Credentials: (provisioning - active within 1-2 minutes)`];
+ const text = [`Created list ${list.id}.`, ...credLines, ...list.entries.map((e) => ` ${e}`)].join("\n");
+ return structuredOk(text, { list });
+ });
+
+// ── delete_proxy_list ───────────────────────────────────────────────────────
+
+export const deleteProxyListHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { proxy_id: string; list_id: string }): Promise => {
+ await callApi(http, "DELETE", `/v1/proxies/${args.proxy_id}/lists/${args.list_id}`, {
+ idempotencyKey: newIdempotencyKey(),
+ });
+ return structuredOk(`List ${args.list_id} deleted.`, { proxy_id: args.proxy_id, list_id: args.list_id });
+ });
+
+// ── registration ────────────────────────────────────────────────────────────
+
+export function registerProxyTools(server: McpServer, http: HttpClient) {
server.tool(
"search_proxies",
- "Search available mobile proxy products by country or type.",
+ "Search available mobile proxy plans (shared rotating mobile IPs, billed by data). Each result includes the plan id, location, data allowance, duration, and price. Region/city/ISP targeting is configured per list after purchase via create_proxy_list.",
{
- country: z
- .string()
- .optional()
- .describe("ISO 3166-1 alpha-2 country code (e.g., US, GB, DE)"),
- type: z
- .enum(["shared", "dedicated_standard", "dedicated_premium"])
- .optional()
- .describe("Proxy type: shared (pay-per-GB), dedicated standard, or dedicated premium"),
+ country: z.string().optional().describe("ISO-3166-1 alpha-2 (e.g. 'US'). Many plans are worldwide and match any country."),
+ min_data_gb: z.number().optional().describe("Minimum included data allowance in GB"),
},
- async ({ country, type }) => {
- try {
- const validatedCountry = country ? validateCountry(country) : undefined;
- const results = searchProducts({ country: validatedCountry, type });
-
- if (results.length === 0) {
- return errorResponse(
- "No proxy products found matching your criteria. Try a different country or type."
- );
- }
-
- let text = `Found ${results.length} proxy product(s):\n\n`;
- for (const p of results) {
- text += ` ${p.name}\n`;
- text += ` ID: ${p.id}\n`;
- text += ` Type: ${p.type}\n`;
- text += ` Country: ${p.country} (${p.countryName})\n`;
- text += ` Carrier: ${p.carrier} (${p.carrierName})\n`;
- if (p.dataGb !== null) {
- text += ` Data: ${formatGb(p.dataGb)}\n`;
- }
- text += ` Duration: ${p.durationDays} days (${p.period})\n`;
- text += ` Price: ${formatUsd(p.priceCents)}\n`;
- if (p.features.length > 0) {
- text += ` Features: ${p.features.join(", ")}\n`;
- }
- text += `\n`;
- }
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ searchProxiesHandler(http),
);
server.tool(
"purchase_proxy",
- "Purchase a mobile proxy. Shared (pay-per-GB), dedicated standard, or dedicated premium.",
- {
- productId: z.string().describe("Product ID from search_proxies"),
- },
- async ({ productId }) => {
- try {
- const product = getProduct(productId);
- if (!product) {
- return errorResponse(
- `Product not found: ${productId}. Use search_proxies to find available products.`
- );
- }
-
- if (
- !state.deductBalance(
- product.priceCents,
- "proxy_purchase",
- `Proxy: ${product.name}`
- )
- ) {
- return errorResponse(
- `Insufficient balance. Need ${formatUsd(product.priceCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.`
- );
- }
-
- const creds = generateProxyCredentials(product.country);
- const ip = generateIp();
- const now = Date.now();
-
- const protocol: "http" | "socks5" | "vless" =
- product.type === "shared"
- ? "http"
- : product.type === "dedicated_standard"
- ? "socks5"
- : "vless";
-
- const lists: ProxyList[] = [];
- if (product.type === "shared") {
- const r = () => Math.random().toString(36).substring(2, 8);
- lists.push({
- id: generateId("lst"),
- name: "Default",
- login: `vm_${r()}`,
- password: r() + r(),
- country: null,
- region: null,
- city: null,
- isp: null,
- locationPreset: "world_mix",
- countries: null,
- rotationPeriod: 0,
- });
- }
-
- const proxyId = generateId("prx");
- const entry = {
- id: proxyId,
- displayId: generateDisplayId("PRX"),
- type: product.type,
- status: "active" as const,
- proxyHost: creds.host,
- proxyPort: creds.port,
- socksPort:
- product.type === "dedicated_standard" || product.type === "dedicated_premium"
- ? creds.socksPort
- : null,
- proxyUsername: creds.username,
- proxyPassword: creds.password,
- protocol,
- country: product.country,
- countryName: product.countryName,
- carrier: product.carrier,
- carrierName: product.carrierName,
- currentIp: ip,
- isOnline: true,
- dataTotal: product.type === "shared" ? product.dataGb : null,
- dataUsed: product.type === "shared" ? 0 : null,
- rotationInterval: null,
- lastRotatedAt: null,
- autoRenew: false,
- expiresAt: now + product.durationDays * 24 * 60 * 60 * 1000,
- createdAt: now,
- priceCents: product.priceCents,
- features: product.features,
- lists,
- };
-
- state.proxies.set(proxyId, entry);
-
- const connectionString = generateConnectionString(
- creds.host,
- creds.port,
- creds.username,
- creds.password
- );
-
- const lines = [
- `Proxy purchased!`,
- ``,
- ` Order ID: ${proxyId}`,
- ` Display ID: ${entry.displayId}`,
- ` Product: ${product.name}`,
- ` Type: ${product.type}`,
- ` Protocol: ${protocol}`,
- ` Country: ${product.country} (${product.countryName})`,
- ` Carrier: ${product.carrier} (${product.carrierName})`,
- ` Cost: ${formatUsd(product.priceCents)}`,
- ` Expires: ${formatTimeRemaining(entry.expiresAt)}`,
- ];
-
- if (product.type === "shared" && product.dataGb !== null) {
- lines.push(` Data: ${formatGb(product.dataGb)}`);
- }
-
- lines.push(
- ``,
- ` Connection:`,
- ` Host: ${creds.host}`,
- ` Port: ${creds.port}`
- );
-
- if (entry.socksPort !== null) {
- lines.push(` SOCKS Port: ${entry.socksPort}`);
- }
-
- lines.push(
- ` Username: ${creds.username}`,
- ` Password: ${creds.password}`,
- ` String: ${connectionString}`,
- ` Current IP: ${ip}`
- );
-
- if (lists.length > 0) {
- const list = lists[0];
- const listConn = generateConnectionString(creds.host, creds.port, list.login, list.password);
- lines.push(
- ``,
- ` Default Proxy List:`,
- ` List ID: ${list.id}`,
- ` Login: ${list.login}`,
- ` Password: ${list.password}`,
- ` Preset: ${list.locationPreset}`,
- ` Rotation: per-request`,
- ` Connection: ${listConn}`
- );
- }
-
- lines.push(
- ``,
- `Use get_proxy_status to check status, rotate_proxy to rotate IP (dedicated only).`
- );
-
- return textResponse(lines.join("\n"));
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ "Purchase a proxy plan. Returns 202 Accepted with status=provisioning; the gateway becomes active in 1-2 minutes. Poll get_proxy_status until status='active'.",
+ { plan_id: z.string() },
+ purchaseProxyHandler(http),
);
server.tool(
"get_proxy_status",
- "Check status, bandwidth, and connection details for a proxy.",
- {
- proxyId: z.string().describe("Proxy order ID returned from purchase_proxy"),
- },
- async ({ proxyId }) => {
- try {
- const proxy = state.proxies.get(proxyId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${proxyId}`);
- }
-
- const now = Date.now();
-
- if (proxy.type === "shared" && proxy.dataTotal !== null) {
- const hoursElapsed = (now - proxy.createdAt) / 3600000;
- const simulatedUsage = hoursElapsed * 0.05;
- const maxUsage = proxy.dataTotal * 0.9;
- proxy.dataUsed = Math.round(Math.min(simulatedUsage, maxUsage) * 100) / 100;
- }
-
- const lines = [
- `Proxy Status - ${proxy.carrierName} (${proxy.countryName})`,
- ``,
- ` Order ID: ${proxy.id}`,
- ` Display ID: ${proxy.displayId}`,
- ` Status: ${proxy.status}`,
- ` Type: ${proxy.type}`,
- ` Protocol: ${proxy.protocol}`,
- ` Country: ${proxy.country} (${proxy.countryName})`,
- ` Carrier: ${proxy.carrier} (${proxy.carrierName})`,
- ` Online: ${proxy.isOnline ? "yes" : "no"}`,
- ` Current IP: ${proxy.currentIp}`,
- ` Expires: ${formatTimeRemaining(proxy.expiresAt)}`,
- ` Auto-renew: ${proxy.autoRenew ? "on" : "off"}`,
- ];
-
- if (proxy.type === "shared" && proxy.dataTotal !== null && proxy.dataUsed !== null) {
- const remaining = Math.max(0, proxy.dataTotal - proxy.dataUsed);
- lines.push(
- ``,
- ` Bandwidth:`,
- ` Used: ${formatGb(proxy.dataUsed)}`,
- ` Remaining: ${formatGb(remaining)}`,
- ` Total: ${formatGb(proxy.dataTotal)}`
- );
- }
-
- if (proxy.type !== "shared") {
- lines.push(
- ``,
- ` Rotation:`,
- ` Interval: ${proxy.rotationInterval !== null ? `${proxy.rotationInterval}s` : "manual"}`,
- ` Last rotated: ${proxy.lastRotatedAt ? new Date(proxy.lastRotatedAt).toISOString() : "never"}`
- );
- }
-
- lines.push(
- ``,
- ` Connection:`,
- ` Host: ${proxy.proxyHost}`,
- ` Port: ${proxy.proxyPort}`
- );
-
- if (proxy.socksPort !== null) {
- lines.push(` SOCKS Port: ${proxy.socksPort}`);
- }
-
- lines.push(
- ` Username: ${proxy.proxyUsername}`,
- ` Password: ${proxy.proxyPassword}`
- );
-
- if (proxy.features.length > 0) {
- lines.push(``, ` Features: ${proxy.features.join(", ")}`);
- }
-
- if (proxy.type === "shared" && proxy.lists.length > 0) {
- lines.push(``, ` Proxy Lists: ${proxy.lists.length}`);
- for (const list of proxy.lists) {
- lines.push(` - ${list.name} (${list.id}) preset=${list.locationPreset}`);
- }
- }
-
- return textResponse(lines.join("\n"));
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ "Read a proxy's status, usage, and gateway credentials in one call.",
+ { proxy_id: z.string() },
+ getProxyStatusHandler(http),
);
server.tool(
- "rotate_proxy",
- "Rotate a proxy to get a new IP address. Works on dedicated proxies only.",
- {
- proxyId: z.string().describe("Proxy order ID to rotate"),
- },
- async ({ proxyId }) => {
- try {
- const proxy = state.proxies.get(proxyId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${proxyId}`);
- }
-
- if (proxy.status !== "active") {
- return errorResponse(
- `Proxy ${proxyId} is ${proxy.status}. Only active proxies can be rotated.`
- );
- }
-
- if (proxy.type === "shared") {
- return errorResponse(
- `Shared proxies rotate automatically per-request. Use proxy lists to control rotation. Only dedicated proxies support manual rotation.`
- );
- }
-
- const oldIp = proxy.currentIp;
- const newIp = generateIp();
- proxy.currentIp = newIp;
- proxy.lastRotatedAt = Date.now();
-
- const text = [
- `IP rotated!`,
- ``,
- ` Order ID: ${proxy.id}`,
- ` Old IP: ${oldIp}`,
- ` New IP: ${newIp}`,
- ` Last rotated: ${new Date(proxy.lastRotatedAt).toISOString()}`,
- ].join("\n");
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ "rotate_proxy_ip",
+ "Rotate a dedicated proxy to a new IP. Shared proxies rotate per-request through their lists - use create_proxy_list / list_proxy_lists instead.",
+ { proxy_id: z.string() },
+ rotateProxyIpHandler(http),
);
server.tool(
- "get_proxy_lists",
- "Get proxy lists for a shared proxy order.",
- {
- orderId: z.string().describe("Shared proxy order ID"),
- },
- async ({ orderId }) => {
- try {
- const proxy = state.proxies.get(orderId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${orderId}`);
- }
-
- if (proxy.type !== "shared") {
- return errorResponse(
- `Proxy lists are only available for shared proxies. This is a ${proxy.type} proxy.`
- );
- }
-
- if (proxy.lists.length === 0) {
- return errorResponse(`No proxy lists found for order ${orderId}.`);
- }
-
- let text = `Proxy Lists for ${proxy.displayId} (${proxy.countryName}):\n\n`;
- for (const list of proxy.lists) {
- const connStr = generateConnectionString(
- proxy.proxyHost,
- proxy.proxyPort,
- list.login,
- list.password
- );
- text += ` ${list.name} (${list.id})\n`;
- text += ` Login: ${list.login}\n`;
- text += ` Password: ${list.password}\n`;
- text += ` Preset: ${list.locationPreset}\n`;
- if (list.countries) {
- text += ` Countries: ${list.countries.join(", ")}\n`;
- }
- text += ` Rotation: ${list.rotationPeriod === 0 ? "per-request" : list.rotationPeriod === -1 ? "sticky" : `${list.rotationPeriod}s`}\n`;
- if (list.country) text += ` Country: ${list.country}\n`;
- if (list.region) text += ` Region: ${list.region}\n`;
- if (list.city) text += ` City: ${list.city}\n`;
- if (list.isp) text += ` ISP: ${list.isp}\n`;
- text += ` Connection: ${connStr}\n`;
- text += `\n`;
- }
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ "renew_proxy",
+ "Extend a proxy's expiry by purchasing another period. Quote-then-commit.",
+ { proxy_id: z.string() },
+ renewProxyHandler(http),
);
server.tool(
- "create_proxy_list",
- "Create a new geo-targeted proxy list for a shared proxy order.",
+ "topup_proxy",
+ "Add more data to a shared proxy plan. Quote-then-commit: derives per-GB price from the proxy's original plan and ties max_price_cents to (per_gb * additional_gb).",
{
- orderId: z.string().describe("Shared proxy order ID"),
- name: z.string().describe("Name for the proxy list"),
- locationPreset: z
- .enum(["world_mix", "north_america", "europe", "asia", "latin_america", "custom"])
- .default("world_mix")
- .describe("Location preset for the list"),
- countries: z
- .array(z.string())
- .optional()
- .describe("Country codes (required when preset is 'custom')"),
- rotationPeriod: z
- .number()
- .default(0)
- .describe("Rotation period: 0=per-request, -1=sticky, N=seconds"),
+ proxy_id: z.string(),
+ additional_gb: z.number().int().positive(),
},
- async ({ orderId, name, locationPreset, countries, rotationPeriod }) => {
- try {
- const proxy = state.proxies.get(orderId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${orderId}`);
- }
-
- if (proxy.type !== "shared") {
- return errorResponse(
- `Proxy lists are only available for shared proxies. This is a ${proxy.type} proxy.`
- );
- }
-
- if (locationPreset === "custom" && (!countries || countries.length === 0)) {
- return errorResponse(
- `Countries must be provided when using the 'custom' location preset.`
- );
- }
-
- const validatedCountries = countries
- ? countries.map((c) => validateCountry(c))
- : null;
-
- const r = () => Math.random().toString(36).substring(2, 8);
- const list: ProxyList = {
- id: generateId("lst"),
- name,
- login: `vm_${r()}`,
- password: r() + r(),
- country: null,
- region: null,
- city: null,
- isp: null,
- locationPreset,
- countries: validatedCountries,
- rotationPeriod,
- };
-
- proxy.lists.push(list);
-
- const connStr = generateConnectionString(
- proxy.proxyHost,
- proxy.proxyPort,
- list.login,
- list.password
- );
-
- const rotationLabel =
- rotationPeriod === 0
- ? "per-request"
- : rotationPeriod === -1
- ? "sticky"
- : `${rotationPeriod}s`;
-
- const lines = [
- `Proxy list created!`,
- ``,
- ` List ID: ${list.id}`,
- ` Name: ${list.name}`,
- ` Login: ${list.login}`,
- ` Password: ${list.password}`,
- ` Preset: ${locationPreset}`,
- ];
-
- if (validatedCountries) {
- lines.push(` Countries: ${validatedCountries.join(", ")}`);
- }
-
- lines.push(
- ` Rotation: ${rotationLabel}`,
- ` Connection: ${connStr}`,
- ``,
- `Total lists for this order: ${proxy.lists.length}`
- );
-
- return textResponse(lines.join("\n"));
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ topupProxyHandler(http),
);
server.tool(
- "get_openvpn_config",
- "Get OpenVPN configuration file for a dedicated proxy.",
+ "regenerate_proxy_password",
+ "Rotate the main proxy gateway password. Returns the new credentials.",
+ { proxy_id: z.string() },
+ regenerateProxyPasswordHandler(http),
+ );
+
+ server.tool(
+ "list_proxy_lists",
+ "List proxy lists for a shared proxy (geo-targeted sub-pools that share the proxy's bandwidth).",
+ { proxy_id: z.string() },
+ listProxyListsHandler(http),
+ );
+
+ server.tool(
+ "create_proxy_list",
+ "Create a new geo-targeted proxy list on a shared proxy. Provide either a single country (with optional region/city/isp/zip subfilters) or a countries array (2-30, mutually exclusive with the subfilters). To edit an existing list, delete it and create a new one.",
{
- orderId: z.string().describe("Dedicated proxy order ID"),
+ proxy_id: z.string(),
+ name: z.string(),
+ country: z.string().optional().describe("ISO-3166-1 alpha-2, lowercased. Mutually exclusive with countries."),
+ countries: z.array(z.string()).optional().describe("2-30 ISO-3166-1 alpha-2 codes. Mutually exclusive with country/subfilters."),
+ region: z.string().optional().describe("Only valid with a single country."),
+ city: z.string().optional().describe("Only valid with a single country."),
+ isp: z.string().optional().describe("Only valid with a single country."),
+ zip: z.string().optional().describe("Only valid with a single country."),
+ rotation_period_seconds: z.number().int().default(0).describe("0=per-request, -1=sticky, N=seconds (max 86400)"),
+ rotation_mode: z.enum(["instant", "delayed_5s", "no_rotation_on_fail"]).default("instant"),
+ format: z.string().default("login_pass_host_port").describe("Output format for entries[] (e.g. login_pass_host_port, http_url, socks5_url)"),
},
- async ({ orderId }) => {
- try {
- const proxy = state.proxies.get(orderId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${orderId}`);
- }
-
- if (proxy.type === "shared") {
- return errorResponse(
- `OpenVPN config is only available for dedicated proxies. This is a shared proxy.`
- );
- }
-
- const { config, filename } = generateOpenvpnConfig(
- proxy.proxyHost,
- proxy.proxyUsername,
- proxy.proxyPassword,
- proxy.country,
- proxy.carrierName
- );
-
- const text = [
- `OpenVPN Configuration - ${proxy.displayId}`,
- ``,
- ` Filename: ${filename}`,
- ``,
- `--- ${filename} ---`,
- config,
- `--- end ---`,
- ].join("\n");
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ createProxyListHandler(http),
);
server.tool(
- "get_vless_config",
- "Get VLESS connection URI for a dedicated premium proxy.",
+ "delete_proxy_list",
+ "Delete a proxy list. The list's credentials stop working immediately.",
{
- orderId: z.string().describe("Dedicated premium proxy order ID"),
+ proxy_id: z.string(),
+ list_id: z.string(),
},
- async ({ orderId }) => {
- try {
- const proxy = state.proxies.get(orderId);
- if (!proxy) {
- return errorResponse(`Proxy not found: ${orderId}`);
- }
-
- if (proxy.type !== "dedicated_premium") {
- return errorResponse(
- `VLESS config is only available for dedicated premium proxies. This is a ${proxy.type} proxy.`
- );
- }
-
- const { uri, uuid } = generateVlessUri(
- proxy.proxyHost,
- proxy.proxyPort,
- proxy.country
- );
-
- const text = [
- `VLESS Configuration - ${proxy.displayId}`,
- ``,
- ` Host: ${proxy.proxyHost}`,
- ` Port: ${proxy.proxyPort}`,
- ` UUID: ${uuid}`,
- ``,
- ` URI: ${uri}`,
- ].join("\n");
-
- return textResponse(text);
- } catch (e) {
- if (e instanceof ToolError) return errorResponse(e.message);
- throw e;
- }
- }
+ deleteProxyListHandler(http),
);
}
diff --git a/src/tools/sms.ts b/src/tools/sms.ts
index c20d2be..15e944d 100644
--- a/src/tools/sms.ts
+++ b/src/tools/sms.ts
@@ -1,461 +1,316 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { state, SmsRental } from "../sandbox/state.js";
-import { generateId } from "../utils/validation.js";
+import { HttpClient } from "../client/http.js";
+import { callApi } from "../client/call-api.js";
import {
- formatUsd,
- generatePhoneNumber,
- generateVerificationCode,
- generateDisplayId,
- formatTimeRemaining,
-} from "../utils/format.js";
-import { errorResponse, textResponse } from "../utils/response.js";
-import { searchServices, getService, smsServices } from "../mock-data/sms.js";
-
-const DURATION_DAYS: Record = {
- "3D": 3,
- "7D": 7,
- "14D": 14,
- "30D": 30,
-};
-
-const REUSE_COST_CENTS = 50;
-
-export function registerSmsTools(server: McpServer) {
- server.tool(
- "search_sms_services",
- "Search available US non-VoIP SMS services with pricing for verification, long-term rental, and dedicated numbers.",
- {
- query: z
- .string()
- .optional()
- .describe("Search by service name (e.g., 'telegram', 'whatsapp')"),
- },
- async ({ query }) => {
- const results = searchServices(query);
-
- if (results.length === 0) {
- return errorResponse(
- "No services found matching your query. Try a different search term or omit the query to see all services."
- );
- }
-
- let text = `Found ${results.length} US non-VoIP SMS service(s):\n\n`;
- text += `${"Service".padEnd(22)} ${"ID".padEnd(6)} ${"Verify".padStart(8)} ${"3-Day".padStart(8)} ${"7-Day".padStart(8)} ${"14-Day".padStart(8)} ${"30-Day".padStart(8)} ${"Dedicated".padStart(10)} Icon\n`;
- text += `${"─".repeat(22)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(4)}\n`;
-
- for (const s of results) {
- text += `${s.serviceName.padEnd(22)} ${s.id.padEnd(6)} ${formatUsd(s.priceUsdCents).padStart(8)} ${formatUsd(s.ltr3PriceCents).padStart(8)} ${formatUsd(s.ltr7PriceCents).padStart(8)} ${formatUsd(s.ltr14PriceCents).padStart(8)} ${formatUsd(s.ltr30PriceCents).padStart(8)} ${formatUsd(s.dedicatedPriceCents).padStart(10)} ${s.hasIcon ? "Yes" : "No"}\n`;
- }
-
- return textResponse(text);
+ Verification,
+ VerificationCancelResult,
+ Rental,
+ ServicesResponse,
+ type Verification as VerificationT,
+ type Rental as RentalT,
+} from "../client/types.js";
+import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js";
+import { formatUsd, formatTimeRemaining } from "../utils/format.js";
+import { newIdempotencyKey } from "../client/idempotency.js";
+import {
+ VER_PREFIX,
+ REN_PREFIX,
+ isVerificationId,
+ isRentalId,
+ INVALID_RENTAL_ID,
+} from "../constants/rental-id.js";
+
+// The catalog service id for the 28-day dedicated number tier.
+const DEDICATED_SERVICE_ID = "svc_dedicated_28d";
+
+// ── search_sms_services ─────────────────────────────────────────────────────
+
+export const searchSmsServicesHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { query?: string }): Promise => {
+ const raw = await callApi(http, "GET", "/v1/services");
+ const parsed = ServicesResponse.parse(raw);
+ let services = parsed.services;
+ if (args.query) {
+ const q = args.query.toLowerCase();
+ services = services.filter((s) => s.name.toLowerCase().includes(q));
}
- );
-
- server.tool(
- "get_sms_price",
- "Get all pricing tiers for a specific US non-VoIP SMS service.",
- {
- service: z.string().describe("Service ID (e.g., 'wa', 'tg', 'go')"),
- },
- async ({ service }) => {
- const svc = getService(service);
-
- if (!svc) {
- return errorResponse(
- `Service "${service}" not found. Use search_sms_services to find available options.`
- );
- }
+ if (services.length === 0) return toolError("No services matched.");
+ const text = [
+ `${services.length} SMS service(s):`,
+ ``,
+ ...services.slice(0, 50).map((s) =>
+ ` ${s.name.padEnd(20)} ${s.id.padEnd(14)} verify=${formatUsd(s.quoted_price_cents)}${
+ s.ltr_7d_price_cents ? ` 7d=${formatUsd(s.ltr_7d_price_cents)}` : ""
+ }`,
+ ),
+ ].join("\n");
+ return structuredOk(text, { services });
+ });
+
+// ── get_rental ──────────────────────────────────────────────────────────────
+
+export const getRentalHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { rental_id: string }): Promise => {
+ const id = args.rental_id;
+ if (isVerificationId(id)) {
+ const raw = await callApi<{ verification: unknown }>(http, "GET", `/v1/verifications/${id}`);
+ const v = Verification.parse(raw.verification);
+ return structuredOk(renderVerification(v), { verification: v });
+ }
+ if (isRentalId(id)) {
+ const raw = await callApi(http, "GET", `/v1/rentals/${id}`);
+ const r = Rental.parse(raw);
+ return structuredOk(renderRental(r), { rental: r });
+ }
+ return toolError(INVALID_RENTAL_ID(id));
+ });
- const text = [
- `${svc.serviceName} (${svc.id}) - US Non-VoIP`,
- ``,
- ` Verification (20min): ${formatUsd(svc.priceUsdCents)}`,
- ` Long-Term Rental:`,
- ` 3-day: ${formatUsd(svc.ltr3PriceCents)}`,
- ` 7-day: ${formatUsd(svc.ltr7PriceCents)}`,
- ` 14-day: ${formatUsd(svc.ltr14PriceCents)}`,
- ` 30-day: ${formatUsd(svc.ltr30PriceCents)}`,
- ` Dedicated (28 days): ${formatUsd(svc.dedicatedPriceCents)}`,
- ``,
- ` Has icon: ${svc.hasIcon ? "Yes" : "No"}`,
- ].join("\n");
+// ── rent_number ─────────────────────────────────────────────────────────────
- return textResponse(text);
+export const rentNumberHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { service_id: string; kind?: "verification" | "rental" | "dedicated"; duration?: "3d" | "7d" | "14d" | "30d" }): Promise => {
+ const kind = args.kind ?? "verification";
+ if (kind === "rental" && !args.duration) {
+ return toolError("rent_number kind='rental' requires a duration (3d|7d|14d|30d).");
}
- );
-
- server.tool(
- "rent_number",
- "Rent a US non-VoIP phone number. Supports verification (20min), long-term rental (3-30 days), and dedicated numbers (28 days, all services).",
- {
- service: z.string().describe("Service ID (e.g., 'wa', 'tg', 'go')"),
- rentalType: z
- .enum(["verification", "rental", "dedicated"])
- .default("verification")
- .describe("Type of rental (default: verification)"),
- duration: z
- .enum(["3D", "7D", "14D", "30D"])
- .optional()
- .describe("Duration for long-term rentals (required when rentalType is 'rental')"),
- autoRenew: z
- .boolean()
- .default(false)
- .describe("Enable auto-renewal (only for rental/dedicated)"),
- },
- async ({ service, rentalType, duration, autoRenew }) => {
- const svc = getService(service);
+ // Quote against the live catalog.
+ const services = await callApi(http, "GET", "/v1/services");
+ const parsed = ServicesResponse.parse(services);
+ if (kind === "verification") {
+ const svc = parsed.services.find((s) => s.id === args.service_id);
if (!svc) {
- return errorResponse(
- `Service "${service}" not found. Use search_sms_services to find available options.`
- );
+ return toolError(`Service '${args.service_id}' not found. Use search_sms_services to list available services.`);
}
+ const created = await callApi<{ verification: unknown }>(http, "POST", "/v1/verifications", {
+ body: { service_id: args.service_id, max_price_cents: svc.quoted_price_cents },
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const v = Verification.parse(created.verification);
+ return structuredOk(`Verification ${v.id} created.\n\n${renderVerification(v)}`, { verification: v });
+ }
- if (rentalType === "rental" && !duration) {
- return errorResponse(
- `Duration is required for long-term rentals. Choose one of: 3D, 7D, 14D, 30D.`
- );
+ // Long-term + dedicated both POST /v1/rentals with an uppercase duration.
+ // Dedicated is the dedicated 28-day catalog service (svc_dedicated_28d, 28D).
+ let serviceId: string;
+ let duration: "3D" | "7D" | "14D" | "30D" | "28D";
+ let quotedCents: number;
+ if (kind === "dedicated") {
+ serviceId = DEDICATED_SERVICE_ID;
+ duration = "28D";
+ const ded = parsed.services.find((s) => s.id === DEDICATED_SERVICE_ID);
+ if (!ded || !ded.ltr_28d_price_cents) {
+ return toolError("Dedicated 28-day numbers are not currently available.");
}
-
- let priceCents: number;
- let txType: "sms_verification" | "sms_rental" | "sms_dedicated";
- let expiryMs: number;
-
- const now = Date.now();
-
- if (rentalType === "verification") {
- priceCents = svc.priceUsdCents;
- txType = "sms_verification";
- expiryMs = 20 * 60 * 1000;
- } else if (rentalType === "rental") {
- const durationKey = duration!;
- const priceMap: Record = {
- "3D": svc.ltr3PriceCents,
- "7D": svc.ltr7PriceCents,
- "14D": svc.ltr14PriceCents,
- "30D": svc.ltr30PriceCents,
- };
- priceCents = priceMap[durationKey];
- txType = "sms_rental";
- expiryMs = DURATION_DAYS[durationKey] * 86400000;
- } else {
- // dedicated
- priceCents = svc.dedicatedPriceCents;
- txType = "sms_dedicated";
- expiryMs = 28 * 86400000;
+ quotedCents = ded.ltr_28d_price_cents;
+ } else {
+ serviceId = args.service_id;
+ const svc = parsed.services.find((s) => s.id === args.service_id);
+ if (!svc) {
+ return toolError(`Service '${args.service_id}' not found. Use search_sms_services to list available services.`);
}
-
- const desc = rentalType === "verification"
- ? `SMS verification: ${svc.serviceName}`
- : rentalType === "rental"
- ? `SMS ${duration} rental: ${svc.serviceName}`
- : `SMS dedicated: ${svc.serviceName}`;
-
- if (!state.deductBalance(priceCents, txType, desc)) {
- return errorResponse(
- `Insufficient balance. Need ${formatUsd(priceCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.`
- );
+ const tier = args.duration as "3d" | "7d" | "14d" | "30d";
+ const priceByTier: Record = {
+ "3d": svc.ltr_3d_price_cents,
+ "7d": svc.ltr_7d_price_cents,
+ "14d": svc.ltr_14d_price_cents,
+ "30d": svc.ltr_30d_price_cents,
+ };
+ const v = priceByTier[tier];
+ if (!v) {
+ return toolError(`Long-term rental ${args.duration} is not offered for ${svc.name}. Try a different duration or kind='dedicated'.`);
}
+ quotedCents = v;
+ duration = tier.toUpperCase() as "3D" | "7D" | "14D" | "30D";
+ }
+ // /v1/rentals returns the rental object flat (no { rental: ... } wrapper)
+ const created = await callApi(http, "POST", "/v1/rentals", {
+ body: { service_id: serviceId, duration, max_price_cents: quotedCents },
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const r = Rental.parse(created);
+ return structuredOk(`Rental ${r.id} created.\n\n${renderRental(r)}`, { rental: r });
+ });
+
+// ── cancel_rental ───────────────────────────────────────────────────────────
+
+export const cancelRentalHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { rental_id: string }): Promise => {
+ const id = args.rental_id;
+ if (isVerificationId(id)) {
+ const out = await callApi<{ verification: unknown }>(http, "POST", `/v1/verifications/${id}/cancel`, {
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const v = VerificationCancelResult.parse(out.verification);
+ const refund = v.refunded_cents && v.refunded_cents > 0 ? ` Refunded ${formatUsd(v.refunded_cents)}.` : "";
+ return structuredOk(`Verification ${v.id} cancelled.${refund}`, { verification: v });
+ }
+ if (isRentalId(id)) {
+ const out = await callApi(http, "DELETE", `/v1/rentals/${id}`, {
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const r = Rental.parse(out);
+ return structuredOk(`Rental ${r.id} cancelled.`, { rental: r });
+ }
+ return toolError(INVALID_RENTAL_ID(id));
+ });
- const rentalId = generateId("sms");
- const phoneNumber = generatePhoneNumber();
- const displayId = generateDisplayId("SMS");
-
- const rental: SmsRental = {
- id: rentalId,
- displayId,
- phoneNumber,
- service: svc.id,
- serviceName: svc.serviceName,
- rentalType,
- duration: rentalType === "rental" ? duration! : null,
- autoRenew: rentalType === "verification" ? false : autoRenew,
- paidUntil: rentalType === "verification" ? null : now + expiryMs,
- status: "active",
- messages: [],
- expiresAt: now + expiryMs,
- createdAt: now,
- priceCents,
- reuseCounter: 0,
- };
+// ── reuse_number ────────────────────────────────────────────────────────────
- state.smsRentals.set(rentalId, rental);
+export const reuseNumberHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { rental_id: string; paid?: boolean }): Promise => {
+ const id = args.rental_id;
+ if (!isVerificationId(id)) {
+ return toolError(`reuse_number requires a verification id (${VER_PREFIX}xxx). Got '${id}'.`);
+ }
+ const path = args.paid ? `/v1/verifications/${id}/reuse/paid` : `/v1/verifications/${id}/reuse`;
+ const out = await callApi<{ verification: unknown }>(http, "POST", path, {
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const v = Verification.parse(out.verification);
+ return structuredOk(renderVerification(v), { verification: v });
+ });
+
+// ── re_rent_rental ──────────────────────────────────────────────────────────
+
+export const reRentRentalHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { rental_id: string }): Promise => {
+ const id = args.rental_id;
+ if (!isRentalId(id)) {
+ return toolError(`re_rent_rental requires ${REN_PREFIX}xxx. Got '${id}'.`);
+ }
+ // No request body: re-rents the same number for the same duration at the
+ // current price. Only valid when re_rent_available is true on the rental.
+ const out = await callApi(http, "POST", `/v1/rentals/${id}/re_rent`, {
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const r = Rental.parse(out);
+ return structuredOk(`Re-rented ${r.id}.\n\n${renderRental(r)}`, { rental: r });
+ });
+
+// ── toggle_auto_renew ───────────────────────────────────────────────────────
+
+export const toggleAutoRenewHandler = (http: HttpClient) =>
+ wrapToolErrors(async (args: { rental_id: string; auto_renew: boolean }): Promise => {
+ const id = args.rental_id;
+ if (!isRentalId(id)) {
+ return toolError(`toggle_auto_renew requires ${REN_PREFIX}xxx. Got '${id}'.`);
+ }
+ const out = await callApi(http, "POST", `/v1/rentals/${id}/auto_renew`, {
+ body: { auto_renew: args.auto_renew },
+ idempotencyKey: newIdempotencyKey(),
+ });
+ const r = Rental.parse(out);
+ return structuredOk(`Auto-renew on ${r.id} is now ${r.auto_renew ? "on" : "off"}.`, { rental: r });
+ });
+
+// ── render helpers ──────────────────────────────────────────────────────────
+
+export function renderVerification(v: VerificationT): string {
+ const lines = [
+ `Verification ${v.id}`,
+ ``,
+ ` Phone: ${v.phone_number}`,
+ ` Service: ${v.service_name} (${v.service_id})`,
+ ` Status: ${v.status}`,
+ ` Charged: ${formatUsd(v.charged_price_cents)}`,
+ ` Expires: ${formatTimeRemaining(new Date(v.expires_at).getTime())}`,
+ ];
+ if (v.status === "code_received" && v.code) {
+ lines.push(``, ` Code received: ${v.code}`);
+ if (v.code_received_at) lines.push(` At: ${v.code_received_at}`);
+ } else if (v.status === "waiting_for_code") {
+ lines.push(``, ` No code yet. Try get_rental again in 10-30s.`);
+ }
+ return lines.join("\n");
+}
- const lines = [
- `Number rented!`,
- ``,
- ` Rental ID: ${rentalId}`,
- ` Display ID: ${displayId}`,
- ` Number: ${phoneNumber}`,
- ` Service: ${svc.serviceName}`,
- ` Type: ${rentalType}${rentalType === "rental" ? ` (${duration})` : ""}`,
- ` Cost: ${formatUsd(priceCents)}`,
- ` Expires: ${formatTimeRemaining(now + expiryMs)}`,
- ];
+export function renderRental(r: RentalT): string {
+ const label = r.duration === "28D" ? "dedicated" : "rental";
+ const lines = [
+ `Rental ${r.id} (${label})`,
+ ``,
+ ` Phone: ${r.phone_number}`,
+ ` Service: ${r.service_name} (${r.service_id})`,
+ ` Status: ${r.status}`,
+ ` Charged: ${formatUsd(r.charged_price_cents)}`,
+ ` Duration: ${r.duration ?? "-"}`,
+ ` Auto-renew: ${r.auto_renew ? "on" : "off"}`,
+ ` Paid until: ${r.paid_until ?? "-"}`,
+ ` Expires: ${formatTimeRemaining(new Date(r.expires_at).getTime())}`,
+ ];
+ if (r.messages && r.messages.length > 0) {
+ lines.push(``, ` Messages (${r.messages.length}):`);
+ for (const m of r.messages) {
+ lines.push(` [${m.received_at.slice(11, 19)}] ${m.text}`);
+ if (m.code) lines.push(` Code: ${m.code}`);
+ }
+ }
+ return lines.join("\n");
+}
- if (rentalType !== "verification") {
- lines.push(` Auto-renew: ${autoRenew ? "On" : "Off"}`);
- lines.push(` Paid until: ${new Date(now + expiryMs).toISOString().slice(0, 16)}`);
- }
+// ── registration ────────────────────────────────────────────────────────────
- lines.push(``);
- lines.push(` Balance: ${formatUsd(state.balanceCents)}`);
- lines.push(``);
- lines.push(`Use get_messages with the rental ID to check for incoming SMS.`);
+export function registerSmsTools(server: McpServer, http: HttpClient) {
+ server.tool(
+ "search_sms_services",
+ "Search available US non-VoIP SMS services with prices per row. Returns each service's verification price plus LTR/dedicated tiers when offered.",
+ { query: z.string().optional().describe("Substring filter on service name (e.g. 'telegram').") },
+ searchSmsServicesHandler(http),
+ );
- return textResponse(lines.join("\n"));
- }
+ server.tool(
+ "get_rental",
+ "Read a rental's current status and any messages received. Pass the ID you got from rent_number (ver_xxx for verifications, ren_xxx for long-term/dedicated). SMS codes typically arrive 10-60s after rent_number; poll this tool until status changes.",
+ { rental_id: z.string().describe("ver_xxx or ren_xxx") },
+ getRentalHandler(http),
);
server.tool(
- "get_messages",
- "Check for incoming SMS messages on a rented number.",
+ "rent_number",
+ "Rent a US non-VoIP phone number. kind='verification' (single SMS, 20min); kind='rental' (timed LTR with duration); kind='dedicated' (28-day all-services number). Quote-then-commit: the tool fetches the live price and ties max_price_cents to the quote so you never pay above what you saw.",
{
- rentalId: z.string().describe("Rental ID returned from rent_number"),
+ service_id: z.string().describe("svc_xxx from search_sms_services"),
+ kind: z.enum(["verification", "rental", "dedicated"]).default("verification"),
+ duration: z.enum(["3d", "7d", "14d", "30d"]).optional().describe("Required when kind='rental'"),
},
- async ({ rentalId }) => {
- const rental = state.smsRentals.get(rentalId);
-
- if (!rental) {
- return errorResponse(`Rental not found: ${rentalId}`);
- }
-
- if (rental.status === "active" && Date.now() >= rental.expiresAt) {
- rental.status = "expired";
- }
-
- if (rental.status === "expired") {
- return errorResponse(
- `Rental ${rentalId} has expired. Use reuse_number to reactivate a verification rental.`
- );
- }
-
- if (rental.status === "cancelled") {
- return errorResponse(`Rental ${rentalId} has been cancelled.`);
- }
-
- // Lazy mock: generate a message after 5 seconds
- if (rental.messages.length === 0) {
- const elapsed = Date.now() - rental.createdAt;
-
- if (elapsed < 5000) {
- const text = [
- `No messages yet for ${rental.phoneNumber} (${rental.serviceName}).`,
- ``,
- ` Waiting for SMS... try again shortly.`,
- ` Time since rental: ${Math.floor(elapsed / 1000)}s`,
- ].join("\n");
-
- return textResponse(text);
- }
-
- // For dedicated numbers, the message comes from a random service
- let msgService = rental.serviceName;
- if (rental.rentalType === "dedicated") {
- const randomSvc = smsServices[Math.floor(Math.random() * smsServices.length)];
- msgService = randomSvc.serviceName;
- }
-
- const code = generateVerificationCode();
- rental.messages.push({
- id: generateId("msg"),
- message_text: `Your ${msgService} verification code is: ${code}`,
- code,
- received_at: Date.now(),
- read_at: null,
- });
-
- if (rental.rentalType === "verification") {
- rental.status = "completed";
- }
- }
-
- let text = `Messages for ${rental.phoneNumber} (${rental.serviceName}):\n\n`;
-
- for (const msg of rental.messages) {
- const time = new Date(msg.received_at).toISOString().slice(11, 19);
- const readStatus = msg.read_at ? "read" : "new";
- text += ` [${time}] (${readStatus})\n`;
- text += ` ${msg.message_text}\n`;
- if (msg.code) {
- text += ` Code: ${msg.code}\n`;
- }
- text += `\n`;
-
- if (!msg.read_at) {
- msg.read_at = Date.now();
- }
- }
-
- text += `Total messages: ${rental.messages.length}`;
- if (rental.status === "completed") {
- text += `\nStatus: completed - use reuse_number to receive another SMS.`;
- }
-
- return textResponse(text);
- }
+ rentNumberHandler(http),
);
server.tool(
"cancel_rental",
- "Cancel an SMS rental. Full refund for verification with no messages, no refund for LTR/dedicated.",
- {
- rentalId: z.string().describe("Rental ID to cancel"),
- },
- async ({ rentalId }) => {
- const rental = state.smsRentals.get(rentalId);
-
- if (!rental) {
- return errorResponse(`Rental not found: ${rentalId}`);
- }
-
- if (rental.status === "cancelled") {
- return errorResponse(`Rental ${rentalId} is already cancelled.`);
- }
-
- if (rental.status === "expired") {
- return errorResponse(`Rental ${rentalId} has expired and cannot be cancelled.`);
- }
-
- rental.status = "cancelled";
-
- if (rental.rentalType === "verification" && rental.messages.length === 0) {
- state.addBalance(rental.priceCents, "refund", `Refund: ${rental.serviceName} verification`);
-
- const text = [
- `Rental ${rentalId} cancelled.`,
- ``,
- ` Refund: ${formatUsd(rental.priceCents)} (no messages received)`,
- ` New balance: ${formatUsd(state.balanceCents)}`,
- ].join("\n");
-
- return textResponse(text);
- }
-
- const reason = rental.rentalType === "verification"
- ? "messages were already received"
- : `${rental.rentalType} rentals are non-refundable`;
-
- const text = [
- `Rental ${rentalId} cancelled.`,
- ``,
- ` No refund - ${reason}.`,
- ].join("\n");
-
- return textResponse(text);
- }
+ "Cancel a rental. For verifications (ver_xxx) the API may refund if no message arrived. For long-term/dedicated rentals (ren_xxx) cancellation is typically non-refundable - check the response.",
+ { rental_id: z.string() },
+ cancelRentalHandler(http),
);
server.tool(
"reuse_number",
- "Reuse a completed or expired verification number to receive another SMS.",
+ "Reuse a completed/expired verification to receive another SMS. Free reuse is available when allow_reuse is true on the verification. Paid reuse ($0.50) is available when allow_paid_reuse is true.",
{
- rentalId: z.string().describe("Rental ID of a verification rental to reuse"),
- paid: z
- .boolean()
- .default(false)
- .describe("Use paid reuse ($0.50) for expired numbers"),
+ rental_id: z.string().describe("ver_xxx from a verification"),
+ paid: z.boolean().default(false),
},
- async ({ rentalId, paid }) => {
- const rental = state.smsRentals.get(rentalId);
-
- if (!rental) {
- return errorResponse(`Rental not found: ${rentalId}`);
- }
-
- if (rental.rentalType !== "verification") {
- return errorResponse(
- `Only verification rentals can be reused. This is a ${rental.rentalType} rental.`
- );
- }
-
- if (rental.status === "active") {
- return errorResponse(
- `Rental ${rentalId} is still active. Wait for it to complete or expire before reusing.`
- );
- }
-
- if (rental.status === "cancelled") {
- return errorResponse(`Rental ${rentalId} has been cancelled and cannot be reused.`);
- }
-
- if (!paid && rental.status === "expired") {
- return errorResponse(
- `Rental ${rentalId} has expired. Use paid reuse (paid: true) for ${formatUsd(REUSE_COST_CENTS)} to reactivate.`
- );
- }
-
- if (paid) {
- if (!state.deductBalance(REUSE_COST_CENTS, "sms_reuse", `SMS reuse: ${rental.serviceName}`)) {
- return errorResponse(
- `Insufficient balance. Need ${formatUsd(REUSE_COST_CENTS)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.`
- );
- }
- }
-
- const now = Date.now();
- rental.status = "active";
- rental.expiresAt = now + 20 * 60 * 1000;
- rental.messages = [];
- rental.reuseCounter += 1;
-
- const lines = [
- `Number reused!`,
- ``,
- ` Rental ID: ${rentalId}`,
- ` Number: ${rental.phoneNumber}`,
- ` Service: ${rental.serviceName}`,
- ` Reuse count: ${rental.reuseCounter}`,
- ` Cost: ${paid ? formatUsd(REUSE_COST_CENTS) : "Free"}`,
- ` Expires: ${formatTimeRemaining(rental.expiresAt)}`,
- ` Balance: ${formatUsd(state.balanceCents)}`,
- ``,
- `Use get_messages to check for incoming SMS.`,
- ];
+ reuseNumberHandler(http),
+ );
- return textResponse(lines.join("\n"));
- }
+ server.tool(
+ "re_rent_rental",
+ "Re-rent the same number for another period at the current price. Only works on an expired rental whose re_rent_available is true (the provider has not yet released the number). Re-uses the rental's original duration; no duration argument.",
+ {
+ rental_id: z.string().describe("ren_xxx from an expired LTR with re_rent_available=true"),
+ },
+ reRentRentalHandler(http),
);
server.tool(
"toggle_auto_renew",
- "Toggle auto-renewal for a long-term rental or dedicated number.",
+ "Turn auto-renewal on/off for an LTR or dedicated rental.",
{
- rentalId: z.string().describe("Rental ID of a long-term or dedicated rental"),
+ rental_id: z.string().describe("ren_xxx"),
+ auto_renew: z.boolean(),
},
- async ({ rentalId }) => {
- const rental = state.smsRentals.get(rentalId);
-
- if (!rental) {
- return errorResponse(`Rental not found: ${rentalId}`);
- }
-
- if (rental.rentalType === "verification") {
- return errorResponse(
- `Auto-renewal is not available for verification rentals. Only long-term and dedicated rentals support auto-renewal.`
- );
- }
-
- rental.autoRenew = !rental.autoRenew;
-
- const lines = [
- `Auto-renewal ${rental.autoRenew ? "enabled" : "disabled"}.`,
- ``,
- ` Rental ID: ${rentalId}`,
- ` Service: ${rental.serviceName}`,
- ` Type: ${rental.rentalType}${rental.duration ? ` (${rental.duration})` : ""}`,
- ` Auto-renew: ${rental.autoRenew ? "On" : "Off"}`,
- ` Paid until: ${rental.paidUntil ? new Date(rental.paidUntil).toISOString().slice(0, 16) : "N/A"}`,
- ` Renewal cost: ${formatUsd(rental.priceCents)}`,
- ];
-
- if (rental.autoRenew && state.balanceCents < rental.priceCents) {
- lines.push(``);
- lines.push(` WARNING: Balance (${formatUsd(state.balanceCents)}) is less than renewal cost (${formatUsd(rental.priceCents)}). Add funds before renewal date.`);
- }
-
- return textResponse(lines.join("\n"));
- }
+ toggleAutoRenewHandler(http),
);
}
diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts
deleted file mode 100644
index e3efc55..0000000
--- a/src/tools/wallet.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { z } from "zod";
-import { state } from "../sandbox/state.js";
-import { generateId } from "../utils/validation.js";
-import {
- formatUsd,
- generateMockWalletAddress,
- generateMockCryptoAmount,
-} from "../utils/format.js";
-import { textResponse } from "../utils/response.js";
-
-export function registerWalletTools(server: McpServer) {
- server.tool(
- "get_balance",
- "Get wallet balance.",
- {},
- async () => {
- state.resolvePendingDeposits();
-
- const pendingDeposits = [...state.deposits.values()].filter(
- (d) => d.status === "pending"
- );
-
- let text = `Balance: ${formatUsd(state.balanceCents)} (${state.balanceCents} cents)`;
-
- if (pendingDeposits.length > 0) {
- text += `\n\nPending deposits: ${pendingDeposits.length}`;
- for (const d of pendingDeposits) {
- text += `\n - $${d.amount.toFixed(2)} ${d.currency} (auto-confirms in ~5s)`;
- }
- }
-
- return textResponse(text);
- }
- );
-
- server.tool(
- "deposit",
- "Create a crypto deposit to add funds. Auto-confirms in ~5 seconds in sandbox.",
- {
- amount: z
- .number()
- .min(5)
- .max(10000)
- .describe("Amount in USD to deposit (min $5, max $10,000)"),
- currency: z
- .enum(["BTC", "ETH", "SOL", "LTC", "XMR", "TRX", "TON", "BNB", "USDT_TRX", "USDT_SOL", "USDC_SOL", "USDC_ETH"])
- .default("BTC")
- .describe("Cryptocurrency to pay with (default: BTC)"),
- },
- async ({ amount, currency }) => {
- const invoiceId = generateId("inv");
- const now = Date.now();
- const walletAddress = generateMockWalletAddress(currency);
- const qrCodeUrl = `https://sandbox.voidmob.com/pay/qr/${invoiceId}`;
- const cryptoAmount = generateMockCryptoAmount(amount, currency);
- const expiresAt = now + 30 * 60 * 1000;
-
- state.deposits.set(invoiceId, {
- invoiceId,
- amount,
- currency,
- walletAddress,
- qrCodeUrl,
- cryptoAmount,
- status: "pending",
- expiresAt,
- createdAt: now,
- });
-
- const text = [
- `Deposit created!`,
- ``,
- ` Deposit ID: ${invoiceId}`,
- ` Amount: $${amount.toFixed(2)} USD`,
- ` Crypto amount: ${cryptoAmount} ${currency}`,
- ` Wallet address: ${walletAddress}`,
- ` QR code: ${qrCodeUrl}`,
- ` Expires at: ${new Date(expiresAt).toISOString()}`,
- ``,
- `This is a sandbox deposit. It will auto-confirm in ~5 seconds.`,
- `Call get_balance after a few seconds to see the updated balance.`,
- ].join("\n");
-
- return textResponse(text);
- }
- );
-}
diff --git a/src/utils/format.ts b/src/utils/format.ts
index af311bc..8e69a34 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -14,11 +14,6 @@ export function formatData(gb: number | null, unlimited: boolean): string {
return formatGb(gb);
}
-export function formatMb(mb: number): string {
- if (mb >= 1024) return formatGb(mb / 1024);
- return `${mb.toFixed(0)} MB`;
-}
-
export function formatTimeRemaining(expiryMs: number): string {
const remaining = expiryMs - Date.now();
if (remaining <= 0) return "expired";
@@ -28,144 +23,3 @@ export function formatTimeRemaining(expiryMs: number): string {
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
-
-// ── Generators ──
-
-function rand(min: number, max: number): number {
- return Math.floor(Math.random() * (max - min + 1)) + min;
-}
-
-function randomHex(length: number): string {
- return Array.from({ length }, () => rand(0, 15).toString(16)).join("");
-}
-
-function randomAlphanumeric(length: number): string {
- const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
- return Array.from({ length }, () => chars[rand(0, chars.length - 1)]).join("");
-}
-
-export function generateDisplayId(prefix: string): string {
- return `${prefix}${randomAlphanumeric(7)}`;
-}
-
-export function generatePhoneNumber(): string {
- return `+1${rand(200, 999)}${rand(200, 999)}${rand(1000, 9999)}`;
-}
-
-export function generateVerificationCode(): string {
- return Math.floor(100000 + Math.random() * 900000).toString();
-}
-
-export function generateIp(): string {
- const o = () => rand(1, 254);
- return `${o()}.${o()}.${o()}.${o()}`;
-}
-
-export function generateProxyCredentials(country: string): {
- host: string; port: number; socksPort: number; username: string; password: string;
-} {
- const r = () => Math.random().toString(36).substring(2, 8);
- return {
- host: `${country.toLowerCase()}.proxy.voidmob.com`,
- port: 10000 + rand(0, 4999),
- socksPort: 20000 + rand(0, 4999),
- username: `vm_${r()}`,
- password: r() + r(),
- };
-}
-
-export function generateIccid(): string {
- return `8901${rand(1000, 9999)}${rand(10000000, 99999999)}${rand(0, 9)}`;
-}
-
-export function generateActivationCode(): string {
- return `LPA:1$smdp.voidmob.com$${randomHex(32).toUpperCase()}`;
-}
-
-export function generateMockWalletAddress(currency: string): string {
- switch (currency) {
- case "BTC": return `bc1q${randomHex(38)}`;
- case "ETH":
- case "BNB":
- case "USDC_ETH": return `0x${randomHex(40)}`;
- case "SOL":
- case "USDT_SOL":
- case "USDC_SOL": return randomAlphanumeric(44);
- case "LTC": return `ltc1q${randomHex(38)}`;
- case "XMR": return `4${randomAlphanumeric(94)}`;
- case "TRX":
- case "USDT_TRX": return `T${randomAlphanumeric(33)}`;
- case "TON": return `UQ${randomAlphanumeric(46)}`;
- default: return `0x${randomHex(40)}`;
- }
-}
-
-export function generateMockCryptoAmount(usdAmount: number, currency: string): string {
- const rates: Record = {
- BTC: 95000, ETH: 3800, SOL: 180, LTC: 90,
- XMR: 170, TRX: 0.25, TON: 6, BNB: 600,
- USDT_TRX: 1, USDT_SOL: 1, USDC_SOL: 1, USDC_ETH: 1,
- };
- const rate = rates[currency] ?? 1;
- return (usdAmount / rate).toFixed(rate >= 100 ? 8 : 4);
-}
-
-export function generateConnectionString(
- host: string, port: number, username: string, password: string
-): string {
- return `${username}:${password}@${host}:${port}`;
-}
-
-export function generateVlessUri(
- host: string, port: number, country: string
-): { uri: string; uuid: string } {
- const uuid = `${randomHex(8)}-${randomHex(4)}-4${randomHex(3)}-${randomHex(4)}-${randomHex(12)}`;
- const uri = `vless://${uuid}@${host}:${port}?security=tls&type=tcp#VoidMob-${country}`;
- return { uri, uuid };
-}
-
-export function generateOpenvpnConfig(
- host: string, username: string, password: string, country: string, carrier: string
-): { config: string; filename: string } {
- const filename = `voidmob-${country.toLowerCase()}-${carrier.toLowerCase().replace(/\s+/g, "-")}.ovpn`;
- const mockCert = btoa(`MOCK-CERTIFICATE-${randomHex(32)}`);
- const config = `# VoidMob Proxy - OpenVPN Configuration
-# ${country} / ${carrier}
-# Generated for sandbox mode
-
-client
-dev tun
-proto udp
-remote ${host} 1194
-resolv-retry infinite
-nobind
-persist-key
-persist-tun
-
-auth-user-pass
-# Username: ${username}
-# Password: ${password}
-
-cipher AES-256-GCM
-auth SHA256
-tls-client
-remote-cert-tls server
-verb 3
-
-
------BEGIN CERTIFICATE-----
-${mockCert}
------END CERTIFICATE-----
-
-
-
------BEGIN OpenVPN Static key V1-----
-${randomHex(64)}
-${randomHex(64)}
-${randomHex(64)}
-${randomHex(64)}
------END OpenVPN Static key V1-----
-
-key-direction 1`;
- return { config, filename };
-}
diff --git a/src/utils/render.ts b/src/utils/render.ts
new file mode 100644
index 0000000..dad9fa7
--- /dev/null
+++ b/src/utils/render.ts
@@ -0,0 +1,73 @@
+import { ZodError } from "zod";
+import { HttpError, NetworkError } from "../client/http.js";
+import { mapApiError } from "../client/errors.js";
+
+export interface ToolResult {
+ // SDK CallToolResult includes a string index signature; mirroring it here lets
+ // factory handlers be passed straight to server.tool() without a cast.
+ [x: string]: unknown;
+ content: Array<
+ | { type: "text"; text: string }
+ | { type: "image"; mimeType: string; data: string }
+ >;
+ structuredContent?: Record;
+ isError?: boolean;
+}
+
+export function structuredOk(text: string, structured: Record): ToolResult {
+ return {
+ content: [{ type: "text", text }],
+ structuredContent: structured,
+ };
+}
+
+export function structuredWithImage(
+ text: string,
+ structured: Record,
+ image: { mimeType: string; base64: string },
+): ToolResult {
+ return {
+ content: [
+ { type: "text", text },
+ { type: "image", mimeType: image.mimeType, data: image.base64 },
+ ],
+ structuredContent: structured,
+ };
+}
+
+export function toolError(message: string): ToolResult {
+ return { content: [{ type: "text", text: message }], isError: true };
+}
+
+/**
+ * Wrap a tool handler so error surfaces become clean, white-labeled tool
+ * results instead of opaque protocol crashes:
+ * - HttpError / NetworkError -> agent-readable text via mapApiError.
+ * - ZodError (response shape we can't parse) -> a generic message that leaks
+ * no schema internals. The detail is logged to stderr only. We deliberately
+ * do NOT tell the caller to blindly retry: a money operation may have
+ * succeeded server-side even though we couldn't parse its response, so the
+ * caller should verify before re-running.
+ * - Anything else still propagates.
+ */
+export function wrapToolErrors(
+ fn: (args: A) => Promise,
+): (args: A) => Promise {
+ return async (args: A) => {
+ try {
+ return await fn(args);
+ } catch (e) {
+ if (e instanceof HttpError || e instanceof NetworkError) {
+ return toolError(mapApiError(e));
+ }
+ if (e instanceof ZodError) {
+ process.stderr.write(`[voidmob-mcp] response schema mismatch: ${e.message}\n`);
+ return toolError(
+ "The API returned an unexpected response the client could not parse. " +
+ "The operation may have completed - check your account or use list_orders / a get_* tool to verify before retrying.",
+ );
+ }
+ throw e;
+ }
+ };
+}
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
deleted file mode 100644
index 17505fb..0000000
--- a/src/utils/validation.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// ISO 3166-1 alpha-2 country codes (subset of most common)
-const VALID_COUNTRIES = new Set([
- "US", "GB", "CA", "AU", "DE", "FR", "NL", "IT", "ES", "PT",
- "BR", "MX", "AR", "CO", "CL", "PE", "JP", "KR", "CN", "IN",
- "ID", "TH", "VN", "PH", "MY", "SG", "HK", "TW", "RU", "UA",
- "PL", "CZ", "RO", "SE", "NO", "DK", "FI", "AT", "CH", "BE",
- "IE", "NZ", "ZA", "EG", "NG", "KE", "IL", "TR", "SA", "AE",
-]);
-
-export function validateCountry(country: string): string {
- const upper = country.toUpperCase();
- if (!VALID_COUNTRIES.has(upper)) {
- throw new ToolError(
- `Invalid country code: ${country}. Use ISO 3166-1 alpha-2 (e.g., US, GB, JP).`
- );
- }
- return upper;
-}
-
-export function requireField(value: unknown, fieldName: string): void {
- if (value === undefined || value === null || value === "") {
- throw new ToolError(`Missing required field: ${fieldName}`);
- }
-}
-
-export class ToolError extends Error {
- constructor(message: string) {
- super(message);
- this.name = "ToolError";
- }
-}
-
-let counter = 0;
-export function generateId(prefix: string): string {
- counter++;
- const rand = Math.random().toString(36).substring(2, 8);
- return `${prefix}_${rand}${counter}`;
-}
diff --git a/tests/client/errors.test.ts b/tests/client/errors.test.ts
new file mode 100644
index 0000000..339d797
--- /dev/null
+++ b/tests/client/errors.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { mapApiError } from "../../src/client/errors.js";
+import { HttpError, NetworkError } from "../../src/client/http.js";
+
+describe("mapApiError", () => {
+ it("UNAUTHENTICATED → docs URL pointer", () => {
+ const err = new HttpError(401, "UNAUTHENTICATED", "req_x", undefined, "Authentication required.");
+ expect(mapApiError(err)).toContain("dashboard.voidmob.com/developers/api-keys");
+ });
+
+ it("INSUFFICIENT_BALANCE → wallet URL pointer", () => {
+ const err = new HttpError(402, "INSUFFICIENT_BALANCE", "req_x", undefined, "...");
+ expect(mapApiError(err)).toContain("dashboard.voidmob.com/wallet");
+ });
+
+ it("RATE_LIMITED → retry hint", () => {
+ const err = new HttpError(429, "RATE_LIMITED", "req_x", undefined, "...");
+ expect(mapApiError(err)).toMatch(/retry/i);
+ });
+
+ it("PRICE_OVER_CAP → quote/available delta", () => {
+ const err = new HttpError(409, "PRICE_OVER_CAP", "req_x", { max_price_cents: 35, available_price_cents: 42 }, "...");
+ expect(mapApiError(err)).toContain("$0.35");
+ expect(mapApiError(err)).toContain("$0.42");
+ });
+
+ it("unknown API code → falls through to API message", () => {
+ const err = new HttpError(500, "WEIRD_NEW_CODE", "req_x", undefined, "Pass-through msg.");
+ expect(mapApiError(err)).toContain("Pass-through msg.");
+ });
+
+ it("NetworkError → connection text", () => {
+ const err = new NetworkError(new Error("ECONNREFUSED"));
+ expect(mapApiError(err)).toMatch(/could not reach/i);
+ });
+
+ it("always includes request_id when present", () => {
+ const err = new HttpError(500, "INTERNAL_ERROR", "req_abc123", undefined, "...");
+ expect(mapApiError(err)).toContain("req_abc123");
+ });
+});
diff --git a/tests/client/http.test.ts b/tests/client/http.test.ts
new file mode 100644
index 0000000..f22e605
--- /dev/null
+++ b/tests/client/http.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { createHttpClient, HttpError, NetworkError } from "../../src/client/http.js";
+
+describe("createHttpClient", () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ function jsonResponse(status: number, body: unknown, headers: Record = {}) {
+ return {
+ status,
+ headers: new Headers({ "Content-Type": "application/json", ...headers }),
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+ }
+
+ it("attaches Authorization, User-Agent, JSON Content-Type", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true, data: { ok: true } }));
+ const c = createHttpClient({ apiKey: "vmk_live_test", baseUrl: "https://x", debug: false, userAgent: "voidmob-mcp/test" });
+ await c.request("GET", "/v1/me");
+ const [_url, init] = fetchMock.mock.calls[0];
+ expect(init.headers.get("Authorization")).toBe("Bearer vmk_live_test");
+ expect(init.headers.get("User-Agent")).toBe("voidmob-mcp/test");
+ expect(init.headers.get("Content-Type")).toBe("application/json");
+ });
+
+ it("adds Idempotency-Key when opts.idempotencyKey is set", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(201, { success: true, data: {} }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await c.request("POST", "/v1/verifications", { body: { a: 1 }, idempotencyKey: "abc-123" });
+ const [_url, init] = fetchMock.mock.calls[0];
+ expect(init.headers.get("Idempotency-Key")).toBe("abc-123");
+ });
+
+ it("returns parsed body on 2xx", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true, data: { id: 42 } }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ const res = await c.request("GET", "/v1/me");
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ success: true, data: { id: 42 } });
+ });
+
+ it("throws HttpError with code on 4xx envelope", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(401, {
+ success: false,
+ error: { code: "UNAUTHENTICATED", message: "Authentication required.", request_id: "req_x", docs_url: "https://docs/x" },
+ }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({
+ name: "HttpError",
+ status: 401,
+ code: "UNAUTHENTICATED",
+ requestId: "req_x",
+ });
+ });
+
+ it("retries GET 2x on 5xx then surfaces last error", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } }))
+ .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } }))
+ .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({ status: 500 });
+ expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 retries
+ });
+
+ it("does NOT retry POST", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toMatchObject({ status: 500 });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("does NOT retry 429", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(429, { success: false, error: { code: "RATE_LIMITED", message: "", request_id: "r", docs_url: "" } }, { "Retry-After": "30" }));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({ code: "RATE_LIMITED" });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("throws NetworkError on fetch failure", async () => {
+ fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toBeInstanceOf(NetworkError);
+ });
+
+ it("returns binary body when expectBinary=true", async () => {
+ const buf = new Uint8Array([1, 2, 3]);
+ fetchMock.mockResolvedValueOnce({
+ status: 200,
+ headers: new Headers({ "Content-Type": "image/png" }),
+ arrayBuffer: async () => buf.buffer,
+ });
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ const res = await c.request("GET", "/v1/esims/x/qr.png", { expectBinary: true });
+ expect(res.binary).toBeInstanceOf(Buffer);
+ expect((res.binary as Buffer).length).toBe(3);
+ });
+
+ it("wraps arrayBuffer errors as NetworkError on binary path", async () => {
+ const brokenBinaryResponse = () => ({
+ status: 200,
+ headers: new Headers({ "Content-Type": "image/png" }),
+ arrayBuffer: async () => { throw new TypeError("stream broken"); },
+ });
+ // GET retries NetworkError up to 3 attempts total - mock all of them
+ fetchMock
+ .mockResolvedValueOnce(brokenBinaryResponse())
+ .mockResolvedValueOnce(brokenBinaryResponse())
+ .mockResolvedValueOnce(brokenBinaryResponse());
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" });
+ await expect(c.request("GET", "/v1/esims/x/qr.png", { expectBinary: true })).rejects.toBeInstanceOf(NetworkError);
+ });
+
+ it("debug log does not crash on malformed error envelope shapes", async () => {
+ // {error: null} shape
+ fetchMock.mockResolvedValueOnce({
+ status: 500,
+ headers: new Headers({ "Content-Type": "application/json" }),
+ json: async () => ({ success: false, error: null }),
+ });
+ const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: true, userAgent: "ua" });
+ // Should throw HttpError, not blow up trying to read .code
+ await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toBeInstanceOf(Error);
+ });
+});
diff --git a/tests/client/idempotency.test.ts b/tests/client/idempotency.test.ts
new file mode 100644
index 0000000..294ae6f
--- /dev/null
+++ b/tests/client/idempotency.test.ts
@@ -0,0 +1,13 @@
+import { describe, it, expect } from "vitest";
+import { newIdempotencyKey } from "../../src/client/idempotency.js";
+
+describe("newIdempotencyKey", () => {
+ it("returns a UUIDv4-shaped string", () => {
+ const k = newIdempotencyKey();
+ expect(k).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ });
+
+ it("returns a different value each call", () => {
+ expect(newIdempotencyKey()).not.toBe(newIdempotencyKey());
+ });
+});
diff --git a/tests/config.test.ts b/tests/config.test.ts
new file mode 100644
index 0000000..32be4dd
--- /dev/null
+++ b/tests/config.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+import { parseEnv, ConfigError } from "../src/config.js";
+
+describe("parseEnv", () => {
+ it("sandbox mode when VOIDMOB_SANDBOX=1 (no key needed)", () => {
+ const cfg = parseEnv({ VOIDMOB_SANDBOX: "1" });
+ expect(cfg.sandbox).toBe(true);
+ expect(cfg.apiKey).toBeNull();
+ });
+
+ it("live mode when VOIDMOB_API_KEY is set", () => {
+ const cfg = parseEnv({ VOIDMOB_API_KEY: "vmk_live_" + "a".repeat(32) });
+ expect(cfg.sandbox).toBe(false);
+ expect(cfg.apiKey).toBe("vmk_live_" + "a".repeat(32));
+ expect(cfg.baseUrl).toBe("https://dashboard.voidmob.com/api");
+ });
+
+ it("accepts vmk_test_ keys", () => {
+ const cfg = parseEnv({ VOIDMOB_API_KEY: "vmk_test_" + "b".repeat(32) });
+ expect(cfg.sandbox).toBe(false);
+ });
+
+ it("throws ConfigError when no mode selected", () => {
+ expect(() => parseEnv({})).toThrow(ConfigError);
+ });
+
+ it("throws ConfigError on bad key prefix", () => {
+ expect(() => parseEnv({ VOIDMOB_API_KEY: "sk_test_abc" })).toThrow(ConfigError);
+ });
+
+ it("throws ConfigError on too-short key", () => {
+ expect(() => parseEnv({ VOIDMOB_API_KEY: "vmk_live_short" })).toThrow(ConfigError);
+ });
+
+ it("VOIDMOB_BASE_URL overrides default", () => {
+ const cfg = parseEnv({
+ VOIDMOB_API_KEY: "vmk_live_" + "a".repeat(32),
+ VOIDMOB_BASE_URL: "http://localhost:4000",
+ });
+ expect(cfg.baseUrl).toBe("http://localhost:4000");
+ });
+
+ it("VOIDMOB_DEBUG=1 enables debug", () => {
+ const cfg = parseEnv({ VOIDMOB_SANDBOX: "1", VOIDMOB_DEBUG: "1" });
+ expect(cfg.debug).toBe(true);
+ });
+
+ it("error message points the user to the docs URL", () => {
+ expect(() => parseEnv({})).toThrowError(/dashboard\.voidmob\.com/);
+ });
+
+ it("empty VOIDMOB_API_KEY in live mode is treated as missing", () => {
+ expect(() => parseEnv({ VOIDMOB_API_KEY: "" })).toThrow(ConfigError);
+ });
+});
diff --git a/tests/fixtures/tools-list-v1.json b/tests/fixtures/tools-list-v1.json
new file mode 100644
index 0000000..86606aa
--- /dev/null
+++ b/tests/fixtures/tools-list-v1.json
@@ -0,0 +1,29 @@
+{
+ "tools": [
+ "cancel_rental",
+ "create_proxy_list",
+ "delete_proxy_list",
+ "get_account",
+ "get_esim_qr",
+ "get_esim_status",
+ "get_geo",
+ "get_proxy_status",
+ "get_rental",
+ "list_orders",
+ "list_proxy_lists",
+ "purchase_esim",
+ "purchase_proxy",
+ "re_rent_rental",
+ "regenerate_proxy_password",
+ "renew_proxy",
+ "rent_number",
+ "reuse_number",
+ "rotate_proxy_ip",
+ "search_esim_plans",
+ "search_proxies",
+ "search_sms_services",
+ "toggle_auto_renew",
+ "topup_esim",
+ "topup_proxy"
+ ]
+}
diff --git a/tests/mock-http.ts b/tests/mock-http.ts
new file mode 100644
index 0000000..ddfdae1
--- /dev/null
+++ b/tests/mock-http.ts
@@ -0,0 +1,40 @@
+import type { HttpClient, HttpResponse } from "../src/client/http.js";
+
+interface Expectation {
+ method: string;
+ path: string;
+ respond: HttpResponse;
+}
+
+export interface MockHttpClient extends HttpClient {
+ expect(method: string, path: string, respond: HttpResponse): void;
+ history: Array<{ method: string; path: string; body?: unknown; headers: Record }>;
+}
+
+export function createMockHttpClient(): MockHttpClient {
+ const queue: Expectation[] = [];
+ const history: MockHttpClient["history"] = [];
+
+ const client: MockHttpClient = {
+ history,
+ expect(method, path, respond) {
+ queue.push({ method, path, respond });
+ },
+ async request(method, path, opts) {
+ const recordedHeaders: Record = {
+ ...((opts?.headers as Record) ?? {}),
+ };
+ if (opts?.idempotencyKey) {
+ recordedHeaders["Idempotency-Key"] = opts.idempotencyKey;
+ }
+ history.push({ method, path, body: opts?.body, headers: recordedHeaders });
+ const exp = queue.shift();
+ if (!exp) throw new Error(`No expectation for ${method} ${path}`);
+ if (exp.method !== method || exp.path !== path) {
+ throw new Error(`Expected ${exp.method} ${exp.path}, got ${method} ${path}`);
+ }
+ return exp.respond;
+ },
+ };
+ return client;
+}
diff --git a/tests/sandbox.e2e.test.ts b/tests/sandbox.e2e.test.ts
new file mode 100644
index 0000000..03f3eb8
--- /dev/null
+++ b/tests/sandbox.e2e.test.ts
@@ -0,0 +1,91 @@
+// Drives every sandbox tool against the in-memory mock client and asserts none
+// return an error result. An error here means the mock's response shape no
+// longer satisfies the live Zod schema / renderer - i.e. sandbox has drifted
+// from live. Timing-gated transitions (verification code arrival, proxy going
+// active) are exercised in their pre-transition state, which is still valid.
+import { describe, it, expect } from "vitest";
+import type { ToolResult } from "../src/utils/render.js";
+import { createSandboxHttpClient } from "../src/sandbox/mock-http.js";
+import { getAccountHandler } from "../src/tools/account.js";
+import {
+ searchSmsServicesHandler, getRentalHandler, rentNumberHandler,
+ cancelRentalHandler, reuseNumberHandler, reRentRentalHandler, toggleAutoRenewHandler,
+} from "../src/tools/sms.js";
+import {
+ searchEsimPlansHandler, purchaseEsimHandler, getEsimStatusHandler,
+ topupEsimHandler, getEsimQrHandler,
+} from "../src/tools/esim.js";
+import {
+ searchProxiesHandler, purchaseProxyHandler, getProxyStatusHandler, rotateProxyIpHandler,
+ renewProxyHandler, topupProxyHandler, regenerateProxyPasswordHandler,
+ listProxyListsHandler, createProxyListHandler, deleteProxyListHandler,
+} from "../src/tools/proxy.js";
+import { getGeoHandler } from "../src/tools/geo.js";
+import { listOrdersHandler } from "../src/tools/orders.js";
+
+const http = createSandboxHttpClient();
+const sc = (r: ToolResult) => r.structuredContent as Record;
+const okResult = (r: ToolResult) => {
+ expect(r.isError ?? false, (r.content[0] as { text?: string })?.text).toBe(false);
+ return r;
+};
+
+describe("sandbox e2e (every tool resolves against the mock)", () => {
+ it("account + catalogs + geo cascade", async () => {
+ okResult(await getAccountHandler(http)());
+ okResult(await searchSmsServicesHandler(http)({}));
+ okResult(await searchEsimPlansHandler(http)({ country: "US" }));
+ okResult(await searchProxiesHandler(http)({ country: "US" }));
+ okResult(await getGeoHandler(http)({}));
+ okResult(await getGeoHandler(http)({ country: "US" }));
+ okResult(await getGeoHandler(http)({ country: "US", region: "California" }));
+ okResult(await getGeoHandler(http)({ country: "US", region: "California", city: "Los Angeles" }));
+ });
+
+ it("SMS verification lifecycle", async () => {
+ const ver = okResult(await rentNumberHandler(http)({ service_id: "svc_telegram", kind: "verification" }));
+ const id = sc(ver).verification.id as string;
+ expect(id.startsWith("ver_")).toBe(true);
+ okResult(await getRentalHandler(http)({ rental_id: id }));
+ okResult(await reuseNumberHandler(http)({ rental_id: id }));
+ okResult(await reuseNumberHandler(http)({ rental_id: id, paid: true }));
+ });
+
+ it("SMS long-term + dedicated lifecycle", async () => {
+ const ren = okResult(await rentNumberHandler(http)({ service_id: "svc_telegram", kind: "rental", duration: "7d" }));
+ const id = sc(ren).rental.id as string;
+ expect(id.startsWith("ren_")).toBe(true);
+ okResult(await toggleAutoRenewHandler(http)({ rental_id: id, auto_renew: true }));
+ okResult(await getRentalHandler(http)({ rental_id: id }));
+ okResult(await reRentRentalHandler(http)({ rental_id: id }));
+ okResult(await cancelRentalHandler(http)({ rental_id: id }));
+ okResult(await rentNumberHandler(http)({ service_id: "svc_dedicated_28d", kind: "dedicated" }));
+ });
+
+ it("eSIM lifecycle", async () => {
+ const esim = okResult(await purchaseEsimHandler(http)({ plan_id: "prod_us_5gb_30d" }));
+ const id = sc(esim).esim.id as string;
+ okResult(await getEsimStatusHandler(http)({ esim_id: id }));
+ okResult(await getEsimQrHandler(http)({ esim_id: id }));
+ okResult(await topupEsimHandler(http)({ esim_id: id }));
+ okResult(await topupEsimHandler(http)({ esim_id: id, topup_product_id: "prod_topup_5gb" }));
+ });
+
+ it("proxy lifecycle + lists", async () => {
+ const prx = okResult(await purchaseProxyHandler(http)({ plan_id: "pplan_us_5gb_30d" }));
+ const id = sc(prx).proxy.id as string;
+ okResult(await getProxyStatusHandler(http)({ proxy_id: id }));
+ okResult(await rotateProxyIpHandler(http)({ proxy_id: id }));
+ okResult(await topupProxyHandler(http)({ proxy_id: id, additional_gb: 5 }));
+ okResult(await renewProxyHandler(http)({ proxy_id: id }));
+ okResult(await regenerateProxyPasswordHandler(http)({ proxy_id: id }));
+ const list = okResult(await createProxyListHandler(http)({ proxy_id: id, name: "la", country: "us", city: "Los Angeles" }));
+ const listId = sc(list).list.id as string;
+ okResult(await listProxyListsHandler(http)({ proxy_id: id }));
+ okResult(await deleteProxyListHandler(http)({ proxy_id: id, list_id: listId }));
+ });
+
+ it("list_orders aggregates across kinds", async () => {
+ okResult(await listOrdersHandler(http)({}));
+ });
+});
diff --git a/tests/sandbox.smoke.test.ts b/tests/sandbox.smoke.test.ts
new file mode 100644
index 0000000..f255078
--- /dev/null
+++ b/tests/sandbox.smoke.test.ts
@@ -0,0 +1,34 @@
+// tests/sandbox.smoke.test.ts
+import { describe, it, expect } from "vitest";
+import { buildSandboxServer } from "../src/modes/sandbox.js";
+import { buildLiveServer } from "../src/modes/live.js";
+import { readFileSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+function toolNames(server: ReturnType): string[] {
+ // @ts-expect-error - reach into internal map for the registered tool set
+ return Object.keys(server._registeredTools).sort();
+}
+
+const liveServer = () =>
+ buildLiveServer({ sandbox: false, apiKey: "vmk_live_" + "a".repeat(32), baseUrl: "https://x", debug: false });
+
+describe("smoke (live)", () => {
+ it("registers exactly 25 live tools matching the checked-in fixture", () => {
+ const names = toolNames(liveServer());
+ expect(names.length).toBe(25);
+
+ const fixturePath = join(__dirname, "fixtures/tools-list-v1.json");
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as { tools: string[] };
+ expect(names).toEqual([...fixture.tools].sort());
+ });
+});
+
+describe("smoke (sandbox)", () => {
+ it("exposes the EXACT same tool set as live (cannot drift)", () => {
+ expect(toolNames(buildSandboxServer())).toEqual(toolNames(liveServer()));
+ });
+});
diff --git a/tests/tools/account.test.ts b/tests/tools/account.test.ts
new file mode 100644
index 0000000..5ff276f
--- /dev/null
+++ b/tests/tools/account.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import { getAccountHandler } from "../../src/tools/account.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+describe("get_account", () => {
+ it("calls GET /v1/me and renders balance + rate limits", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/me", {
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ id: "usr_abc",
+ balance: { amount_cents: 1250, currency: "USD", formatted: "$12.50" },
+ rate_limits: {
+ account: { limit: 60, window_seconds: 60 },
+ verifications: { limit: 30, window_seconds: 60 },
+ },
+ created_at: "2026-01-01T00:00:00Z",
+ },
+ },
+ headers: new Headers(),
+ });
+
+ const handler = getAccountHandler(http);
+ const res = await handler();
+ expect(res.isError).toBeFalsy();
+ const textBlock = res.content[0];
+ if (textBlock.type !== "text") throw new Error("expected text block");
+ expect(textBlock.text).toContain("$12.50");
+ expect(res.structuredContent?.account).toMatchObject({ id: "usr_abc" });
+ });
+
+ it("maps UNAUTHENTICATED to docs URL", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/me", {
+ status: 401,
+ body: {
+ success: false,
+ error: {
+ code: "UNAUTHENTICATED",
+ message: "Authentication required.",
+ request_id: "req_x",
+ docs_url: "",
+ },
+ },
+ headers: new Headers(),
+ });
+ const handler = getAccountHandler(http);
+ const res = await handler();
+ expect(res.isError).toBe(true);
+ const textBlock = res.content[0];
+ if (textBlock.type !== "text") throw new Error("expected text block");
+ expect(textBlock.text).toContain("developers/api-keys");
+ expect(textBlock.text).toContain("req_x");
+ });
+});
diff --git a/tests/tools/esim.test.ts b/tests/tools/esim.test.ts
new file mode 100644
index 0000000..1c1e237
--- /dev/null
+++ b/tests/tools/esim.test.ts
@@ -0,0 +1,389 @@
+import { describe, it, expect } from "vitest";
+import {
+ searchEsimPlansHandler,
+ purchaseEsimHandler,
+ getEsimStatusHandler,
+ topupEsimHandler,
+ getEsimQrHandler,
+} from "../../src/tools/esim.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+// ── Fixture builders ────────────────────────────────────────────────────────
+
+function productFixture(overrides: Partial> = {}) {
+ return {
+ id: "esim_product_jp7d",
+ title: "Japan 5GB / 7 days",
+ countries: ["JP"],
+ region: "Asia",
+ country_count: 1,
+ routing_location: "JP",
+ data_limit_gb: 5,
+ data_unlimited: false,
+ validity_days: 7,
+ features: {
+ has_5g: true,
+ has_hotspot: true,
+ has_calls: false,
+ has_sms: false,
+ supports_topup: true,
+ },
+ price_cents: 999,
+ currency: "USD",
+ ...overrides,
+ };
+}
+
+function esimFixture(overrides: Partial> = {}) {
+ return {
+ id: "esim_abc",
+ status: "completed",
+ product_id: "esim_product_jp7d",
+ is_topup: false,
+ parent_order_id: null,
+ iccid: "8901123412345678901",
+ activation_code: "LPA:1$smdp.voidmob.com$ABC123",
+ qr_code_url: "https://dashboard.voidmob.com/api/v1/esims/esim_abc/qr.png",
+ smdp_address: "smdp.voidmob.com",
+ data_limit_gb: 5,
+ data_unlimited: false,
+ validity_days: 7,
+ countries: ["JP"],
+ routing_location: "JP",
+ charged_price_cents: 999,
+ currency: "USD",
+ created_at: "2026-05-21T00:00:00Z",
+ completed_at: "2026-05-21T00:00:00Z",
+ expires_at: "2026-05-28T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function usageFixture(overrides: Partial> = {}) {
+ return {
+ esim_id: "esim_abc",
+ esim_status: "completed",
+ packages: [
+ {
+ name: "Plan A",
+ total_mb: 5120,
+ total_gb: 5,
+ used_mb: 250,
+ used_gb: 0.24,
+ remaining_mb: 4870,
+ remaining_gb: 4.8,
+ percent_used: 4.9,
+ activation_date: "2026-05-21T00:00:00Z",
+ expiration_date: "2026-05-28T00:00:00Z",
+ },
+ ],
+ ...overrides,
+ };
+}
+
+// ── search_esim_plans ───────────────────────────────────────────────────────
+
+describe("search_esim_plans", () => {
+ it("composes query string with all filters and renders a list", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products?country=JP&min_data_gb=5&has_5g=true&limit=20", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ products: [productFixture()],
+ next_cursor: null,
+ },
+ },
+ });
+ const res = await searchEsimPlansHandler(http)({
+ country: "JP",
+ min_data_gb: 5,
+ has_5g: true,
+ });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("Japan 5GB / 7 days");
+ expect(t.text).toContain("JP");
+ expect(t.text).toContain("$9.99");
+ const plans = res.structuredContent?.esim_plans as Array>;
+ expect(plans).toHaveLength(1);
+ // Full plan shape returned — no separate get_esim_plan_details tool needed
+ expect(plans[0]).toMatchObject({
+ region: "Asia",
+ country_count: 1,
+ routing_location: "JP",
+ data_limit_gb: 5,
+ features: { has_5g: true, has_hotspot: true, supports_topup: true },
+ });
+ });
+
+ it("returns toolError when no plans match", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products?country=XX&limit=20", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { products: [], next_cursor: null } },
+ });
+ const res = await searchEsimPlansHandler(http)({ country: "XX" });
+ expect(res.isError).toBe(true);
+ });
+
+ it("surfaces upstream error with request_id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products?limit=20", {
+ status: 500,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: { code: "INTERNAL_ERROR", message: "boom", request_id: "req_esim_err", docs_url: "" },
+ },
+ });
+ const res = await searchEsimPlansHandler(http)({});
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("req_esim_err");
+ });
+});
+
+// ── purchase_esim ───────────────────────────────────────────────────────────
+
+describe("purchase_esim", () => {
+ it("quote-then-commit: GET product, POST /v1/esims with tied max_price_cents and idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products/esim_product_jp7d", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { product: productFixture() } },
+ });
+ http.expect("POST", "/v1/esims", {
+ status: 201,
+ headers: new Headers(),
+ body: { success: true, data: { esim: esimFixture() } },
+ });
+ const res = await purchaseEsimHandler(http)({ plan_id: "esim_product_jp7d" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(2);
+ expect(http.history[1].method).toBe("POST");
+ expect(http.history[1].body).toMatchObject({
+ product_id: "esim_product_jp7d",
+ max_price_cents: 999,
+ });
+ expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" });
+ });
+
+ it("maps PRICE_OVER_CAP from commit step with request_id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products/esim_product_jp7d", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { product: productFixture() } },
+ });
+ http.expect("POST", "/v1/esims", {
+ status: 409,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "PRICE_OVER_CAP",
+ message: "...",
+ request_id: "req_esim_cap",
+ details: { max_price_cents: 999, available_price_cents: 1099 },
+ docs_url: "",
+ },
+ },
+ });
+ const res = await purchaseEsimHandler(http)({ plan_id: "esim_product_jp7d" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("$9.99");
+ expect(t.text).toContain("$10.99");
+ expect(t.text).toContain("req_esim_cap");
+ });
+});
+
+// ── get_esim_status ─────────────────────────────────────────────────────────
+
+describe("get_esim_status", () => {
+ it("fetches core + usage in parallel and merges into structuredContent", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esims/esim_abc", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { esim: esimFixture() } },
+ });
+ http.expect("GET", "/v1/esims/esim_abc/usage", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { usage: usageFixture() } },
+ });
+ const res = await getEsimStatusHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBeFalsy();
+ expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" });
+ expect(res.structuredContent?.usage).toMatchObject({
+ esim_id: "esim_abc",
+ esim_status: "completed",
+ });
+ expect((res.structuredContent?.usage as { packages: unknown[] }).packages).toHaveLength(1);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("250 MB");
+ expect(t.text).toContain("5120 MB");
+ });
+
+ it("USAGE_UNAVAILABLE: degrades gracefully with usage=null", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esims/esim_abc", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { esim: esimFixture() } },
+ });
+ http.expect("GET", "/v1/esims/esim_abc/usage", {
+ status: 503,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: { code: "USAGE_UNAVAILABLE", message: "...", request_id: "req_u", docs_url: "" },
+ },
+ });
+ const res = await getEsimStatusHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBeFalsy();
+ expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" });
+ expect(res.structuredContent?.usage).toBeNull();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("not yet available");
+ });
+});
+
+// ── topup_esim ──────────────────────────────────────────────────────────────
+
+describe("topup_esim", () => {
+ it("browse: no topup_product_id → GET /v1/esims/:id/topups, renders list", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esims/esim_abc/topups", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ supports_topup: true,
+ topups: [
+ productFixture({ id: "esim_topup_jp_3gb", title: "Japan +3GB", price_cents: 599, data_limit_gb: 3 }),
+ productFixture({ id: "esim_topup_jp_10gb", title: "Japan +10GB", price_cents: 1499, data_limit_gb: 10 }),
+ ],
+ },
+ },
+ });
+ const res = await topupEsimHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(1);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("Japan +3GB");
+ expect(t.text).toContain("Japan +10GB");
+ expect((res.structuredContent?.topups as unknown[])).toHaveLength(2);
+ });
+
+ it("browse: supports_topup=false → toolError", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esims/esim_abc/topups", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { supports_topup: false, topups: [] } },
+ });
+ const res = await topupEsimHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBe(true);
+ });
+
+ it("purchase: topup_product_id supplied → quote then POST /v1/esims/:id/topups", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/esim_products/esim_topup_jp_3gb", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { product: productFixture({ id: "esim_topup_jp_3gb", title: "Japan +3GB", price_cents: 599, data_limit_gb: 3 }) },
+ },
+ });
+ http.expect("POST", "/v1/esims/esim_abc/topups", {
+ status: 201,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ esim: esimFixture({
+ id: "esim_topup_xyz",
+ product_id: "esim_topup_jp_3gb",
+ data_limit_gb: null,
+ validity_days: 7,
+ charged_price_cents: 599,
+ is_topup: true,
+ parent_order_id: "esim_abc",
+ iccid: null,
+ activation_code: null,
+ qr_code_url: null,
+ smdp_address: null,
+ }),
+ },
+ },
+ });
+ const res = await topupEsimHandler(http)({
+ esim_id: "esim_abc",
+ topup_product_id: "esim_topup_jp_3gb",
+ });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(2);
+ expect(http.history[1].method).toBe("POST");
+ expect(http.history[1].path).toBe("/v1/esims/esim_abc/topups");
+ expect(http.history[1].body).toMatchObject({
+ product_id: "esim_topup_jp_3gb",
+ max_price_cents: 599,
+ });
+ expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.esim).toMatchObject({ id: "esim_topup_xyz", is_topup: true });
+ });
+});
+
+// ── get_esim_qr ─────────────────────────────────────────────────────────────
+
+describe("get_esim_qr", () => {
+ it("returns content with both text + image blocks; structuredContent has esim_id", async () => {
+ const http = createMockHttpClient();
+ // Minimal PNG magic bytes
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ http.expect("GET", "/v1/esims/esim_abc/qr.png", {
+ status: 200,
+ headers: new Headers(),
+ binary: png,
+ });
+ const res = await getEsimQrHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBeFalsy();
+ expect(res.content).toHaveLength(2);
+ expect(res.content[0].type).toBe("text");
+ const img = res.content[1];
+ if (img.type !== "image") throw new Error("expected image block");
+ expect(img.mimeType).toBe("image/png");
+ expect(img.data).toBe(png.toString("base64"));
+ expect(res.structuredContent?.esim_id).toBe("esim_abc");
+ });
+
+ it("returns toolError when server returns no binary payload", async () => {
+ const http = createMockHttpClient();
+ // Edge case: 2xx with neither binary nor a parseable body (server bug)
+ http.expect("GET", "/v1/esims/esim_abc/qr.png", {
+ status: 200,
+ headers: new Headers(),
+ });
+ const res = await getEsimQrHandler(http)({ esim_id: "esim_abc" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toMatch(/no binary payload/i);
+ });
+});
diff --git a/tests/tools/geo.test.ts b/tests/tools/geo.test.ts
new file mode 100644
index 0000000..33dad3a
--- /dev/null
+++ b/tests/tools/geo.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect } from "vitest";
+import { getGeoHandler } from "../../src/tools/geo.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+// ── Fixture builders ────────────────────────────────────────────────────────
+
+function countryFixture(overrides: Partial> = {}) {
+ return { code: "US", name: "United States", available_nodes: 1234, ...overrides };
+}
+
+function regionFixture(overrides: Partial> = {}) {
+ return { name: "California", available_nodes: 250, ...overrides };
+}
+
+function cityFixture(overrides: Partial> = {}) {
+ return { name: "Los Angeles", available_nodes: 80, ...overrides };
+}
+
+function ispFixture(overrides: Partial> = {}) {
+ return { name: "Comcast", available_nodes: 12, ...overrides };
+}
+
+// ── get_geo ────────────────────────────────────────────────────────────────
+
+describe("get_geo", () => {
+ it("with no params hits /v1/geo and renders countries", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/geo", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { countries: [countryFixture(), countryFixture({ code: "GB", name: "United Kingdom", available_nodes: 500 })] },
+ },
+ });
+ const res = await getGeoHandler(http)({});
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("2 countries");
+ expect(t.text).toContain("United States");
+ expect(t.text).toContain("United Kingdom");
+ expect(t.text).toContain("(US)");
+ const countries = res.structuredContent?.countries as Array>;
+ expect(countries).toHaveLength(2);
+ expect(countries[0]).toMatchObject({ code: "US", name: "United States", available_nodes: 1234 });
+ });
+
+ it("with country=US hits /v1/geo?country=US and renders regions", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/geo?country=US", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { regions: [regionFixture()] },
+ },
+ });
+ const res = await getGeoHandler(http)({ country: "US" });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("1 regions");
+ expect(t.text).toContain("California");
+ const regions = res.structuredContent?.regions as Array>;
+ expect(regions).toHaveLength(1);
+ expect(regions[0]).toMatchObject({ name: "California", available_nodes: 250 });
+ });
+
+ it("with country + region hits cascading geo and renders cities", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/geo?country=US®ion=California", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { cities: [cityFixture(), cityFixture({ name: "San Francisco", available_nodes: 45 })] },
+ },
+ });
+ const res = await getGeoHandler(http)({ country: "US", region: "California" });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("2 cities");
+ expect(t.text).toContain("Los Angeles");
+ expect(t.text).toContain("San Francisco");
+ const cities = res.structuredContent?.cities as Array>;
+ expect(cities).toHaveLength(2);
+ });
+
+ it("with country + region + city hits cascading geo and renders ISPs", async () => {
+ const http = createMockHttpClient();
+ // URLSearchParams encodes spaces as '+'
+ http.expect("GET", "/v1/geo?country=US®ion=California&city=Los+Angeles", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { isps: [ispFixture(), ispFixture({ name: "AT&T", available_nodes: 7 })] },
+ },
+ });
+ const res = await getGeoHandler(http)({ country: "US", region: "California", city: "Los Angeles" });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("2 ISP(s)");
+ expect(t.text).toContain("Comcast");
+ expect(t.text).toContain("AT&T");
+ const isps = res.structuredContent?.isps as Array>;
+ expect(isps).toHaveLength(2);
+ expect(isps[0]).toMatchObject({ name: "Comcast", available_nodes: 12 });
+ });
+
+ it("propagates request_id on 4xx error", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/geo?country=ZZ", {
+ status: 400,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "INVALID_REQUEST",
+ message: "Unknown country code.",
+ request_id: "req_geo_4xx",
+ },
+ },
+ });
+ const res = await getGeoHandler(http)({ country: "ZZ" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("req_geo_4xx");
+ expect(t.text).toContain("Unknown country code");
+ });
+});
diff --git a/tests/tools/orders.test.ts b/tests/tools/orders.test.ts
new file mode 100644
index 0000000..8d81249
--- /dev/null
+++ b/tests/tools/orders.test.ts
@@ -0,0 +1,252 @@
+import { describe, it, expect } from "vitest";
+import { listOrdersHandler } from "../../src/tools/orders.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+// ── Fixture builders ────────────────────────────────────────────────────────
+
+function rentalFixture(overrides: Partial> = {}) {
+ return {
+ id: "ren_old",
+ display_id: "LTR123",
+ status: "active",
+ phone_number: "+14155550123",
+ service_id: "svc_tg",
+ service_name: "Telegram",
+ country: "us",
+ duration: "7D",
+ rental_type: "rental",
+ charged_price_cents: 500,
+ auto_renew: false,
+ next_renewal_price_cents: 500,
+ re_rent_available: false,
+ re_rent_price_cents: null,
+ re_rent_blocked_at: null,
+ created_at: "2026-05-01T00:00:00Z",
+ paid_until: "2026-05-28T00:00:00Z",
+ expires_at: "2026-05-28T00:00:00Z",
+ can_cancel: false,
+ cancel_window_expires_at: null,
+ messages: [],
+ ...overrides,
+ };
+}
+
+function esimFixture(overrides: Partial> = {}) {
+ return {
+ id: "esim_mid",
+ status: "completed",
+ product_id: "prod_jp7d",
+ is_topup: false,
+ parent_order_id: null,
+ iccid: "8901123412345678901",
+ activation_code: "LPA:1$smdp.voidmob.com$ABC123",
+ qr_code_url: "https://dashboard.voidmob.com/api/v1/esims/esim_mid/qr.png",
+ smdp_address: "smdp.voidmob.com",
+ data_limit_gb: 5,
+ data_unlimited: false,
+ validity_days: 7,
+ countries: ["JP"],
+ routing_location: "JP",
+ charged_price_cents: 999,
+ currency: "USD",
+ created_at: "2026-05-10T00:00:00Z",
+ completed_at: "2026-05-10T00:00:00Z",
+ expires_at: "2026-05-28T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function gatewayFixture() {
+ return {
+ host: "us.proxy.voidmob.com",
+ port: 10000,
+ protocol: "http",
+ username: "vm_abc",
+ password: "p4ss",
+ };
+}
+
+function proxyFixture(overrides: Partial> = {}) {
+ return {
+ id: "px_new",
+ status: "active",
+ plan_id: "proxy_plan_us_shared_5gb",
+ data_gb_total: 5,
+ data_bytes_used: 0,
+ charged_price_cents: 1499,
+ expires_at: "2026-06-20T00:00:00Z",
+ gateway: gatewayFixture(),
+ lists: [],
+ rotation_url: null,
+ created_at: "2026-05-20T00:00:00Z",
+ ...overrides,
+ };
+}
+
+// ── list_orders ─────────────────────────────────────────────────────────────
+
+describe("list_orders", () => {
+ it("with no kind filter fans out to all 3 endpoints, merges, sorts desc by created_at", async () => {
+ const http = createMockHttpClient();
+ // FIFO: handler enqueues sms → esim → proxy in order
+ http.expect("GET", "/v1/rentals", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: [rentalFixture()] },
+ });
+ http.expect("GET", "/v1/esims", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { esims: [esimFixture()] } },
+ });
+ http.expect("GET", "/v1/proxies", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { proxies: [proxyFixture()] } },
+ });
+
+ const res = await listOrdersHandler(http)({});
+ expect(res.isError).toBeFalsy();
+
+ // All three GETs occurred
+ expect(http.history.map((h) => h.path)).toEqual([
+ "/v1/rentals",
+ "/v1/esims",
+ "/v1/proxies",
+ ]);
+
+ const orders = res.structuredContent?.orders as Array>;
+ expect(orders).toHaveLength(3);
+ // Sorted by created_at desc: proxy(2026-05-20) > esim(2026-05-10) > rental(2026-05-01)
+ expect(orders[0]).toMatchObject({ kind: "proxy", id: "px_new" });
+ expect(orders[1]).toMatchObject({ kind: "esim", id: "esim_mid" });
+ expect(orders[2]).toMatchObject({ kind: "sms", id: "ren_old" });
+
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("3 order(s)");
+ expect(t.text).toContain("SMS");
+ expect(t.text).toContain("ESIM");
+ expect(t.text).toContain("PROXY");
+ });
+
+ it("with kind='sms' only fetches /v1/rentals", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/rentals", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: [rentalFixture()] },
+ });
+
+ const res = await listOrdersHandler(http)({ kind: "sms" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(1);
+ expect(http.history[0].path).toBe("/v1/rentals");
+ const orders = res.structuredContent?.orders as Array>;
+ expect(orders).toHaveLength(1);
+ expect(orders[0]).toMatchObject({ kind: "sms", id: "ren_old" });
+ });
+
+ it("surfaces results from successful fan-out branches when one fails, with partial warning", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/rentals", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: [rentalFixture()] },
+ });
+ http.expect("GET", "/v1/esims", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { esims: [esimFixture()] } },
+ });
+ // Proxies endpoint fails — callApi will throw on success:false envelope
+ http.expect("GET", "/v1/proxies", {
+ status: 500,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "INTERNAL_ERROR",
+ message: "Proxy service down.",
+ request_id: "req_px_500",
+ },
+ },
+ });
+
+ const res = await listOrdersHandler(http)({});
+ expect(res.isError).toBeFalsy();
+ const orders = res.structuredContent?.orders as Array>;
+ expect(orders).toHaveLength(2);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("partial:");
+ });
+
+ it("with limit=2 truncates output even when more rows exist", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/rentals", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: [
+ rentalFixture({ id: "ren_1", created_at: "2026-05-01T00:00:00Z" }),
+ rentalFixture({ id: "ren_2", created_at: "2026-05-02T00:00:00Z" }),
+ rentalFixture({ id: "ren_3", created_at: "2026-05-03T00:00:00Z" }),
+ rentalFixture({ id: "ren_4", created_at: "2026-05-04T00:00:00Z" }),
+ ],
+ },
+ });
+
+ const res = await listOrdersHandler(http)({ kind: "sms", limit: 2 });
+ expect(res.isError).toBeFalsy();
+ const orders = res.structuredContent?.orders as Array>;
+ expect(orders).toHaveLength(2);
+ // Newest first
+ expect(orders[0]).toMatchObject({ id: "ren_4" });
+ expect(orders[1]).toMatchObject({ id: "ren_3" });
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("4 order(s)");
+ expect(t.text).toContain("(showing 2)");
+ });
+
+ it("surfaces partial-failure warnings (not a misleading 'No orders found.') when all 3 branches fail", async () => {
+ const http = createMockHttpClient();
+ const fail = {
+ status: 500,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: { code: "INTERNAL_ERROR", message: "boom", request_id: "req_x" },
+ },
+ };
+ http.expect("GET", "/v1/rentals", fail);
+ http.expect("GET", "/v1/esims", fail);
+ http.expect("GET", "/v1/proxies", fail);
+
+ const res = await listOrdersHandler(http)({});
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ // When every fan-out call fails, the empty result is a hidden error, not an
+ // empty account: surface the partial failures instead of "No orders found."
+ expect(t.text).toContain("Could not load orders");
+ expect(t.text).toContain("partial:");
+ expect(t.text).not.toContain("No orders found.");
+ });
+
+ it("returns 'No orders found.' only when the account is genuinely empty (no warnings)", async () => {
+ const http = createMockHttpClient();
+ const empty = { status: 200, headers: new Headers(), body: { success: true, data: [] as unknown[] } };
+ http.expect("GET", "/v1/rentals", empty);
+ http.expect("GET", "/v1/esims", { status: 200, headers: new Headers(), body: { success: true, data: { esims: [] } } });
+ http.expect("GET", "/v1/proxies", { status: 200, headers: new Headers(), body: { success: true, data: { proxies: [] } } });
+
+ const res = await listOrdersHandler(http)({});
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("No orders found.");
+ });
+});
diff --git a/tests/tools/proxy.test.ts b/tests/tools/proxy.test.ts
new file mode 100644
index 0000000..b05b69d
--- /dev/null
+++ b/tests/tools/proxy.test.ts
@@ -0,0 +1,594 @@
+import { describe, it, expect } from "vitest";
+import {
+ searchProxiesHandler,
+ purchaseProxyHandler,
+ getProxyStatusHandler,
+ rotateProxyIpHandler,
+ renewProxyHandler,
+ topupProxyHandler,
+ regenerateProxyPasswordHandler,
+ listProxyListsHandler,
+ createProxyListHandler,
+ deleteProxyListHandler,
+} from "../../src/tools/proxy.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+// ── Fixture builders ────────────────────────────────────────────────────────
+
+function planFixture(overrides: Partial> = {}) {
+ return {
+ id: "proxy_plan_us_shared_5gb",
+ name: "US Shared 5GB / 30d",
+ type: "shared",
+ country: "US",
+ data_gb: 5,
+ duration_days: 30,
+ quoted_price_cents: 1499,
+ ...overrides,
+ };
+}
+
+function gatewayFixture(overrides: Partial> = {}) {
+ return {
+ host: "us.proxy.voidmob.com",
+ port: 10000,
+ protocol: "http",
+ username: "vm_abc123",
+ password: "p4ssw0rd",
+ ...overrides,
+ };
+}
+
+function proxyResp(id: string, overrides: Partial> = {}) {
+ return {
+ id,
+ status: "active",
+ plan_id: "proxy_plan_us_shared_5gb",
+ type: "shared",
+ country: "US",
+ data_gb_total: 5,
+ data_bytes_used: 0,
+ charged_price_cents: 1499,
+ expires_at: "2026-06-20T00:00:00Z",
+ gateway: gatewayFixture(),
+ lists: [],
+ created_at: "2026-05-21T00:00:00Z",
+ ...overrides,
+ };
+}
+
+// ── search_proxies ──────────────────────────────────────────────────────────
+
+describe("search_proxies", () => {
+ it("composes query string with all filters and renders a list", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxy_plans?country=US&min_gb=5", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { plans: [planFixture()] },
+ },
+ });
+ const res = await searchProxiesHandler(http)({
+ country: "US",
+ min_data_gb: 5,
+ });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("US Shared 5GB / 30d");
+ expect(t.text).toContain("$14.99");
+ expect(t.text).toContain("US");
+ const plans = res.structuredContent?.proxy_plans as Array>;
+ expect(plans).toHaveLength(1);
+ expect(plans[0]).toMatchObject({
+ id: "proxy_plan_us_shared_5gb",
+ type: "shared",
+ country: "US",
+ quoted_price_cents: 1499,
+ });
+ });
+
+ it("returns toolError when no plans match", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxy_plans?country=ZZ", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { plans: [] } },
+ });
+ const res = await searchProxiesHandler(http)({ country: "ZZ" });
+ expect(res.isError).toBe(true);
+ });
+
+ it("surfaces upstream error with request_id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxy_plans", {
+ status: 500,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "INTERNAL_ERROR",
+ message: "boom",
+ request_id: "req_proxy_err",
+ docs_url: "",
+ },
+ },
+ });
+ const res = await searchProxiesHandler(http)({});
+ expect(res.isError).toBe(true);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("req_proxy_err");
+ });
+});
+
+// ── purchase_proxy ──────────────────────────────────────────────────────────
+
+describe("purchase_proxy", () => {
+ it("quote-then-commit: GET plans, find plan, POST /v1/proxies with tied max_price_cents and idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxy_plans", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { plans: [planFixture()] },
+ },
+ });
+ http.expect("POST", "/v1/proxies", {
+ status: 202,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ proxy: proxyResp("proxy_xyz", { status: "provisioning", gateway: null }),
+ },
+ },
+ });
+ const res = await purchaseProxyHandler(http)({ plan_id: "proxy_plan_us_shared_5gb" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(2);
+ expect(http.history[1].method).toBe("POST");
+ expect(http.history[1].path).toBe("/v1/proxies");
+ expect(http.history[1].body).toMatchObject({
+ plan_id: "proxy_plan_us_shared_5gb",
+ max_price_cents: 1499,
+ });
+ expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.proxy).toMatchObject({
+ id: "proxy_xyz",
+ status: "provisioning",
+ });
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("provisioning");
+ expect(t.text).toContain("get_proxy_status");
+ });
+
+ it("plan not found → toolError without commit attempt", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxy_plans", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { plans: [planFixture()] },
+ },
+ });
+ const res = await purchaseProxyHandler(http)({ plan_id: "proxy_plan_does_not_exist" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(1); // No POST attempt
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("proxy_plan_does_not_exist");
+ expect(t.text).toContain("search_proxies");
+ });
+});
+
+// ── get_proxy_status ────────────────────────────────────────────────────────
+
+describe("get_proxy_status", () => {
+ it("happy path: 3 parallel calls (proxy GET, usage GET, nolist POST), merged response", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/proxy_xyz", {
+ status: 200,
+ headers: new Headers(),
+ // Core GET has gateway null until nolist provisions it.
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_xyz", { data_bytes_used: 1073741824, gateway: null }) },
+ },
+ });
+ http.expect("GET", "/v1/proxies/proxy_xyz/usage", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { usage: { daily_bytes: 0, weekly_bytes: 0, monthly_bytes: 0, total_bytes: 1073741824, total_gb_allocated: 5, remaining_bytes: 4 } },
+ },
+ });
+ // nolist is an idempotent get-or-create POST that returns the proxy with
+ // gateway populated.
+ http.expect("POST", "/v1/proxies/proxy_xyz/nolist_credentials", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_xyz", { data_bytes_used: 1073741824 }) },
+ },
+ });
+ const res = await getProxyStatusHandler(http)({ proxy_id: "proxy_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" });
+ expect(res.structuredContent?.usage).toMatchObject({ total_bytes: 1073741824 });
+ expect(res.structuredContent?.nolist_credentials).toMatchObject({ username: "vm_abc123" });
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("proxy_xyz");
+ expect(t.text).toContain("us.proxy.voidmob.com");
+ expect(t.text).toContain("p4ssw0rd");
+ });
+
+ it("partial failure: usage and nolist return 503 → still succeeds with those fields null", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/proxy_xyz", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_xyz", { gateway: null, status: "provisioning" }) },
+ },
+ });
+ http.expect("GET", "/v1/proxies/proxy_xyz/usage", {
+ status: 503,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "USAGE_UNAVAILABLE",
+ message: "usage not ready",
+ request_id: "req_usage_503",
+ docs_url: "",
+ },
+ },
+ });
+ http.expect("POST", "/v1/proxies/proxy_xyz/nolist_credentials", {
+ status: 409,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: {
+ code: "PROXY_NOT_READY",
+ message: "not provisioned yet",
+ request_id: "req_nolist_409",
+ docs_url: "",
+ },
+ },
+ });
+ const res = await getProxyStatusHandler(http)({ proxy_id: "proxy_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" });
+ expect(res.structuredContent?.usage).toBeNull();
+ // gateway is null on the core GET when nolist isn't provisioned yet.
+ expect(res.structuredContent?.nolist_credentials).toBeNull();
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("not yet provisioned");
+ });
+});
+
+// ── rotate_proxy_ip ─────────────────────────────────────────────────────────
+
+describe("rotate_proxy_ip", () => {
+ it("happy path with idempotency, surfaces proxy_id/rotated_at/current_ip", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/proxies/PRX-abc/rotate_ip", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ proxy_id: "PRX-abc",
+ rotated_at: "2026-01-01T00:00:00Z",
+ current_ip: "1.2.3.4",
+ },
+ },
+ });
+ const res = await rotateProxyIpHandler(http)({ proxy_id: "PRX-abc" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(1);
+ expect(http.history[0].method).toBe("POST");
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("PRX-abc");
+ expect(t.text).toContain("2026-01-01T00:00:00Z");
+ expect(t.text).toContain("1.2.3.4");
+ expect(res.structuredContent?.proxy_id).toBe("PRX-abc");
+ expect(res.structuredContent?.rotated_at).toBe("2026-01-01T00:00:00Z");
+ expect(res.structuredContent?.current_ip).toBe("1.2.3.4");
+ });
+});
+
+// ── renew_proxy ─────────────────────────────────────────────────────────────
+
+describe("renew_proxy", () => {
+ it("happy path: fetch core, fetch plans, POST renew with tied max_price_cents + idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/proxy_xyz", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_xyz") },
+ },
+ });
+ http.expect("GET", "/v1/proxy_plans", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { plans: [planFixture()] },
+ },
+ });
+ http.expect("POST", "/v1/proxies/proxy_xyz/renew", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ proxy: proxyResp("proxy_xyz", { expires_at: "2026-07-20T00:00:00Z" }),
+ },
+ },
+ });
+ const res = await renewProxyHandler(http)({ proxy_id: "proxy_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(3);
+ expect(http.history[2].method).toBe("POST");
+ expect(http.history[2].path).toBe("/v1/proxies/proxy_xyz/renew");
+ expect(http.history[2].body).toMatchObject({ max_price_cents: 1499 });
+ expect(http.history[2].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.proxy).toMatchObject({
+ id: "proxy_xyz",
+ expires_at: "2026-07-20T00:00:00Z",
+ });
+ });
+
+ it("proxy has null plan_id → toolError, no plan fetch or POST", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/proxy_legacy", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_legacy", { plan_id: null }) },
+ },
+ });
+ const res = await renewProxyHandler(http)({ proxy_id: "proxy_legacy" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(1); // Stopped before plans + renew
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("proxy_legacy");
+ expect(t.text).toContain("no plan_id");
+ });
+});
+
+// ── topup_proxy ─────────────────────────────────────────────────────────────
+
+describe("topup_proxy", () => {
+ it("quote-then-commit: GET proxy + plans, POST topup with additional_gb + tied max_price_cents + idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/proxy_xyz", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { proxy: proxyResp("proxy_xyz") },
+ },
+ });
+ http.expect("GET", "/v1/proxy_plans", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: { plans: [planFixture()] },
+ },
+ });
+ http.expect("POST", "/v1/proxies/proxy_xyz/topup", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ proxy: proxyResp("proxy_xyz", { data_gb_total: 10 }),
+ charged_price_cents: 1499,
+ },
+ },
+ });
+ const res = await topupProxyHandler(http)({ proxy_id: "proxy_xyz", additional_gb: 5 });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(3);
+ expect(http.history[2].method).toBe("POST");
+ expect(http.history[2].path).toBe("/v1/proxies/proxy_xyz/topup");
+ // Plan: quoted=1499c for 5GB → perGb=299.8 → 5GB topup = round(1499) = 1499
+ expect(http.history[2].body).toMatchObject({ additional_gb: 5, max_price_cents: 1499 });
+ expect(http.history[2].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("5 GB");
+ expect(t.text).toContain("$14.99");
+ expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz", data_gb_total: 10 });
+ });
+});
+
+// ── regenerate_proxy_password ───────────────────────────────────────────────
+
+describe("regenerate_proxy_password", () => {
+ it("happy path + new password surfaced in text", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/proxies/proxy_xyz/regenerate_password", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ proxy: proxyResp("proxy_xyz", {
+ gateway: gatewayFixture({ password: "n3wP4ssw0rd" }),
+ }),
+ },
+ },
+ });
+ const res = await regenerateProxyPasswordHandler(http)({ proxy_id: "proxy_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(1);
+ expect(http.history[0].method).toBe("POST");
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ const t = res.content[0];
+ if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("n3wP4ssw0rd");
+ expect(t.text).toContain("proxy_xyz");
+ expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" });
+ });
+});
+
+// ── list_proxy_lists ────────────────────────────────────────────────────────
+
+describe("list_proxy_lists", () => {
+ it("reads lists from GET /v1/proxies/:id (no list collection endpoint)", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/prx_abc", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { proxy: proxyResp("prx_abc", { lists: [{
+ id: "lst_1", proxy_id: "prx_abc", name: "Default",
+ country: null, countries: ["us", "ca"], region: null, city: null, isp: null, zip: null,
+ rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port",
+ credentials: { host: "proxy.voidmob.com", port: 10000, protocol: "http", username: "u", password: "p" },
+ entries: ["u:p@proxy.voidmob.com:10000"], activation_note: "List active within 1-2 minutes.",
+ created_at: "2026-05-01T00:00:00Z",
+ }] }) } },
+ });
+ const res = await listProxyListsHandler(http)({ proxy_id: "prx_abc" });
+ expect(res.isError).toBeFalsy();
+ expect(res.structuredContent?.lists).toHaveLength(1);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("per-request");
+ expect(t.text).toContain("us,ca");
+ });
+
+ it("empty lists → toolError", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/prx_abc", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { proxy: proxyResp("prx_abc", { lists: [] }) } },
+ });
+ const res = await listProxyListsHandler(http)({ proxy_id: "prx_abc" });
+ expect(res.isError).toBe(true);
+ });
+
+ it("propagates request_id on PROXY_NOT_FOUND", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/proxies/prx_missing", {
+ status: 404, headers: new Headers(),
+ body: { success: false, error: { code: "PROXY_NOT_FOUND", message: "Proxy not found.", request_id: "req_listmissing", docs_url: "" } },
+ });
+ const res = await listProxyListsHandler(http)({ proxy_id: "prx_missing" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("req_listmissing");
+ });
+});
+
+// ── create_proxy_list ───────────────────────────────────────────────────────
+
+describe("create_proxy_list", () => {
+ function listFixture(id: string, overrides: Record = {}) {
+ return {
+ id, proxy_id: "prx_abc", name: "Test",
+ country: null, countries: null, region: null, city: null, isp: null, zip: null,
+ rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port",
+ credentials: { host: "proxy.voidmob.com", port: 10000, protocol: "http", username: "u", password: "p" },
+ entries: ["u:p@proxy.voidmob.com:10000"], activation_note: "List active within 1-2 minutes.",
+ created_at: "2026-05-01T00:00:00Z",
+ ...overrides,
+ };
+ }
+
+ it("single country → POST /v1/proxies/:id/lists with country + defaults + idempotency key", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/proxies/prx_abc/lists", {
+ status: 201, headers: new Headers(),
+ body: { success: true, data: { list: listFixture("lst_new", { country: "us" }) } },
+ });
+ const res = await createProxyListHandler(http)({
+ proxy_id: "prx_abc",
+ name: "Test",
+ country: "us",
+ });
+ expect(res.isError).toBeFalsy();
+ expect(http.history[0].body).toMatchObject({
+ name: "Test", country: "us", rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port",
+ });
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("u:p@proxy.voidmob.com:10000");
+ });
+
+ it("neither country nor countries → toolError (no HTTP call)", async () => {
+ const http = createMockHttpClient();
+ const res = await createProxyListHandler(http)({ proxy_id: "prx_abc", name: "Test" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("country AND countries together → toolError (no HTTP call)", async () => {
+ const http = createMockHttpClient();
+ const res = await createProxyListHandler(http)({
+ proxy_id: "prx_abc", name: "Test", country: "us", countries: ["us", "gb"],
+ });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("countries with a subfilter → toolError (no HTTP call)", async () => {
+ const http = createMockHttpClient();
+ const res = await createProxyListHandler(http)({
+ proxy_id: "prx_abc", name: "Test", countries: ["us", "gb"], region: "California",
+ });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("countries (multi) → POST with countries array, no subfilters", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/proxies/prx_abc/lists", {
+ status: 201, headers: new Headers(),
+ body: { success: true, data: { list: listFixture("lst_new", { countries: ["us", "gb"] }) } },
+ });
+ const res = await createProxyListHandler(http)({
+ proxy_id: "prx_abc",
+ name: "Test",
+ countries: ["us", "gb"],
+ });
+ expect(res.isError).toBeFalsy();
+ expect(http.history[0].body).toMatchObject({ countries: ["us", "gb"] });
+ expect(http.history[0].body).not.toHaveProperty("country");
+ });
+});
+
+// ── delete_proxy_list ───────────────────────────────────────────────────────
+
+describe("delete_proxy_list", () => {
+ it("DELETE /v1/proxies/:id/lists/:lid with idempotency key", async () => {
+ const http = createMockHttpClient();
+ http.expect("DELETE", "/v1/proxies/prx_abc/lists/lst_xyz", {
+ status: 204, headers: new Headers(),
+ body: { success: true, data: null },
+ });
+ const res = await deleteProxyListHandler(http)({ proxy_id: "prx_abc", list_id: "lst_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ });
+});
diff --git a/tests/tools/sms.test.ts b/tests/tools/sms.test.ts
new file mode 100644
index 0000000..7059a92
--- /dev/null
+++ b/tests/tools/sms.test.ts
@@ -0,0 +1,373 @@
+import { describe, it, expect } from "vitest";
+import {
+ searchSmsServicesHandler,
+ getRentalHandler,
+ rentNumberHandler,
+ cancelRentalHandler,
+ reuseNumberHandler,
+ reRentRentalHandler,
+ toggleAutoRenewHandler,
+} from "../../src/tools/sms.js";
+import { createMockHttpClient } from "../mock-http.js";
+
+describe("search_sms_services", () => {
+ it("calls GET /v1/services and renders a table", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ services: [
+ { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 },
+ { id: "svc_wa", name: "WhatsApp", quoted_price_cents: 42 },
+ ],
+ },
+ },
+ });
+ const handler = searchSmsServicesHandler(http);
+ const res = await handler({});
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("Telegram");
+ expect(t.text).toContain("$0.35");
+ expect(res.structuredContent?.services).toHaveLength(2);
+ });
+
+ it("filters by query substring on service name", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200,
+ headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ services: [
+ { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 },
+ { id: "svc_wa", name: "WhatsApp", quoted_price_cents: 42 },
+ ],
+ },
+ },
+ });
+ const handler = searchSmsServicesHandler(http);
+ const res = await handler({ query: "tele" });
+ expect(res.structuredContent?.services).toHaveLength(1);
+ });
+});
+
+describe("get_rental", () => {
+ function verFixture() {
+ return {
+ id: "ver_abc",
+ status: "waiting_for_code",
+ phone_number: "+14155550123",
+ service_id: "svc_tg",
+ service_name: "Telegram",
+ charged_price_cents: 35,
+ expires_at: "2026-05-21T19:00:00Z",
+ can_cancel: true,
+ created_at: "2026-05-21T18:40:00Z",
+ reuse_counter: 0,
+ allow_reuse: false,
+ allow_paid_reuse: false,
+ paid_reuse_price_cents: 50,
+ messages: [],
+ };
+ }
+ function rntFixture() {
+ return {
+ id: "ren_xyz",
+ display_id: "LTR456",
+ status: "active",
+ phone_number: "+14155550123",
+ service_id: "svc_tg",
+ service_name: "Telegram",
+ country: "us",
+ duration: "7D",
+ rental_type: "rental",
+ charged_price_cents: 500,
+ auto_renew: false,
+ next_renewal_price_cents: 500,
+ re_rent_available: false,
+ re_rent_price_cents: null,
+ re_rent_blocked_at: null,
+ created_at: "2026-05-21T00:00:00Z",
+ paid_until: "2026-05-28T00:00:00Z",
+ expires_at: "2026-05-28T00:00:00Z",
+ can_cancel: true,
+ cancel_window_expires_at: "2026-05-21T01:00:00Z",
+ messages: [],
+ };
+ }
+
+ it("routes ver_ ID to /v1/verifications/:id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/verifications/ver_abc", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: { verification: verFixture() } },
+ });
+ const res = await getRentalHandler(http)({ rental_id: "ver_abc" });
+ expect(res.structuredContent?.verification).toMatchObject({ id: "ver_abc" });
+ });
+
+ it("routes ren_ ID to /v1/rentals/:id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/rentals/ren_xyz", {
+ status: 200,
+ headers: new Headers(),
+ body: { success: true, data: rntFixture() },
+ });
+ const res = await getRentalHandler(http)({ rental_id: "ren_xyz" });
+ expect(res.structuredContent?.rental).toMatchObject({ id: "ren_xyz" });
+ });
+
+ it("rejects bad ID prefix without an HTTP call", async () => {
+ const http = createMockHttpClient();
+ const res = await getRentalHandler(http)({ rental_id: "bad_id" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toMatch(/ver_|ren_/);
+ expect(http.history).toHaveLength(0); // no HTTP call made
+ });
+
+ it("maps VERIFICATION_NOT_FOUND error with request_id end-to-end", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/verifications/ver_missing", {
+ status: 404,
+ headers: new Headers(),
+ body: {
+ success: false,
+ error: { code: "VERIFICATION_NOT_FOUND", message: "Verification not found.", request_id: "req_xyz", docs_url: "" },
+ },
+ });
+ const res = await getRentalHandler(http)({ rental_id: "ver_missing" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("req_xyz"); // request_id flowed end-to-end
+ });
+});
+
+describe("rent_number (verification path)", () => {
+ it("quotes via /v1/services then commits with strict-tie max_price_cents + idempotency key", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } },
+ });
+ http.expect("POST", "/v1/verifications", {
+ status: 201, headers: new Headers(),
+ body: {
+ success: true,
+ data: {
+ verification: {
+ id: "ver_new", status: "waiting_for_code", phone_number: "+14155550123",
+ service_id: "svc_tg", service_name: "Telegram", charged_price_cents: 35,
+ expires_at: "2026-05-21T19:00:00Z", can_cancel: true,
+ created_at: "2026-05-21T18:40:00Z", reuse_counter: 0,
+ allow_reuse: false, allow_paid_reuse: false, paid_reuse_price_cents: 50,
+ messages: [],
+ },
+ },
+ },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history).toHaveLength(2);
+ expect(http.history[1].method).toBe("POST");
+ expect(http.history[1].body).toMatchObject({ service_id: "svc_tg", max_price_cents: 35 });
+ expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.verification).toMatchObject({ id: "ver_new" });
+ });
+
+ it("surfaces PRICE_OVER_CAP with quote/available delta + request_id", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } },
+ });
+ http.expect("POST", "/v1/verifications", {
+ status: 409, headers: new Headers(),
+ body: {
+ success: false,
+ error: { code: "PRICE_OVER_CAP", message: "...", request_id: "req_pricecap", details: { max_price_cents: 35, available_price_cents: 42 }, docs_url: "" },
+ },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" });
+ expect(res.isError).toBe(true);
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("$0.35");
+ expect(t.text).toContain("$0.42");
+ expect(t.text).toContain("req_pricecap"); // request_id flows
+ });
+
+ it("returns toolError when service not in catalog (no commit attempted)", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_other", name: "Other", quoted_price_cents: 50 }] } },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(1); // no POST
+ });
+});
+
+describe("rent_number (rental + dedicated paths)", () => {
+ // Real rental shape: rental_type (not kind), uppercase duration, country, etc.
+ function rentalResp(id: string, overrides: Record = {}) {
+ return {
+ id, display_id: "LTR789", status: "active", phone_number: "+14155550123",
+ service_id: "svc_tg", service_name: "Telegram", country: "us", duration: "7D",
+ rental_type: "rental", charged_price_cents: 500, auto_renew: false,
+ next_renewal_price_cents: 500, re_rent_available: false, re_rent_price_cents: null,
+ re_rent_blocked_at: null, created_at: "x", paid_until: "x", expires_at: "x",
+ can_cancel: true, cancel_window_expires_at: "x", messages: [],
+ ...overrides,
+ };
+ }
+
+ it("rental kind → POST /v1/rentals with uppercase duration body and tied max_price_cents (no kind field)", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35, ltr_7d_price_cents: 500 }] } },
+ });
+ http.expect("POST", "/v1/rentals", {
+ status: 201, headers: new Headers(),
+ body: { success: true, data: rentalResp("ren_new") },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental", duration: "7d" });
+ expect(http.history[1].body).toMatchObject({ service_id: "svc_tg", duration: "7D", max_price_cents: 500 });
+ expect(http.history[1].body).not.toHaveProperty("kind");
+ expect(res.structuredContent?.rental).toMatchObject({ id: "ren_new" });
+ });
+
+ it("rental kind without duration → toolError without HTTP call", async () => {
+ const http = createMockHttpClient();
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("rental tier not offered (price 0) → toolError without commit", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35, ltr_7d_price_cents: 0 }] } },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental", duration: "7d" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(1);
+ });
+
+ it("dedicated kind → POST /v1/rentals with svc_dedicated_28d + 28D using ltr_28d_price_cents", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [
+ { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 },
+ { id: "svc_dedicated_28d", name: "Dedicated Number", quoted_price_cents: 1999, ltr_28d_price_cents: 1999 },
+ ] } },
+ });
+ http.expect("POST", "/v1/rentals", {
+ status: 201, headers: new Headers(),
+ body: { success: true, data: rentalResp("ren_ded", { duration: "28D", charged_price_cents: 1999 }) },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "dedicated" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history[1].body).toMatchObject({ service_id: "svc_dedicated_28d", duration: "28D", max_price_cents: 1999 });
+ expect(res.structuredContent?.rental).toMatchObject({ id: "ren_ded" });
+ });
+
+ it("dedicated kind when svc_dedicated_28d not in catalog → toolError", async () => {
+ const http = createMockHttpClient();
+ http.expect("GET", "/v1/services", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } },
+ });
+ const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "dedicated" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(1);
+ });
+});
+
+describe("cancel_rental", () => {
+ it("ver_ ID → POST /v1/verifications/:id/cancel with idempotency", async () => {
+ const http = createMockHttpClient();
+ // Cancel returns a SLIM verification object, not the full resource.
+ http.expect("POST", "/v1/verifications/ver_abc/cancel", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { verification: { id: "ver_abc", status: "cancelled", refunded_cents: 15 } } },
+ });
+ const res = await cancelRentalHandler(http)({ rental_id: "ver_abc" });
+ expect(res.isError).toBeFalsy();
+ const t = res.content[0]; if (t.type !== "text") throw new Error("text");
+ expect(t.text).toContain("Refunded $0.15");
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ });
+
+ it("ren_ ID → DELETE /v1/rentals/:id with idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("DELETE", "/v1/rentals/ren_xyz", {
+ status: 200, headers: new Headers(),
+ body: { success: true, data: { id: "ren_xyz", status: "cancelled", phone_number: "x", service_id: "x", service_name: "x", country: "us", duration: "7D", rental_type: "rental", charged_price_cents: 0, auto_renew: false, next_renewal_price_cents: 0, re_rent_available: false, re_rent_price_cents: null, re_rent_blocked_at: null, paid_until: "x", expires_at: "x", created_at: "x", can_cancel: false, messages: [] } },
+ });
+ const res = await cancelRentalHandler(http)({ rental_id: "ren_xyz" });
+ expect(res.isError).toBeFalsy();
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ });
+});
+
+describe("reuse_number, re_rent_rental, toggle_auto_renew", () => {
+ function verResp(id: string) {
+ return { success: true, data: { verification: { id, status: "waiting_for_code", phone_number: "x", service_id: "x", service_name: "x", charged_price_cents: 0, expires_at: "x", can_cancel: true, created_at: "x", reuse_counter: 1, allow_reuse: false, allow_paid_reuse: false, paid_reuse_price_cents: 50, messages: [] } } };
+ }
+ function rntResp(id: string, auto_renew = false) {
+ return { success: true, data: { id, status: "active", phone_number: "x", service_id: "x", service_name: "x", country: "us", duration: "7D", rental_type: "rental", charged_price_cents: 500, auto_renew, next_renewal_price_cents: 500, re_rent_available: false, re_rent_price_cents: null, re_rent_blocked_at: null, paid_until: "x", expires_at: "x", created_at: "x", can_cancel: true, messages: [] } };
+ }
+
+ it("reuse_number free path → POST /v1/verifications/:id/reuse", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/verifications/ver_abc/reuse", { status: 200, headers: new Headers(), body: verResp("ver_abc") });
+ const res = await reuseNumberHandler(http)({ rental_id: "ver_abc", paid: false });
+ expect(res.isError).toBeFalsy();
+ });
+
+ it("reuse_number paid path → POST /v1/verifications/:id/reuse/paid", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/verifications/ver_abc/reuse/paid", { status: 200, headers: new Headers(), body: verResp("ver_abc") });
+ await reuseNumberHandler(http)({ rental_id: "ver_abc", paid: true });
+ expect(http.history[0].path).toBe("/v1/verifications/ver_abc/reuse/paid");
+ });
+
+ it("reuse_number rejects ren_ prefix", async () => {
+ const http = createMockHttpClient();
+ const res = await reuseNumberHandler(http)({ rental_id: "ren_xyz", paid: false });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("re_rent_rental → POST /v1/rentals/:id/re_rent with no body + idempotency", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/rentals/ren_xyz/re_rent", { status: 200, headers: new Headers(), body: rntResp("ren_new") });
+ const res = await reRentRentalHandler(http)({ rental_id: "ren_xyz" });
+ expect(http.history[0].body).toBeUndefined();
+ expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/);
+ expect(res.structuredContent?.rental).toMatchObject({ id: "ren_new" });
+ });
+
+ it("re_rent_rental rejects ver_ prefix", async () => {
+ const http = createMockHttpClient();
+ const res = await reRentRentalHandler(http)({ rental_id: "ver_abc" });
+ expect(res.isError).toBe(true);
+ expect(http.history).toHaveLength(0);
+ });
+
+ it("toggle_auto_renew → POST /v1/rentals/:id/auto_renew", async () => {
+ const http = createMockHttpClient();
+ http.expect("POST", "/v1/rentals/ren_xyz/auto_renew", { status: 200, headers: new Headers(), body: rntResp("ren_xyz", true) });
+ await toggleAutoRenewHandler(http)({ rental_id: "ren_xyz", auto_renew: true });
+ expect(http.history[0].body).toMatchObject({ auto_renew: true });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 8d4e7cc..5fd188d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,8 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
- "declaration": true
+ "declaration": true,
+ "types": ["node"]
},
"include": ["src/**/*"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..d138870
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["tests/**/*.test.ts"],
+ testTimeout: 5000,
+ },
+});