portveil-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/index.js +132 -0
- package/dist/portveil.js +172 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Streetside Films LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Portveil MCP server
|
|
2
|
+
|
|
3
|
+
Let an AI assistant see the devices on your [Portveil](https://portveil.com) account and move them between VPN locations.
|
|
4
|
+
|
|
5
|
+
> "Move my scraper box to Finland."
|
|
6
|
+
> "Rotate every agent to a new location."
|
|
7
|
+
> "Which of my devices aren't protected right now?"
|
|
8
|
+
|
|
9
|
+
Moves are verified: a tool only reports success after the device has switched **and** the exit server in the new location confirms it sees that device.
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
| Tool | What it does | Needs |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `list_devices` | Every device: protected or not, where it exits, remote control on/off | read |
|
|
16
|
+
| `list_locations` | The locations you can move to | read |
|
|
17
|
+
| `device_status` | One device's current state | read |
|
|
18
|
+
| `account_info` | Plan and devices used | read |
|
|
19
|
+
| `recent_activity` | Recent moves, reconnects and changes, and who made them | read |
|
|
20
|
+
| `move_device` | Move a device to a country or city ("Finland", "US", "Helsinki") | control |
|
|
21
|
+
| `rotate_device` | Move a device to the next location | control |
|
|
22
|
+
| `reconnect_device` | Re-establish a device's tunnel | control |
|
|
23
|
+
| `disconnect_device` | Turn a device's VPN off (marked destructive, so assistants ask first) | control |
|
|
24
|
+
|
|
25
|
+
Devices can be named loosely ("scraper" finds "Scraper box"); an ambiguous name returns the choices instead of guessing.
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
1. In the [Portveil dashboard](https://portveil.com/dashboard/), create an **API token**. Choose **control** scope to let the assistant move devices, or **read** to let it only look. Don't give it your account key.
|
|
30
|
+
2. Note your account ID (`acct_…`).
|
|
31
|
+
3. Add the server to your assistant:
|
|
32
|
+
|
|
33
|
+
**Claude Code**
|
|
34
|
+
```bash
|
|
35
|
+
claude mcp add portveil -e PORTVEIL_ACCOUNT_ID=acct_… -e PORTVEIL_TOKEN=clt_… -- npx -y portveil-mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Claude Desktop** (`claude_desktop_config.json`), **Cursor** (`.cursor/mcp.json`) and most other clients:
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"portveil": {
|
|
43
|
+
"command": "npx",
|
|
44
|
+
"args": ["-y", "portveil-mcp"],
|
|
45
|
+
"env": { "PORTVEIL_ACCOUNT_ID": "acct_…", "PORTVEIL_TOKEN": "clt_…" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Devices must be running the Portveil app or the Portveil agent with remote control on. Devices using the plain WireGuard app are shown but can't be moved.
|
|
52
|
+
|
|
53
|
+
## Security
|
|
54
|
+
|
|
55
|
+
- Use a scoped API token. Every action it takes is recorded in your account's activity log with the token that made it, and you can revoke it in the dashboard at any time.
|
|
56
|
+
- The server talks only to `https://api.portveil.com` (override with `PORTVEIL_API`). It stores nothing.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install
|
|
62
|
+
npm test # builds, then runs the tests against a fake Portveil API
|
|
63
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Portveil MCP server (stdio). Lets an AI assistant see the devices on a
|
|
3
|
+
// Portveil account and move them between VPN locations.
|
|
4
|
+
//
|
|
5
|
+
// Configuration (environment):
|
|
6
|
+
// PORTVEIL_ACCOUNT_ID acct_… (dashboard → Settings)
|
|
7
|
+
// PORTVEIL_TOKEN an API token from the dashboard. "read" scope can look;
|
|
8
|
+
// "control" scope can also move, reconnect and disconnect.
|
|
9
|
+
// PORTVEIL_API optional, defaults to https://api.portveil.com
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { Portveil, PortveilError, VERSION, describeDevice, isWaiting, locationLabel, nextLocation, resolveDevice, resolveLocation, } from "./portveil.js";
|
|
14
|
+
const accountId = process.env.PORTVEIL_ACCOUNT_ID?.trim() ?? "";
|
|
15
|
+
const token = process.env.PORTVEIL_TOKEN?.trim() ?? "";
|
|
16
|
+
if (!/^acct_[0-9a-f]{16}$/.test(accountId) || !token) {
|
|
17
|
+
console.error("portveil-mcp: set PORTVEIL_ACCOUNT_ID (acct_…) and PORTVEIL_TOKEN (an API token from https://portveil.com/dashboard/).");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
if (token.startsWith("cla_")) {
|
|
21
|
+
console.error("portveil-mcp: warning: that's your account key. Create a scoped API token in the dashboard instead, so an assistant never holds full access.");
|
|
22
|
+
}
|
|
23
|
+
const pv = new Portveil({ apiBase: process.env.PORTVEIL_API || "https://api.portveil.com", accountId, token });
|
|
24
|
+
const server = new McpServer({ name: "portveil", version: VERSION });
|
|
25
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
26
|
+
async function run(fn) {
|
|
27
|
+
try {
|
|
28
|
+
return ok(await fn());
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
const msg = e instanceof PortveilError ? e.message : `Unexpected error: ${e.message}`;
|
|
32
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const deviceArg = z.string().min(1).describe('Device name (or part of it, e.g. "scraper") or its device ID (dev_…)');
|
|
36
|
+
server.registerTool("list_devices", {
|
|
37
|
+
title: "List devices",
|
|
38
|
+
description: "List every device on the Portveil account: whether each is protected, which country its traffic exits from, and whether it accepts remote control.",
|
|
39
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
40
|
+
}, () => run(async () => {
|
|
41
|
+
const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
|
|
42
|
+
if (devices.length === 0)
|
|
43
|
+
return "No devices on this account yet. Add one at https://portveil.com/start/";
|
|
44
|
+
return devices.map((d) => "- " + describeDevice(d, servers)).join("\n");
|
|
45
|
+
}));
|
|
46
|
+
server.registerTool("list_locations", {
|
|
47
|
+
title: "List locations",
|
|
48
|
+
description: "List the VPN exit locations (countries) a device can be moved to.",
|
|
49
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
50
|
+
}, () => run(async () => (await pv.servers()).map((s) => `- ${locationLabel(s)} [${s.id}]`).join("\n")));
|
|
51
|
+
server.registerTool("device_status", {
|
|
52
|
+
title: "Device status",
|
|
53
|
+
description: "Show one device's current state: connected or not, the location it exits from, and whether the exit server confirms it.",
|
|
54
|
+
inputSchema: { device: deviceArg },
|
|
55
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
56
|
+
}, ({ device }) => run(async () => {
|
|
57
|
+
const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
|
|
58
|
+
const d = resolveDevice(device, devices);
|
|
59
|
+
const seen = d.last_seen_s_ago == null ? "never" : `${Math.round(d.last_seen_s_ago)}s ago`;
|
|
60
|
+
return `${describeDevice(d, servers)}\nLast report: ${seen}.`;
|
|
61
|
+
}));
|
|
62
|
+
server.registerTool("move_device", {
|
|
63
|
+
title: "Move device to a location",
|
|
64
|
+
description: "Move a device's VPN traffic to exit from another country. Waits until the device switches and the new exit server confirms it (usually 10–30 s). Needs a token with control scope and remote control enabled on the device.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
device: deviceArg,
|
|
67
|
+
location: z.string().min(1).describe('Where to exit: a country or city ("Finland", "US", "Helsinki") or a location ID (srv-eu-1)'),
|
|
68
|
+
},
|
|
69
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
70
|
+
}, ({ device, location }) => run(async () => {
|
|
71
|
+
const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
|
|
72
|
+
return pv.move(resolveDevice(device, devices), resolveLocation(location, servers));
|
|
73
|
+
}));
|
|
74
|
+
server.registerTool("rotate_device", {
|
|
75
|
+
title: "Rotate device to the next location",
|
|
76
|
+
description: "Move a device to the next available location, so its traffic exits from somewhere new. Waits for the move to be confirmed.",
|
|
77
|
+
inputSchema: { device: deviceArg },
|
|
78
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
79
|
+
}, ({ device }) => run(async () => {
|
|
80
|
+
const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
|
|
81
|
+
const d = resolveDevice(device, devices);
|
|
82
|
+
return pv.move(d, nextLocation(d.server_id, servers));
|
|
83
|
+
}));
|
|
84
|
+
server.registerTool("reconnect_device", {
|
|
85
|
+
title: "Reconnect device",
|
|
86
|
+
description: "Tell a device to re-establish its VPN tunnel at its current location. Useful when it shows as connected but not confirmed.",
|
|
87
|
+
inputSchema: { device: deviceArg },
|
|
88
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
89
|
+
}, ({ device }) => run(async () => {
|
|
90
|
+
const d = resolveDevice(device, await pv.devices());
|
|
91
|
+
const st = await pv.runCommand(d, "reconnect");
|
|
92
|
+
if (st.status === "acked")
|
|
93
|
+
return `${d.name} reconnected.`;
|
|
94
|
+
if (isWaiting(st.status))
|
|
95
|
+
return `Sent. ${d.name} hasn't picked it up yet (it may be offline).`;
|
|
96
|
+
return `${d.name} did not reconnect: ${st.status}${st.result ? ` (${st.result})` : ""}.`;
|
|
97
|
+
}));
|
|
98
|
+
server.registerTool("disconnect_device", {
|
|
99
|
+
title: "Disconnect device",
|
|
100
|
+
description: "Turn off a device's VPN tunnel. Its traffic stops going through Portveil until it reconnects. Confirm with the user before using this.",
|
|
101
|
+
inputSchema: { device: deviceArg },
|
|
102
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
103
|
+
}, ({ device }) => run(async () => {
|
|
104
|
+
const d = resolveDevice(device, await pv.devices());
|
|
105
|
+
const st = await pv.runCommand(d, "disconnect");
|
|
106
|
+
if (st.status === "acked")
|
|
107
|
+
return `${d.name} is disconnected. Its traffic no longer goes through Portveil.`;
|
|
108
|
+
if (isWaiting(st.status))
|
|
109
|
+
return `Sent. ${d.name} hasn't picked it up yet (it may be offline).`;
|
|
110
|
+
return `${d.name} did not disconnect: ${st.status}${st.result ? ` (${st.result})` : ""}.`;
|
|
111
|
+
}));
|
|
112
|
+
server.registerTool("recent_activity", {
|
|
113
|
+
title: "Recent activity",
|
|
114
|
+
description: "Show recent actions on the account (moves, reconnects, renames, tokens created), newest first, with who issued each.",
|
|
115
|
+
inputSchema: { limit: z.number().int().min(1).max(100).default(20).describe("How many entries (1–100)") },
|
|
116
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
117
|
+
}, ({ limit }) => run(async () => {
|
|
118
|
+
const [{ entries }, devices] = await Promise.all([pv.audit(limit), pv.devices()]);
|
|
119
|
+
if (entries.length === 0)
|
|
120
|
+
return "No activity yet.";
|
|
121
|
+
const name = (id) => (id ? devices.find((d) => d.device_id === id)?.name ?? id : "account");
|
|
122
|
+
return entries.map((e) => `- ${new Date(e.created_at * 1000).toISOString().replace(".000Z", "Z")} ${e.action} ${name(e.device_id)} (by ${e.issued_by})`).join("\n");
|
|
123
|
+
}));
|
|
124
|
+
server.registerTool("account_info", {
|
|
125
|
+
title: "Account info",
|
|
126
|
+
description: "Show the account's plan and how many devices it uses out of its limit.",
|
|
127
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
128
|
+
}, () => run(async () => {
|
|
129
|
+
const a = await pv.account();
|
|
130
|
+
return `Plan: ${a.plan ?? "none"}. Devices: ${a.device_count}${a.device_limit ? ` of ${a.device_limit}` : ""}.`;
|
|
131
|
+
}));
|
|
132
|
+
await server.connect(new StdioServerTransport());
|
package/dist/portveil.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Portveil API client and the logic behind the MCP tools. No MCP types here,
|
|
2
|
+
// so it can be tested against a fake API.
|
|
3
|
+
export const VERSION = "0.1.0";
|
|
4
|
+
export class PortveilError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** A command the device hasn't finished yet: queued (not picked up) or delivered (working on it). */
|
|
7
|
+
export const isWaiting = (status) => status === "queued" || status === "delivered" || status === "pending";
|
|
8
|
+
// Friendly names for the exits we run; anything new falls back to the server's own name.
|
|
9
|
+
const KNOWN = {
|
|
10
|
+
"srv-us-1": { country: "United States", code: "US", place: "US West", aliases: ["usa", "america", "united states", "us", "us west", "west"] },
|
|
11
|
+
"srv-eu-1": { country: "Finland", code: "FI", place: "Helsinki", aliases: ["finland", "fi", "helsinki", "eu", "europe"] },
|
|
12
|
+
};
|
|
13
|
+
export function locationLabel(s) {
|
|
14
|
+
const k = KNOWN[s.id];
|
|
15
|
+
return k ? `${k.country} (${k.place})` : s.name;
|
|
16
|
+
}
|
|
17
|
+
export function describeDevice(d, servers) {
|
|
18
|
+
const where = d.server_id ? servers.find((s) => s.id === d.server_id) : undefined;
|
|
19
|
+
const place = where ? locationLabel(where) : d.server_id ?? "none";
|
|
20
|
+
let state;
|
|
21
|
+
if (!d.connected)
|
|
22
|
+
state = "offline";
|
|
23
|
+
else if (d.exit_confirmed && d.quality === "good")
|
|
24
|
+
state = `protected, exiting in ${place}`;
|
|
25
|
+
else
|
|
26
|
+
state = `connected to ${place}, not yet confirmed by the exit`;
|
|
27
|
+
const remote = d.platform === "wireguard-app" ? "view only (WireGuard app)" : d.allow_remote ? "remote control on" : "remote control off";
|
|
28
|
+
return `${d.name} [${d.device_id}] (${d.platform}): ${state}; ${remote}`;
|
|
29
|
+
}
|
|
30
|
+
/** Match a device by id, exact name, or a unique partial name (case-insensitive). */
|
|
31
|
+
export function resolveDevice(query, devices) {
|
|
32
|
+
const q = query.trim().toLowerCase();
|
|
33
|
+
const byId = devices.find((d) => d.device_id.toLowerCase() === q);
|
|
34
|
+
if (byId)
|
|
35
|
+
return byId;
|
|
36
|
+
const exact = devices.filter((d) => d.name.toLowerCase() === q);
|
|
37
|
+
if (exact.length === 1)
|
|
38
|
+
return exact[0];
|
|
39
|
+
const partial = devices.filter((d) => d.name.toLowerCase().includes(q));
|
|
40
|
+
if (partial.length === 1)
|
|
41
|
+
return partial[0];
|
|
42
|
+
const names = devices.map((d) => `"${d.name}"`).join(", ") || "none";
|
|
43
|
+
if (exact.length > 1 || partial.length > 1) {
|
|
44
|
+
throw new PortveilError(`"${query}" matches more than one device (${(exact.length > 1 ? exact : partial).map((d) => `"${d.name}" [${d.device_id}]`).join(", ")}). Use the device ID.`);
|
|
45
|
+
}
|
|
46
|
+
throw new PortveilError(`No device matches "${query}". Devices on this account: ${names}.`);
|
|
47
|
+
}
|
|
48
|
+
/** Match a location by server id, country, code, city or region (case-insensitive). */
|
|
49
|
+
export function resolveLocation(query, servers) {
|
|
50
|
+
const q = query.trim().toLowerCase();
|
|
51
|
+
const hits = servers.filter((s) => {
|
|
52
|
+
if (s.id.toLowerCase() === q)
|
|
53
|
+
return true;
|
|
54
|
+
const k = KNOWN[s.id];
|
|
55
|
+
const words = [s.name, s.region, ...(k ? [k.country, k.code, k.place, ...k.aliases] : [])].map((w) => w.toLowerCase());
|
|
56
|
+
return words.some((w) => w === q) || words.some((w) => w.length > 3 && (w.includes(q) || q.includes(w)));
|
|
57
|
+
});
|
|
58
|
+
if (hits.length === 1)
|
|
59
|
+
return hits[0];
|
|
60
|
+
const all = servers.map((s) => `${locationLabel(s)} [${s.id}]`).join(", ");
|
|
61
|
+
if (hits.length > 1)
|
|
62
|
+
throw new PortveilError(`"${query}" matches more than one location: ${hits.map((s) => s.id).join(", ")}. Available: ${all}.`);
|
|
63
|
+
throw new PortveilError(`No location matches "${query}". Available: ${all}.`);
|
|
64
|
+
}
|
|
65
|
+
/** The next location after the current one, wrapping around. */
|
|
66
|
+
export function nextLocation(current, servers) {
|
|
67
|
+
if (servers.length === 0)
|
|
68
|
+
throw new PortveilError("No locations are available.");
|
|
69
|
+
const i = current ? servers.findIndex((s) => s.id === current) : -1;
|
|
70
|
+
if (i === -1)
|
|
71
|
+
return servers[0];
|
|
72
|
+
if (servers.length === 1)
|
|
73
|
+
throw new PortveilError("There's only one location, so there's nowhere to rotate to.");
|
|
74
|
+
return servers[(i + 1) % servers.length];
|
|
75
|
+
}
|
|
76
|
+
export class Portveil {
|
|
77
|
+
o;
|
|
78
|
+
constructor(opts) {
|
|
79
|
+
this.o = { ackTimeoutMs: 45_000, confirmTimeoutMs: 45_000, ...opts, apiBase: opts.apiBase.replace(/\/+$/, "") };
|
|
80
|
+
}
|
|
81
|
+
get doFetch() { return this.o.fetch ?? fetch; }
|
|
82
|
+
sleep(ms) { return this.o.sleep ? this.o.sleep(ms) : new Promise((r) => setTimeout(r, ms)); }
|
|
83
|
+
async call(method, path, body) {
|
|
84
|
+
const res = await this.doFetch(this.o.apiBase + path, {
|
|
85
|
+
method,
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${this.o.token}`,
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
// Cloudflare in front of the API rejects generic clients (error 1010).
|
|
90
|
+
"User-Agent": `portveil-mcp/${VERSION} (+https://portveil.com)`,
|
|
91
|
+
},
|
|
92
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
93
|
+
});
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
let data = {};
|
|
96
|
+
try {
|
|
97
|
+
data = text ? JSON.parse(text) : {};
|
|
98
|
+
}
|
|
99
|
+
catch { /* non-JSON error page */ }
|
|
100
|
+
if (res.ok)
|
|
101
|
+
return data;
|
|
102
|
+
const detail = typeof data?.detail === "string" ? data.detail : data?.detail?.reason ?? "";
|
|
103
|
+
switch (res.status) {
|
|
104
|
+
// The control endpoints answer 401 both for a bad token and for one without enough scope.
|
|
105
|
+
case 401: throw new PortveilError(method === "GET"
|
|
106
|
+
? "Portveil rejected the token. Check PORTVEIL_TOKEN (an API token from the dashboard)."
|
|
107
|
+
: 'Portveil refused this with your token. Moving, reconnecting or disconnecting devices needs an API token with "control" scope; a "read" token can only look.');
|
|
108
|
+
case 403: throw new PortveilError(`This token isn't allowed to do that${detail ? ` (${detail})` : ""}. Moving or reconnecting devices needs a token with "control" scope.`);
|
|
109
|
+
case 404: throw new PortveilError("Not found. Check PORTVEIL_ACCOUNT_ID matches the token's account.");
|
|
110
|
+
case 409: throw new PortveilError(detail === "remote_disabled" ? "That device has remote control turned off, so it can't be controlled from here." : `Conflict: ${detail || text}`);
|
|
111
|
+
case 429: throw new PortveilError(`Portveil is rate limiting requests; try again in ${res.headers.get("retry-after") ?? "a few"} seconds.`);
|
|
112
|
+
default: throw new PortveilError(`Portveil returned HTTP ${res.status}${detail ? `: ${detail}` : ""}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
acct() { return `/v1/accounts/${encodeURIComponent(this.o.accountId)}`; }
|
|
116
|
+
async devices() { return (await this.call("GET", `${this.acct()}/devices`)).devices; }
|
|
117
|
+
async servers() { return (await this.call("GET", "/v1/servers")).servers; }
|
|
118
|
+
async account() { return this.call("GET", this.acct()); }
|
|
119
|
+
async audit(limit) {
|
|
120
|
+
return this.call("GET", `${this.acct()}/audit-log?limit=${Math.max(1, Math.min(limit, 100))}`);
|
|
121
|
+
}
|
|
122
|
+
async command(deviceId, type, serverId) {
|
|
123
|
+
const c = await this.call("POST", `${this.acct()}/devices/${encodeURIComponent(deviceId)}/commands`, serverId ? { type, server_id: serverId } : { type });
|
|
124
|
+
return { command_id: c.command_id, status: c.status, result: null };
|
|
125
|
+
}
|
|
126
|
+
async commandState(id) {
|
|
127
|
+
return this.call("GET", `${this.acct()}/commands/${encodeURIComponent(id)}`);
|
|
128
|
+
}
|
|
129
|
+
/** Send a command and wait for the device to pick it up (it polls about every 10 s). */
|
|
130
|
+
async runCommand(device, type, server) {
|
|
131
|
+
if (device.platform === "wireguard-app")
|
|
132
|
+
throw new PortveilError(`${device.name} uses the WireGuard app, which can't be controlled remotely. Install the Portveil agent or app on it for that.`);
|
|
133
|
+
let st = await this.command(device.device_id, type, server?.id);
|
|
134
|
+
const deadline = Date.now() + this.o.ackTimeoutMs;
|
|
135
|
+
while (isWaiting(st.status) && Date.now() < deadline) {
|
|
136
|
+
await this.sleep(2500);
|
|
137
|
+
st = await this.commandState(st.command_id);
|
|
138
|
+
}
|
|
139
|
+
return st;
|
|
140
|
+
}
|
|
141
|
+
/** After a switch is acknowledged, wait for the new exit to confirm it sees the device. */
|
|
142
|
+
async waitConfirmed(deviceId, serverId) {
|
|
143
|
+
const deadline = Date.now() + this.o.confirmTimeoutMs;
|
|
144
|
+
for (;;) {
|
|
145
|
+
const d = (await this.devices()).find((x) => x.device_id === deviceId);
|
|
146
|
+
if (d && d.server_id === serverId && d.exit_confirmed)
|
|
147
|
+
return d;
|
|
148
|
+
if (Date.now() >= deadline)
|
|
149
|
+
return undefined;
|
|
150
|
+
await this.sleep(3000);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Move a device and report plainly what actually happened. */
|
|
154
|
+
async move(device, target) {
|
|
155
|
+
const place = locationLabel(target);
|
|
156
|
+
if (device.server_id === target.id && device.exit_confirmed)
|
|
157
|
+
return `${device.name} is already exiting in ${place}; nothing to do.`;
|
|
158
|
+
const st = await this.runCommand(device, "switch_server", target);
|
|
159
|
+
if (st.status === "delivered") {
|
|
160
|
+
return `${device.name} is switching to ${place} but hasn't reported back yet. Check with device_status in a minute.`;
|
|
161
|
+
}
|
|
162
|
+
if (isWaiting(st.status)) {
|
|
163
|
+
return `Sent. ${device.name} hasn't picked up the move to ${place} yet (it may be offline); it will when it's next online, until the command expires.`;
|
|
164
|
+
}
|
|
165
|
+
if (st.status !== "acked")
|
|
166
|
+
return `${device.name} did not move: ${st.status}${st.result ? ` (${st.result})` : ""}. It is still on its previous location.`;
|
|
167
|
+
const confirmed = await this.waitConfirmed(device.device_id, target.id);
|
|
168
|
+
return confirmed
|
|
169
|
+
? `Done. ${device.name} now exits in ${place}, confirmed by the ${place} exit server.`
|
|
170
|
+
: `${device.name} switched to ${place}, but that exit hasn't confirmed the connection yet. Check again with device_status in a minute.`;
|
|
171
|
+
}
|
|
172
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portveil-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Portveil: let AI assistants see your devices and move them between VPN locations.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"portveil-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
20
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"vpn",
|
|
26
|
+
"wireguard",
|
|
27
|
+
"portveil",
|
|
28
|
+
"ai-agents"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://portveil.com",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/roybogs/portveil-mcp.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/roybogs/portveil-mcp/issues"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
+
"zod": "^3.23.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^5.6.0",
|
|
44
|
+
"@types/node": "^22.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|