portveil-mcp 0.1.1 → 0.2.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
@@ -5,6 +5,7 @@ Let an AI assistant see the devices on your [Portveil](https://portveil.com) acc
5
5
  > "Move my scraper box to Finland."
6
6
  > "Rotate every agent to a new location."
7
7
  > "Which of my devices aren't protected right now?"
8
+ > "Rotate the scraper between the US and Finland every 15 minutes."
8
9
 
9
10
  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
 
@@ -19,8 +20,10 @@ Moves are verified: a tool only reports success after the device has switched **
19
20
  | `recent_activity` | Recent moves, reconnects and changes, and who made them | read |
20
21
  | `move_device` | Move a device to a country or city ("Finland", "US", "Helsinki") | control |
21
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 |
24
+ | `stop_rotation` | Turn scheduled rotation off | control |
22
25
  | `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 |
26
+ | `disconnect_device` | Turn a device's VPN off (flagged destructive: a hint that tells well-behaved assistants to check with you first) | control |
24
27
 
25
28
  Devices can be named loosely ("scraper" finds "Scraper box"); an ambiguous name returns the choices instead of guessing.
26
29
 
package/dist/index.js CHANGED
@@ -81,6 +81,34 @@ server.registerTool("rotate_device", {
81
81
  const d = resolveDevice(device, devices);
82
82
  return pv.move(d, nextLocation(d.server_id, servers));
83
83
  }));
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.",
87
+ inputSchema: {
88
+ 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.'),
91
+ },
92
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
93
+ }, ({ device, every_minutes, locations }) => run(async () => {
94
+ const [devices, servers] = await Promise.all([pv.devices(), pv.servers()]);
95
+ const d = resolveDevice(device, devices);
96
+ const chosen = locations?.map((l) => resolveLocation(l, servers));
97
+ const r = await pv.setRotation(d.device_id, every_minutes, chosen?.map((s) => s.id));
98
+ const where = chosen ? chosen.map(locationLabel).join(" → ") : "every location";
99
+ const next = r.next_rotation_at ? ` First move around ${new Date(r.next_rotation_at * 1000).toISOString().replace(".000Z", "Z")}.` : "";
100
+ return `${d.name} will now move every ${every_minutes} minutes, cycling through ${where}.${next}`;
101
+ }));
102
+ server.registerTool("stop_rotation", {
103
+ title: "Stop scheduled rotation",
104
+ description: "Turn off automatic rotation for a device. It stays at its current location.",
105
+ inputSchema: { device: deviceArg },
106
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
107
+ }, ({ device }) => run(async () => {
108
+ const d = resolveDevice(device, await pv.devices());
109
+ await pv.setRotation(d.device_id, null);
110
+ return `${d.name} will no longer rotate automatically. It stays where it is.`;
111
+ }));
84
112
  server.registerTool("reconnect_device", {
85
113
  title: "Reconnect device",
86
114
  description: "Tell a device to re-establish its VPN tunnel at its current location. Useful when it shows as connected but not confirmed.",
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.1.1";
3
+ export const VERSION = "0.2.0";
4
4
  export class PortveilError extends Error {
5
5
  }
6
6
  /** A command the device hasn't finished yet: queued (not picked up) or delivered (working on it). */
@@ -25,7 +25,12 @@ export function describeDevice(d, servers) {
25
25
  else
26
26
  state = `connected to ${place}, not yet confirmed by the exit`;
27
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}`;
28
+ const extras = [];
29
+ if (d.rotation)
30
+ extras.push(`rotates every ${d.rotation.every_minutes} min`);
31
+ if (d.expires_at)
32
+ extras.push(`temporary, deleted ${new Date(d.expires_at * 1000).toISOString().replace(".000Z", "Z")}`);
33
+ return `${d.name} [${d.device_id}] (${d.platform}): ${state}; ${remote}${extras.length ? `; ${extras.join("; ")}` : ""}`;
29
34
  }
30
35
  /** Match a device by id, exact name, or a unique partial name (case-insensitive). */
31
36
  export function resolveDevice(query, devices) {
@@ -106,6 +111,7 @@ export class Portveil {
106
111
  ? "Portveil rejected the token. Check PORTVEIL_TOKEN (an API token from the dashboard)."
107
112
  : '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
113
  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.`);
114
+ case 400: throw new PortveilError(detail || "Portveil rejected the request.");
109
115
  case 404: throw new PortveilError("Not found. Check PORTVEIL_ACCOUNT_ID matches the token's account.");
110
116
  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
117
  case 429: throw new PortveilError(`Portveil is rate limiting requests; try again in ${res.headers.get("retry-after") ?? "a few"} seconds.`);
@@ -123,6 +129,11 @@ export class Portveil {
123
129
  const c = await this.call("POST", `${this.acct()}/devices/${encodeURIComponent(deviceId)}/commands`, serverId ? { type, server_id: serverId } : { type });
124
130
  return { command_id: c.command_id, status: c.status, result: null };
125
131
  }
132
+ /** Turn scheduled rotation on (every N minutes, optionally among some locations) or off (null). */
133
+ async setRotation(deviceId, everyMinutes, servers) {
134
+ const body = everyMinutes === null ? { every_minutes: null } : servers?.length ? { every_minutes: everyMinutes, servers } : { every_minutes: everyMinutes };
135
+ return this.call("PUT", `${this.acct()}/devices/${encodeURIComponent(deviceId)}/rotation`, body);
136
+ }
126
137
  async commandState(id) {
127
138
  return this.call("GET", `${this.acct()}/commands/${encodeURIComponent(id)}`);
128
139
  }
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.1.1",
4
+ "version": "0.2.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",