portveil-mcp 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Moves are verified: a tool only reports success after the device has switched **
24
24
  | `stop_rotation` | Turn scheduled rotation off | control |
25
25
  | `reconnect_device` | Re-establish a device's tunnel | control |
26
26
  | `disconnect_device` | Turn a device's VPN off (flagged destructive: a hint that tells well-behaved assistants to check with you first) | control |
27
+ | `add_device` | Add a device. Phones and laptops (WireGuard app): creates it and saves its tunnel files locally, key never shown in chat. Linux servers and agent machines: gives the exact commands to run on that machine, which makes its own key and registers itself | admin (phones/laptops) |
27
28
  | `update_device` | Rename a device and/or turn its remote control on or off | admin |
28
29
  | `remove_device` | Remove a device for good (flagged destructive) | admin |
29
30
 
package/dist/index.js CHANGED
@@ -10,7 +10,10 @@
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { z } from "zod";
13
- import { Portveil, PortveilError, VERSION, describeDevice, isWaiting, locationLabel, nextLocation, resolveDevice, resolveLocation, } from "./portveil.js";
13
+ import { mkdir, writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join, resolve } from "node:path";
16
+ import { Portveil, PortveilError, VERSION, agentSetup, describeDevice, wgConfig, wgKeypair, isWaiting, locationLabel, nextLocation, resolveDevice, resolveLocation, } from "./portveil.js";
14
17
  const accountId = process.env.PORTVEIL_ACCOUNT_ID?.trim() ?? "";
15
18
  const token = process.env.PORTVEIL_TOKEN?.trim() ?? "";
