portveil-mcp 0.2.1 → 0.3.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,19 @@ 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
+ | `update_device` | Rename a device and/or turn its remote control on or off | admin |
28
+ | `remove_device` | Remove a device for good (flagged destructive) | admin |
29
+
30
+ 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
31
 
28
32
  Devices can be named loosely ("scraper" finds "Scraper box"); an ambiguous name returns the choices instead of guessing.
29
33
 
package/dist/index.js CHANGED
@@ -32,10 +32,13 @@ async function run(fn) {
32
32
  return { content: [{ type: "text", text: msg }], isError: true };
33
33
  }
34
34
  }
35
- const deviceArg = z.string().min(1).describe('Device name (or part of it, e.g. "scraper") or its device ID (dev_…)');
35
+ 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
+ // Tool names follow one verb_noun pattern: list_*, get_*, and an action verb + device/rotation.
37
+ // 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.
36
39
  server.registerTool("list_devices", {
37
40
  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.",
41
+ 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
42
  annotations: { readOnlyHint: true, openWorldHint: false },
40
43
  }, () => run(async () => {
41
44
  const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
@@ -45,12 +48,12 @@ server.registerTool("list_devices", {
45
48
  }));
46
49
  server.registerTool("list_locations", {
47
50
  title: "List locations",
48
- description: "List the VPN exit locations (countries) a device can be moved to.",
51
+ 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
52
  annotations: { readOnlyHint: true, openWorldHint: false },
50
53
  }, () => 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
+ server.registerTool("get_device", {
55
+ title: "Get device",
56
+ 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
57
  inputSchema: { device: deviceArg },
55
58
  annotations: { readOnlyHint: true, openWorldHint: false },
56
59
  }, ({ device }) => run(async () => {
@@ -59,12 +62,32 @@ server.registerTool("device_status", {
59
62
  const seen = d.last_seen_s_ago == null ? "never" : `${Math.round(d.last_seen_s_ago)}s ago`;
60
63
  return `${describeDevice(d, servers)}\nLast report: ${seen}.`;
61
64
  }));
65
+ server.registerTool("get_account", {
66
+ title: "Get account",
67
+ 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.",
68
+ annotations: { readOnlyHint: true, openWorldHint: false },
69
+ }, () => run(async () => {
70
+ const a = await pv.account();
71
+ return `Plan: ${a.plan ?? "none"}. Devices: ${a.device_count}${a.device_limit ? ` of ${a.device_limit}` : ""}.`;
72
+ }));
73
+ server.registerTool("list_activity", {
74
+ title: "List activity",
75
+ 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.",
76
+ inputSchema: { limit: z.number().int().min(1).max(100).default(20).describe("How many entries to return, newest first (1–100, default 20)") },
77
+ annotations: { readOnlyHint: true, openWorldHint: false },
78
+ }, ({ limit }) => run(async () => {
79
+ const [{ entries }, devices] = await Promise.all([pv.audit(limit), pv.devices()]);
80
+ if (entries.length === 0)
81
+ return "No activity yet.";
82
+ const name = (id) => (id ? devices.find((d) => d.device_id === id)?.name ?? id : "account");
83
+ 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");
84
+ }));
62
85
  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.",
86
+ title: "Move device",
87
+ 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
88
  inputSchema: {
66
89
  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)'),
90
+ 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
91
  },
69
92
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
70
93
  }, ({ device, location }) => run(async () => {
@@ -72,8 +95,8 @@ server.registerTool("move_device", {
72
95
  return pv.move(resolveDevice(device, devices), resolveLocation(location, servers));
73
96
  }));
74
97
  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.",
98
+ title: "Rotate device",
99
+ 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
100
  inputSchema: { device: deviceArg },
78
101
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
79
102
  }, ({ device }) => run(async () => {
@@ -81,13 +104,13 @@ server.registerTool("rotate_device", {
81
104
  const d = resolveDevice(device, devices);
82
105
  return pv.move(d, nextLocation(d.server_id, servers));
83
106
  }));
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.",
107
+ server.registerTool("start_rotation", {
108
+ title: "Start rotation",
109
+ 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
110
  inputSchema: {
88
111
  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.'),
112
+ every_minutes: z.number().int().min(5).max(10080).describe("Minutes between moves: 5 to 10080 (one week)"),
113
+ 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
114
  },
92
115
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
93
116
  }, ({ device, every_minutes, locations }) => run(async () => {
@@ -100,8 +123,8 @@ server.registerTool("set_rotation", {
100
123
  return `${d.name} will now move every ${every_minutes} minutes, cycling through ${where}.${next}`;
101
124
  }));
102
125
  server.registerTool("stop_rotation", {
103
- title: "Stop scheduled rotation",
104
- description: "Turn off automatic rotation for a device. It stays at its current location.",
126
+ title: "Stop rotation",
127
+ 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
128
  inputSchema: { device: deviceArg },
106
129
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
107
130
  }, ({ device }) => run(async () => {
@@ -111,7 +134,7 @@ server.registerTool("stop_rotation", {
111
134
  }));
112
135
  server.registerTool("reconnect_device", {
113
136
  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.",
137
+ 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
138
  inputSchema: { device: deviceArg },
116
139
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
117
140
  }, ({ device }) => run(async () => {
@@ -125,7 +148,7 @@ server.registerTool("reconnect_device", {
125
148
  }));
126
149
  server.registerTool("disconnect_device", {
127
150
  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.",
151
+ 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
152
  inputSchema: { device: deviceArg },
130
153
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
131
154
  }, ({ device }) => run(async () => {
@@ -137,24 +160,31 @@ server.registerTool("disconnect_device", {
137
160
  return `Sent. ${d.name} hasn't picked it up yet (it may be offline).`;
138
161
  return `${d.name} did not disconnect: ${st.status}${st.result ? ` (${st.result})` : ""}.`;
139
162
  }));
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");
163
+ server.registerTool("update_device", {
164
+ title: "Update device",
165
+ 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.",
166
+ inputSchema: {
167
+ device: deviceArg,
168
+ name: z.string().min(1).max(64).optional().describe("New display name, 1–64 characters"),
169
+ remote_control: z.boolean().optional().describe("true lets tokens move and control this device; false blocks it"),
170
+ },
171
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
172
+ }, ({ device, name, remote_control }) => run(async () => {
173
+ if (name === undefined && remote_control === undefined)
174
+ throw new PortveilError("Nothing to change: give a new name, remote_control, or both.");
175
+ const d = resolveDevice(device, await pv.devices());
176
+ const u = await pv.updateDevice(d.device_id, { name, allow_remote: remote_control });
177
+ const parts = [name !== undefined ? `renamed to "${u.name ?? name}"` : "", remote_control !== undefined ? `remote control ${remote_control ? "on" : "off"}` : ""].filter(Boolean);
178
+ return `${d.name}: ${parts.join(", ")}.`;
151
179
  }));
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}` : ""}.`;
180
+ server.registerTool("remove_device", {
181
+ title: "Remove device",
182
+ 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.",
183
+ inputSchema: { device: deviceArg },
184
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
185
+ }, ({ device }) => run(async () => {
186
+ const d = resolveDevice(device, await pv.devices());
187
+ await pv.removeDevice(d.device_id);
188
+ return `${d.name} was removed from the account. Its VPN key no longer works.`;
159
189
  }));
160
190
  await server.connect(new StdioServerTransport());
package/dist/portveil.js CHANGED
@@ -1,6 +1,6 @@
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
+ export const VERSION = "0.3.0";
4
4
  /** "↓ 42.3 Mbps ↑ 6.1 Mbps", or "idle" when nothing is moving. */
5
5
  export function describeSpeed(speed) {
6
6
  if (speed.down_mbps < 0.1 && speed.up_mbps < 0.1)
@@ -142,6 +142,26 @@ export class Portveil {
142
142
  const body = everyMinutes === null ? { every_minutes: null } : servers?.length ? { every_minutes: everyMinutes, servers } : { every_minutes: everyMinutes };
143
143
  return this.call("PUT", `${this.acct()}/devices/${encodeURIComponent(deviceId)}/rotation`, body);
144
144
  }
145
+ /** Rename a device and/or switch remote control. Needs an admin-scope token. */
146
+ async updateDevice(deviceId, change) {
147
+ const body = Object.fromEntries(Object.entries(change).filter(([, v]) => v !== undefined));
148
+ return this.adminOnly(() => this.call("PATCH", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`, body));
149
+ }
150
+ /** Remove a device for good (its key stops working everywhere). Needs an admin-scope token. */
151
+ async removeDevice(deviceId) {
152
+ await this.adminOnly(() => this.call("DELETE", `${this.acct()}/devices/${encodeURIComponent(deviceId)}`));
153
+ }
154
+ async adminOnly(fn) {
155
+ try {
156
+ return await fn();
157
+ }
158
+ catch (e) {
159
+ 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.');
161
+ }
162
+ throw e;
163
+ }
164
+ }
145
165
  async commandState(id) {
146
166
  return this.call("GET", `${this.acct()}/commands/${encodeURIComponent(id)}`);
147
167
  }
@@ -176,7 +196,7 @@ export class Portveil {
176
196
  return `${device.name} is already exiting in ${place}; nothing to do.`;
177
197
  const st = await this.runCommand(device, "switch_server", target);
178
198
  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.`;
199
+ return `${device.name} is switching to ${place} but hasn't reported back yet. Check with get_device in a minute.`;
180
200
  }
181
201
  if (isWaiting(st.status)) {
182
202
  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 +206,6 @@ export class Portveil {
186
206
  const confirmed = await this.waitConfirmed(device.device_id, target.id);
187
207
  return confirmed
188
208
  ? `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.`;
209
+ : `${device.name} switched to ${place}, but that exit hasn't confirmed the connection yet. Check again with get_device in a minute.`;
190
210
  }
191
211
  }
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.3.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",