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 +9 -5
- package/dist/index.js +68 -38
- package/dist/portveil.js +23 -3
- package/package.json +1 -1
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
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
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
|
-
| `
|
|
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('
|
|
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
|
|
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
|
|
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("
|
|
52
|
-
title: "
|
|
53
|
-
description: "
|
|
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
|
|
64
|
-
description: "Move a device's
|
|
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
|
|
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
|
|
76
|
-
description: "Move a device to the next
|
|
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("
|
|
85
|
-
title: "
|
|
86
|
-
description: "Make Portveil move a device to the next location automatically every N minutes
|
|
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
|
|
90
|
-
locations: z.array(z.string().min(1)).optional().describe('Locations to cycle through, e.g. ["US", "Finland"]. Omit
|
|
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
|
|
104
|
-
description: "Turn off automatic rotation
|
|
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.
|
|
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
|
|
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("
|
|
141
|
-
title: "
|
|
142
|
-
description: "
|
|
143
|
-
inputSchema: {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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("
|
|
153
|
-
title: "
|
|
154
|
-
description: "
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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",
|