portveil-mcp 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,20 @@ Moves are verified: a tool only reports success after the device has switched **
15
15
  |---|---|---|
16
16
  | `list_devices` | Every device: protected or not, where it exits, live speed (↓/↑ Mbps), remote control on/off | read |
17
17
  | `list_locations` | The locations you can move to | read |
18
- | `device_status` | One device's current state, including live speed | read |
19
- | `account_info` | Plan and devices used | read |
20
- | `recent_activity` | Recent moves, reconnects and changes, and who made them | read |
18
+ | `get_device` | One device's current state, including live speed and any rotation | read |
19
+ | `get_account` | Plan and devices used | read |
20
+ | `list_activity` | Recent moves, reconnects and changes, and which token made them | read |
21
21
  | `move_device` | Move a device to a country or city ("Finland", "US", "Helsinki") | control |
22
- | `rotate_device` | Move a device to the next location | control |
23
- | `set_rotation` | Move a device automatically every N minutes (5–10080), optionally among chosen locations. Portveil runs the schedule, so the assistant can close | control |
22
+ | `rotate_device` | Move a device once to the next location | control |
23
+ | `start_rotation` | Move a device automatically every N minutes (5–10080), optionally among chosen locations. Portveil runs the schedule, so the assistant can close | control |
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) |
28
+ | `update_device` | Rename a device and/or turn its remote control on or off | admin |
29
+ | `remove_device` | Remove a device for good (flagged destructive) | admin |
30
+
31
+ Tool names changed in 0.3.0 to one verb_noun pattern: `device_status` → `get_device`, `account_info` → `get_account`, `recent_activity` → `list_activity`, `set_rotation` → `start_rotation`.
27
32
 
28
33
  Devices can be named loosely ("scraper" finds "Scraper box"); an ambiguous name returns the choices instead of guessing.
29
34
 
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) {
@@ -32,10 +36,13 @@ async function run(fn) {
32
36
  return { content: [{ type: "text", text: msg }], isError: true };
33
37
  }
34
38
  }