16
19
  if (!/^acct_[0-9a-f]{16}$/.test(accountId) || !token) {
@@ -20,7 +23,8 @@ if (!/^acct_[0-9a-f]{16}$/.test(accountId) || !token) {
20
23
  if (token.startsWith("cla_")) {
21
24
  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
25
  }
23
- const pv = new Portveil({ apiBase: process.env.PORTVEIL_API || "https://api.portveil.com", accountId, token });
26
+ const apiBase = (process.env.PORTVEIL_API || "https://api.portveil.com").replace(/\/+$/, "");
27
+ const pv = new Portveil({ apiBase, accountId, token });
24
28
  const server = new McpServer({ name: "portveil", version: VERSION });
25
29
  const ok = (text) => ({ content: [{ type: "text", text }] });
26
30
  async function run(fn) {
@@ -35,7 +39,7 @@ async function run(fn) {
35
39
  const deviceArg = z.string().min(1).describe('Which device: its name, a unique part of its name (e.g. "scraper"), or its device ID (dev_…). Case-insensitive. Get names and IDs from list_devices.');
36
40
  // Tool names follow one verb_noun pattern: list_*, get_*, and an action verb + device/rotation.
37
41
  // Scopes: "read" tokens can use the list_/get_ tools; "control" adds moving, rotating, reconnecting
38
- // and disconnecting; "admin" adds renaming, remote-control settings and removal.
42
+ // and disconnecting; "admin" adds adding, renaming, remote-control settings and removal.
39
43
  server.registerTool("list_devices", {
40
44
  title: "List devices",
41
45
  description: "List every device on the Portveil account, one line each: whether it's protected (connected AND confirmed by the exit server), which country it exits from, its live download/upload speed while connected, and whether it accepts remote control. Use this first to see what's there or to find a device's exact name; use get_device for one device. Read-only.",
@@ -60,7 +64,7 @@ server.registerTool("get_device", {
60
64
  const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
61
65
  const d = resolveDevice(device, devices);
62
66
  const seen = d.last_seen_s_ago == null ? "never" : `${Math.round(d.last_seen_s_ago)}s ago`;
63
- return `${describeDevice(d, servers)}\nLast report: ${seen}.`;
67
+ return `${describeDevice(d, servers)}\nLast report: ${seen}.${d.status_detail ? `\n${d.status_detail}` : ""}`;
64
68
  }));
65
69
  server.registerTool("get_account", {
66
70
  title: "Get account",
@@ -160,6 +164,46 @@ server.registerTool("disconnect_device", {
160
164
  return `Sent. ${d.name} hasn't picked it up yet (it may be offline).`;
161
165
  return `${d.name} did not disconnect: ${st.status}${st.result ? ` (${st.result})` : ""}.`;
162
166
  }));
167
+ server.registerTool("add_device", {
168
+ title: "Add device",
169
+ description: "Add a new device to the account. kind \"phone_or_computer\" (iPhone, Android, Mac, Windows using the WireGuard app): creates the device now and saves one WireGuard tunnel file per location, with a private key made locally, into a folder on the machine running this MCP server; the key is never shown in the reply. Import a file in the WireGuard app to connect. These devices are view-only: they can't be moved remotely. kind \"linux_machine\" (a server or the machine an agent runs on): returns the exact commands to run as root on that machine; it makes its own key and registers itself, and can then be moved, rotated and switched with the other tools. Uses one device slot (see get_account). Needs an admin-scope token for phone_or_computer.",
170
+ inputSchema: {
171
+ name: z.string().trim().min(1).max(64).describe('Display name for the new device, e.g. "Miguel iPhone" or "scraper-box"'),
172
+ kind: z.enum(["phone_or_computer", "linux_machine"]).describe("phone_or_computer: WireGuard app on a phone or laptop. linux_machine: a Linux server or agent machine running the Portveil agent"),
173
+ temporary_minutes: z.number().int().min(5).max(43200).optional().describe("phone_or_computer only: delete the device automatically after this many minutes (5 to 43200, i.e. 30 days). Omit for a permanent device"),
174
+ folder: z.string().min(1).optional().describe("phone_or_computer only: folder to save the tunnel files in. Default ~/Portveil/<name>"),
175
+ split_tunnel: z.boolean().optional().describe("linux_machine only: default true, so a remote server keeps its SSH session. false sends all of its traffic through Portveil"),
176
+ },
177
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
178
+ }, ({ name, kind, temporary_minutes, folder, split_tunnel }) => run(async () => {
179
+ const acct = await pv.account();
180
+ const slots = acct.device_limit === null ? "" : ` (${acct.device_count} of ${acct.device_limit} devices used)`;
181
+ if (acct.device_limit !== null && acct.device_count >= acct.device_limit) {
182
+ throw new PortveilError(`The plan's device limit is reached${slots}. Remove a device or upgrade first.`);
183
+ }
184
+ if (kind === "linux_machine") {
185
+ return `Run these as root on the machine you're adding. It makes its own VPN key, so this can't be done from here. The token prompt doesn't echo; use an admin-scope API token or the account key, and never paste it into a chat.\n\n` +
186
+ agentSetup(apiBase, accountId, name, split_tunnel ?? true) +
187
+ `\n\nThe daemon keeps running; install it as a service to survive reboots. "${name}" then appears in list_devices, and can be moved once its exit confirms it${slots}.`;
188
+ }
189
+ const dir = resolve(folder?.replace(/^~(?=$|\/)/, homedir()) ?? join(homedir(), "Portveil", name.replace(/[^\w .-]+/g, "_")));
190
+ const k = wgKeypair();
191
+ const d = await pv.addDevice(name, "wireguard-app", k.pub, false, temporary_minutes);
192
+ await mkdir(dir, { recursive: true, mode: 0o700 });
193
+ const files = [];
194
+ for (const s of d.servers) {
195
+ const addr = d.addresses[s.id];
196
+ if (!addr)
197
+ continue;
198
+ const file = join(dir, `Portveil-${locationLabel(s).replace(/[^\w .()-]+/g, "_")}.conf`);
199
+ await writeFile(file, wgConfig(k.priv, addr, s), { mode: 0o600 });
200
+ files.push(file);
201
+ }
202
+ const expires = d.expires_at ? ` It deletes itself at ${new Date(d.expires_at * 1000).toISOString().replace(".000Z", "Z")}.` : "";
203
+ return `Added "${name}" [${d.device_id}].${expires} Its tunnel files are saved on the machine running this MCP server, one per location:\n` +
204
+ files.map((f) => `- ${f}`).join("\n") +
205
+ `\n\nOn a Mac or PC: WireGuard → Import tunnel(s) from file. On a phone: send the files to it (AirDrop, or save to Files) and open them with WireGuard. The files hold the device's private key: keep them private and delete copies once imported. Switch countries by turning on a different tunnel in the app.`;
206
+ }));
163
207
  server.registerTool("update_device", {
164
208
  title: "Update device",
165
209
  description: "Rename a device and/or turn remote control on or off for it. Turning remote control off stops move, rotate, reconnect and disconnect for that device until it's turned back on. Change only what's given; safe to repeat. Needs an admin-scope token.",
package/dist/portveil.js CHANGED
@@ -1,12 +1,38 @@
1
1
  // Portveil API client and the logic behind the MCP tools. No MCP types here,
2
2
  // so it can be tested against a fake API.
3
- export const VERSION = "0.3.0";
3
+ import { generateKeyPairSync } from "node:crypto";
4
+ export const VERSION = "0.4.1";
4
5
  /** "↓ 42.3 Mbps ↑ 6.1 Mbps", or "idle" when nothing is moving. */
5
6
  export function describeSpeed(speed) {
6
7
  if (speed.down_mbps < 0.1 && speed.up_mbps < 0.1)
7
8
  return "idle";
8
9
  return `↓ ${speed.down_mbps.toFixed(1)} Mbps ↑ ${speed.up_mbps.toFixed(1)} Mbps`;
9
10
  }
11
+ /** A fresh WireGuard keypair (base64), made locally: the private key never leaves this machine. */
12
+ export function wgKeypair() {
13
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
14
+ const raw = (der) => der.subarray(der.length - 32).toString("base64");
15
+ return { priv: raw(privateKey.export({ format: "der", type: "pkcs8" })), pub: raw(publicKey.export({ format: "der", type: "spki" })) };
16
+ }
17
+ /** The WireGuard app tunnel file for one exit, the same as the setup page makes. */
18
+ export function wgConfig(priv, address, s) {
19
+ return `[Interface]\nPrivateKey = ${priv}\nAddress = ${address.split("/")[0]}/32\nDNS = 1.1.1.1, 9.9.9.9\nMTU = 1420\n\n` +
20
+ `[Peer]\nPublicKey = ${s.pubkey}\nEndpoint = ${s.endpoint_host}:${s.endpoint_port}\n` +
21
+ `AllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n`;
22
+ }
23
+ /** Commands that set up a Linux machine as a Portveil device. It makes its own key, so they run on that machine. */
24
+ export function agentSetup(apiBase, accountId, name, splitTunnel) {
25
+ const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
26
+ return [
27
+ "apt install -y wireguard-tools curl",
28
+ "curl -fsSLO https://portveil.com/downloads/portveil-agent",
29
+ "curl -fsSL https://portveil.com/downloads/portveil-agent.sha256 | sha256sum -c -",
30
+ "chmod +x portveil-agent",
31
+ "read -rsp 'Admin API token or account key: ' PORTVEIL_ACCOUNT_TOKEN && export PORTVEIL_ACCOUNT_TOKEN && echo",
32
+ `./portveil-agent register --control ${apiBase} --account-id ${accountId} --name ${q(name)}${splitTunnel ? " --split-tunnel" : ""}`,
33
+ "./portveil-agent daemon",
34
+ ].join("\n");
35
+ }
10
36
  export class PortveilError extends Error {
11
37
  }
12
38
  /** A command the device hasn't finished yet: queued (not picked up) or delivered (working on it). */
@@ -28,6 +54,8 @@ export function describeDevice(d, servers) {
28
54
  state = "offline";
29
55
  else if (d.exit_confirmed && d.quality === "good")
30
56
  state = `protected, exiting in ${place}`;
57
+ else if (d.exit_confirmed && d.quality === "fair")
58
+ state = `idle: tunnel to ${place} confirmed but no traffic right now (it resumes on the device's next use)`;
31
59
  else
32
60
  state = `connected to ${place}, not yet confirmed by the exit`;
33
61
  const remote = d.platform === "wireguard-app" ? "view only (WireGuard app)" : d.allow_remote ? "remote control on" : "remote control off";
@@ -147,6 +175,13 @@ export class Portveil {
147
175
  const body = Object.fromEntries(Object.entries(change).filter(([, v]) => v !== undefined));
148
176
  return this.adminOnly(() => this.call("PATCH", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`, body));
149
177
  }
178
+ /** Register a device with a public key made elsewhere. Needs an admin-scope token. */
179
+ async addDevice(name, platform, pubkey, allowRemote, ttlMinutes) {
180
+ const body = { name, platform, peer_pubkey: pubkey, allow_remote: allowRemote };
181
+ if (ttlMinutes !== undefined)
182
+ body.ttl_minutes = ttlMinutes;
183
+ return this.adminOnly(() => this.call("POST", `${this.acct()}/devices`, body));
184
+ }
150
185
  /** Remove a device for good (its key stops working everywhere). Needs an admin-scope token. */
151
186
  async removeDevice(deviceId) {
152
187
  await this.adminOnly(() => this.call("DELETE", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`));
@@ -157,7 +192,7 @@ export class Portveil {
157
192
  }
158
193
  catch (e) {
159
194
  if (e instanceof PortveilError && /refused this with your token|isn't allowed to do that/.test(e.message)) {
160
- throw new PortveilError('Renaming, changing remote control or removing devices needs an API token with "admin" scope. Create one in the dashboard, or make the change there.');
195
+ throw new PortveilError('Adding, renaming, changing remote control or removing devices needs an API token with "admin" scope. Create one in the dashboard, or make the change there.');
161
196
  }
162
197
  throw e;
163
198
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "portveil-mcp",
3
3
  "mcpName": "io.github.roybogs/portveil-mcp",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "MCP server for Portveil: let AI assistants see your devices and move them between VPN locations.",
6
6
  "license": "MIT",
7
7
  "type": "module",