35
- const deviceArg = z.string().min(1).describe('Device name (or part of it, e.g. "scraper") or its device ID (dev_…)');
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.');
40
+ // Tool names follow one verb_noun pattern: list_*, get_*, and an action verb + device/rotation.
41
+ // Scopes: "read" tokens can use the list_/get_ tools; "control" adds moving, rotating, reconnecting
42
+ // and disconnecting; "admin" adds adding, renaming, remote-control settings and removal.
36
43
  server.registerTool("list_devices", {
37
44
  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.",
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.",
39
46
  annotations: { readOnlyHint: true, openWorldHint: false },
40
47
  }, () => run(async () => {
41
48
  const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
@@ -45,12 +52,12 @@ server.registerTool("list_devices", {
45
52
  }));
46
53
  server.registerTool("list_locations", {
47
54
  title: "List locations",
48
- description: "List the VPN exit locations (countries) a device can be moved to.",
55
+ description: "List the exit locations (country, city and location ID) that devices can be moved to. Use it before move_device or start_rotation when you're unsure what's available. Every plan can use every location. Read-only.",
49
56
  annotations: { readOnlyHint: true, openWorldHint: false },
50
57
  }, () => 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.",
58
+ server.registerTool("get_device", {
59
+ title: "Get device",
60
+ description: "Get one device's current state: online or offline, the location it exits from, whether that exit server confirms the tunnel, its live speed, any rotation schedule, and when it last reported. Use it to check a device before or after an action. Read-only.",
54
61
  inputSchema: { device: deviceArg },
55
62
  annotations: { readOnlyHint: true, openWorldHint: false },
56
63
  }, ({ device }) => run(async () => {
@@ -59,12 +66,32 @@ server.registerTool("device_status", {
59
66
  const seen = d.last_seen_s_ago == null ? "never" : `${Math.round(d.last_seen_s_ago)}s ago`;
60
67
  return `${describeDevice(d, servers)}\nLast report: ${seen}.`;
61
68
  }));
69
+ server.registerTool("get_account", {
70
+ title: "Get account",
71
+ description: "Get the account's plan and how many devices it uses out of its limit. Use it when asked about the plan or before suggesting adding devices. Read-only.",
72
+ annotations: { readOnlyHint: true, openWorldHint: false },
73
+ }, () => run(async () => {
74
+ const a = await pv.account();
75
+ return `Plan: ${a.plan ?? "none"}. Devices: ${a.device_count}${a.device_limit ? ` of ${a.device_limit}` : ""}.`;
76
+ }));
77
+ server.registerTool("list_activity", {
78
+ title: "List activity",
79
+ description: "List recent actions on the account (moves, reconnects, rotations, renames, tokens created), newest first, with the token that made each. Use it to answer \"what changed?\" or to audit an assistant's actions. Read-only.",
80
+ inputSchema: { limit: z.number().int().min(1).max(100).default(20).describe("How many entries to return, newest first (1–100, default 20)") },
81
+ annotations: { readOnlyHint: true, openWorldHint: false },
82
+ }, ({ limit }) => run(async () => {
83
+ const [{ entries }, devices] = await Promise.all([pv.audit(limit), pv.devices()]);
84
+ if (entries.length === 0)
85
+ return "No activity yet.";
86
+ const name = (id) => (id ? devices.find((d) => d.device_id === id)?.name ?? id : "account");
87
+ 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");
88
+ }));
62
89
  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.",
90
+ title: "Move device",
91
+ description: "Move a device's traffic to exit from a chosen location. Waits until the device switches and the new exit server confirms it (usually 10–30 s), and says plainly if that didn't happen. Safe to repeat: moving to where it already is does nothing. Only works on machines running the Portveil agent with remote control on (phones using the WireGuard app switch on the device itself). Needs a control-scope token. To just go somewhere different, use rotate_device.",
65
92
  inputSchema: {
66
93
  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)'),
94
+ location: z.string().min(1).describe('Where to exit: a country, city or region ("Finland", "US", "Helsinki") or a location ID from list_locations (e.g. srv-eu-1)'),
68
95
  },
69
96
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
70
97
  }, ({ device, location }) => run(async () => {
@@ -72,8 +99,8 @@ server.registerTool("move_device", {
72
99
  return pv.move(resolveDevice(device, devices), resolveLocation(location, servers));
73
100
  }));
74
101
  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.",
102
+ title: "Rotate device",
103
+ description: "Move a device once to the next location in the list, so its traffic exits from somewhere new. Same checks and waiting as move_device. Each call moves again, so it isn't idempotent. For repeated automatic moves use start_rotation instead. Needs a control-scope token.",
77
104
  inputSchema: { device: deviceArg },
78
105
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
79
106
  }, ({ device }) => run(async () => {
@@ -81,13 +108,13 @@ server.registerTool("rotate_device", {
81
108
  const d = resolveDevice(device, devices);
82
109
  return pv.move(d, nextLocation(d.server_id, servers));
83
110
  }));
84
- server.registerTool("set_rotation", {
85
- title: "Rotate a device on a schedule",
86
- description: "Make Portveil move a device to the next location automatically every N minutes (5 to 10080), optionally cycling only through some locations. Portveil does the moves itself, so the assistant doesn't need to stay running. Needs control scope.",
111
+ server.registerTool("start_rotation", {
112
+ title: "Start rotation",
113
+ description: "Make Portveil move a device to the next location automatically every N minutes, optionally cycling through chosen locations only. Portveil runs the schedule itself, so the assistant doesn't need to stay running. Calling it again replaces the schedule. Stop it with stop_rotation. Needs a control-scope token and a Portveil-agent device.",
87
114
  inputSchema: {
88
115
  device: deviceArg,
89
- every_minutes: z.number().int().min(5).max(10080).describe("Minutes between moves (5 to 10080)"),
90
- locations: z.array(z.string().min(1)).optional().describe('Locations to cycle through, e.g. ["US", "Finland"]. Omit for every location.'),
116
+ every_minutes: z.number().int().min(5).max(10080).describe("Minutes between moves: 5 to 10080 (one week)"),
117
+ locations: z.array(z.string().min(1)).optional().describe('Locations to cycle through, e.g. ["US", "Finland"]; at least two. Omit to cycle through every location.'),
91
118
  },
92
119
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
93
120
  }, ({ device, every_minutes, locations }) => run(async () => {
@@ -100,8 +127,8 @@ server.registerTool("set_rotation", {
100
127
  return `${d.name} will now move every ${every_minutes} minutes, cycling through ${where}.${next}`;
101
128
  }));
102
129
  server.registerTool("stop_rotation", {
103
- title: "Stop scheduled rotation",
104
- description: "Turn off automatic rotation for a device. It stays at its current location.",
130
+ title: "Stop rotation",
131
+ description: "Turn off a device's automatic rotation. The device stays at its current location. Safe to call when no rotation is set. Needs a control-scope token.",
105
132
  inputSchema: { device: deviceArg },
106
133
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
107
134
  }, ({ device }) => run(async () => {
@@ -111,7 +138,7 @@ server.registerTool("stop_rotation", {
111
138
  }));
112
139
  server.registerTool("reconnect_device", {
113
140
  title: "Reconnect device",
114
- description: "Tell a device to re-establish its VPN tunnel at its current location. Useful when it shows as connected but not confirmed.",
141
+ description: "Tell a device to re-establish its VPN tunnel at its current location, without moving it. Use when it shows connected but not confirmed, or traffic seems stuck. Traffic may pause for a few seconds. Needs a control-scope token and a Portveil-agent device.",
115
142
  inputSchema: { device: deviceArg },
116
143
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
117
144
  }, ({ device }) => run(async () => {
@@ -125,7 +152,7 @@ server.registerTool("reconnect_device", {
125
152
  }));
126
153
  server.registerTool("disconnect_device", {
127
154
  title: "Disconnect device",
128
- description: "Turn off a device's VPN tunnel. Its traffic stops going through Portveil until it reconnects. Confirm with the user before using this.",
155
+ description: "Turn off a device's VPN tunnel. Its traffic stops going through Portveil (and loses VPN protection) until it reconnects. The device stays on the account. Confirm with the user first. Needs a control-scope token and a Portveil-agent device.",
129
156
  inputSchema: { device: deviceArg },
130
157
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
131
158
  }, ({ device }) => run(async () => {
@@ -137,24 +164,71 @@ server.registerTool("disconnect_device", {
137
164
  return `Sent. ${d.name} hasn't picked it up yet (it may be offline).`;
138
165
  return `${d.name} did not disconnect: ${st.status}${st.result ? ` (${st.result})` : ""}.`;
139
166
  }));
140
- server.registerTool("recent_activity", {
141
- title: "Recent activity",
142
- description: "Show recent actions on the account (moves, reconnects, renames, tokens created), newest first, with who issued each.",
143
- inputSchema: { limit: z.number().int().min(1).max(100).default(20).describe("How many entries (1–100)") },
144
- annotations: { readOnlyHint: true, openWorldHint: false },
145
- }, ({ limit }) => run(async () => {
146
- const [{ entries }, devices] = await Promise.all([pv.audit(limit), pv.devices()]);
147
- if (entries.length === 0)
148
- return "No activity yet.";
149
- const name = (id) => (id ? devices.find((d) => d.device_id === id)?.name ?? id : "account");
150
- 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");
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.`;
151
206
  }));
152
- server.registerTool("account_info", {
153
- title: "Account info",
154
- description: "Show the account's plan and how many devices it uses out of its limit.",
155
- annotations: { readOnlyHint: true, openWorldHint: false },
156
- }, () => run(async () => {
157
- const a = await pv.account();
158
- return `Plan: ${a.plan ?? "none"}. Devices: ${a.device_count}${a.device_limit ? ` of ${a.device_limit}` : ""}.`;
207
+ server.registerTool("update_device", {
208
+ title: "Update device",
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.",
210
+ inputSchema: {
211
+ device: deviceArg,
212
+ name: z.string().min(1).max(64).optional().describe("New display name, 1–64 characters"),
213
+ remote_control: z.boolean().optional().describe("true lets tokens move and control this device; false blocks it"),
214
+ },
215
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
216
+ }, ({ device, name, remote_control }) => run(async () => {
217
+ if (name === undefined && remote_control === undefined)
218
+ throw new PortveilError("Nothing to change: give a new name, remote_control, or both.");
219
+ const d = resolveDevice(device, await pv.devices());
220
+ const u = await pv.updateDevice(d.device_id, { name, allow_remote: remote_control });
221
+ const parts = [name !== undefined ? `renamed to "${u.name ?? name}"` : "", remote_control !== undefined ? `remote control ${remote_control ? "on" : "off"}` : ""].filter(Boolean);
222
+ return `${d.name}: ${parts.join(", ")}.`;
223
+ }));
224
+ server.registerTool("remove_device", {
225
+ title: "Remove device",
226
+ description: "Permanently remove a device from the account: its VPN key stops working at every exit and its slot is freed. It can't be undone; the device would have to be set up again. Confirm with the user first, naming the device. Needs an admin-scope token.",
227
+ inputSchema: { device: deviceArg },
228
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
229
+ }, ({ device }) => run(async () => {
230
+ const d = resolveDevice(device, await pv.devices());
231
+ await pv.removeDevice(d.device_id);
232
+ return `${d.name} was removed from the account. Its VPN key no longer works.`;
159
233
  }));
160
234
  await server.connect(new StdioServerTransport());
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.2.1";
3
+ import { generateKeyPairSync } from "node:crypto";
4
+ export const VERSION = "0.4.0";
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). */
@@ -142,6 +168,33 @@ export class Portveil {
142
168
  const body = everyMinutes === null ? { every_minutes: null } : servers?.length ? { every_minutes: everyMinutes, servers } : { every_minutes: everyMinutes };
143
169
  return this.call("PUT", `${this.acct()}/devices/${encodeURIComponent(deviceId)}/rotation`, body);
144
170
  }
171
+ /** Rename a device and/or switch remote control. Needs an admin-scope token. */
172
+ async updateDevice(deviceId, change) {
173
+ const body = Object.fromEntries(Object.entries(change).filter(([, v]) => v !== undefined));
174
+ return this.adminOnly(() => this.call("PATCH", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`, body));
175
+ }
176
+ /** Register a device with a public key made elsewhere. Needs an admin-scope token. */
177
+ async addDevice(name, platform, pubkey, allowRemote, ttlMinutes) {
178
+ const body = { name, platform, peer_pubkey: pubkey, allow_remote: allowRemote };
179
+ if (ttlMinutes !== undefined)
180
+ body.ttl_minutes = ttlMinutes;
181
+ return this.adminOnly(() => this.call("POST", `${this.acct()}/devices`, body));
182
+ }
183
+ /** Remove a device for good (its key stops working everywhere). Needs an admin-scope token. */
184
+ async removeDevice(deviceId) {
185
+ await this.adminOnly(() => this.call("DELETE", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`));
186
+ }
187
+ async adminOnly(fn) {
188
+ try {
189
+ return await fn();
190
+ }
191
+ catch (e) {
192
+ if (e instanceof PortveilError && /refused this with your token|isn't allowed to do that/.test(e.message)) {
193
+ 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.');
194
+ }
195
+ throw e;
196
+ }
197
+ }
145
198
  async commandState(id) {
146
199
  return this.call("GET", `${this.acct()}/commands/${encodeURIComponent(id)}`);
147
200
  }
@@ -176,7 +229,7 @@ export class Portveil {
176
229
  return `${device.name} is already exiting in ${place}; nothing to do.`;
177
230
  const st = await this.runCommand(device, "switch_server", target);
178
231
  if (st.status === "delivered") {
179
- return `${device.name} is switching to ${place} but hasn't reported back yet. Check with device_status in a minute.`;
232
+ return `${device.name} is switching to ${place} but hasn't reported back yet. Check with get_device in a minute.`;
180
233
  }
181
234
  if (isWaiting(st.status)) {
182
235
  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.`;
@@ -186,6 +239,6 @@ export class Portveil {
186
239
  const confirmed = await this.waitConfirmed(device.device_id, target.id);
187
240
  return confirmed
188
241
  ? `Done. ${device.name} now exits in ${place}, confirmed by the ${place} exit server.`
189
- : `${device.name} switched to ${place}, but that exit hasn't confirmed the connection yet. Check again with device_status in a minute.`;
242
+ : `${device.name} switched to ${place}, but that exit hasn't confirmed the connection yet. Check again with get_device in a minute.`;
190
243
  }
191
244
  }
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.2.1",
4
+ "version": "0.4.0",
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",