portveil-mcp 0.4.1 → 0.4.3
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 +16 -0
- package/dist/index.js +10 -7
- package/dist/portveil.js +39 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ Devices can be named loosely ("scraper" finds "Scraper box"); an ambiguous name
|
|
|
34
34
|
|
|
35
35
|
## Setup
|
|
36
36
|
|
|
37
|
+
No account yet? [Start free](https://portveil.com/free/) with just an email, no card.
|
|
38
|
+
|
|
37
39
|
1. In the [Portveil dashboard](https://portveil.com/dashboard/), create an **API token**. Choose **control** scope to let the assistant move devices, or **read** to let it only look. Don't give it your account key.
|
|
38
40
|
2. Note your account ID (`acct_…`).
|
|
39
41
|
3. Add the server to your assistant:
|
|
@@ -56,6 +58,20 @@ claude mcp add portveil -e PORTVEIL_ACCOUNT_ID=acct_… -e PORTVEIL_TOKEN=clt_
|
|
|
56
58
|
}
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
**Hermes Agent**: Hermes only installs npm packages older than 14 days, so install into its own folder and run it with node:
|
|
62
|
+
```bash
|
|
63
|
+
cd ~ && npm install --prefix ~/.hermes/mcp-servers/portveil portveil-mcp@0.4.3
|
|
64
|
+
echo 'PORTVEIL_TOKEN=clt_…' >> ~/.hermes/.env
|
|
65
|
+
hermes mcp add portveil --command node \
|
|
66
|
+
--env PORTVEIL_ACCOUNT_ID=acct_… 'PORTVEIL_TOKEN=${PORTVEIL_TOKEN}' \
|
|
67
|
+
--args ~/.hermes/mcp-servers/portveil/node_modules/portveil-mcp/dist/index.js
|
|
68
|
+
```
|
|
69
|
+
For Hermes to use it well (when to act, what to confirm first, how to add devices), also install the Portveil skill from [ClawHub](https://clawhub.ai/roybogs/portveil):
|
|
70
|
+
```bash
|
|
71
|
+
hermes skills install roybogs/portveil
|
|
72
|
+
```
|
|
73
|
+
(Source: [`skills/portveil/SKILL.md`](skills/portveil/SKILL.md).)
|
|
74
|
+
|
|
59
75
|
Devices must be running the Portveil app or the Portveil agent with remote control on. Devices using the plain WireGuard app are shown but can't be moved.
|
|
60
76
|
|
|
61
77
|
## Security
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { z } from "zod";
|
|
|
13
13
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
|
-
import { Portveil, PortveilError, VERSION, agentSetup, describeDevice, wgConfig, wgKeypair, isWaiting, locationLabel, nextLocation, resolveDevice, resolveLocation, } from "./portveil.js";
|
|
16
|
+
import { Portveil, PortveilError, VERSION, agentSetup, describeDevice, wgConfig, wgKeypair, isWaiting, localHint, locationLabel, nextLocation, resolveDevice, resolveLocation, } from "./portveil.js";
|
|
17
17
|
const accountId = process.env.PORTVEIL_ACCOUNT_ID?.trim() ?? "";
|
|
18
18
|
const token = process.env.PORTVEIL_TOKEN?.trim() ?? "";
|
|
19
19
|
if (!/^acct_[0-9a-f]{16}$/.test(accountId) || !token) {
|
|
@@ -52,12 +52,15 @@ server.registerTool("list_devices", {
|
|
|
52
52
|
}));
|
|
53
53
|
server.registerTool("list_locations", {
|
|
54
54
|
title: "List locations",
|
|
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.",
|
|
55
|
+
description: "List the exit locations (country, city and location ID) that devices can be moved to, with each one's timezone and language settings. Use it before move_device or start_rotation when you're unsure what's available, or to make a browser on a moved machine match its exit (sites compare the browser's timezone with its IP's country). Every plan can use every location. Read-only.",
|
|
56
56
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
57
|
-
}, () => run(async () => (await pv.servers()).map((s) =>
|
|
57
|
+
}, () => run(async () => (await pv.servers()).map((s) => {
|
|
58
|
+
const hint = localHint(s);
|
|
59
|
+
return `- ${locationLabel(s)} [${s.id}]${hint ? `: ${hint}` : ""}`;
|
|
60
|
+
}).join("\n")));
|
|
58
61
|
server.registerTool("get_device", {
|
|
59
62
|
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.",
|
|
63
|
+
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, whether an agent machine's clock matches its exit's timezone, and when it last reported. Use it to check a device before or after an action. Read-only.",
|
|
61
64
|
inputSchema: { device: deviceArg },
|
|
62
65
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
63
66
|
}, ({ device }) => run(async () => {
|
|
@@ -172,7 +175,7 @@ server.registerTool("add_device", {
|
|
|
172
175
|
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
176
|
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
177
|
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
|
|
178
|
+
split_tunnel: z.boolean().optional().describe("linux_machine only, advanced: default false (full tunnel), which sends everything the machine connects to through Portveil and keeps SSH and other incoming connections on their normal route, so remote servers stay reachable. true only reaches Portveil's own network: the machine's internet traffic would NOT go through Portveil"),
|
|
176
179
|
},
|
|
177
180
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
178
181
|
}, ({ name, kind, temporary_minutes, folder, split_tunnel }) => run(async () => {
|
|
@@ -183,8 +186,8 @@ server.registerTool("add_device", {
|
|
|
183
186
|
}
|
|
184
187
|
if (kind === "linux_machine") {
|
|
185
188
|
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 ??
|
|
187
|
-
`\n\
|
|
189
|
+
agentSetup(apiBase, accountId, name, split_tunnel ?? false) +
|
|
190
|
+
`\n\nIts own traffic then goes through Portveil, while SSH and other incoming connections keep their normal route, so a remote session stays up. The 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
191
|
}
|
|
189
192
|
const dir = resolve(folder?.replace(/^~(?=$|\/)/, homedir()) ?? join(homedir(), "Portveil", name.replace(/[^\w .-]+/g, "_")));
|
|
190
193
|
const k = wgKeypair();
|
package/dist/portveil.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
3
|
import { generateKeyPairSync } from "node:crypto";
|
|
4
|
-
export const VERSION = "0.4.
|
|
4
|
+
export const VERSION = "0.4.3";
|
|
5
5
|
/** "↓ 42.3 Mbps ↑ 6.1 Mbps", or "idle" when nothing is moving. */
|
|
6
6
|
export function describeSpeed(speed) {
|
|
7
7
|
if (speed.down_mbps < 0.1 && speed.up_mbps < 0.1)
|
|
@@ -24,7 +24,7 @@ export function wgConfig(priv, address, s) {
|
|
|
24
24
|
export function agentSetup(apiBase, accountId, name, splitTunnel) {
|
|
25
25
|
const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
|
|
26
26
|
return [
|
|
27
|
-
"apt install -y wireguard-tools curl",
|
|
27
|
+
"apt install -y wireguard-tools curl nftables",
|
|
28
28
|
"curl -fsSLO https://portveil.com/downloads/portveil-agent",
|
|
29
29
|
"curl -fsSL https://portveil.com/downloads/portveil-agent.sha256 | sha256sum -c -",
|
|
30
30
|
"chmod +x portveil-agent",
|
|
@@ -39,9 +39,40 @@ export class PortveilError extends Error {
|
|
|
39
39
|
export const isWaiting = (status) => status === "queued" || status === "delivered" || status === "pending";
|
|
40
40
|
// Friendly names for the exits we run; anything new falls back to the server's own name.
|
|
41
41
|
const KNOWN = {
|
|
42
|
-
"srv-us-1": { country: "United States", code: "US", place: "US West", aliases: ["usa", "america", "united states", "us", "us west", "west"]
|
|
43
|
-
|
|
42
|
+
"srv-us-1": { country: "United States", code: "US", place: "US West", aliases: ["usa", "america", "united states", "us", "us west", "west"],
|
|
43
|
+
timezone: "America/Los_Angeles", locale: "en-US", accept_language: "en-US,en;q=0.9" },
|
|
44
|
+
"srv-eu-1": { country: "Finland", code: "FI", place: "Helsinki", aliases: ["finland", "fi", "helsinki", "eu", "europe"],
|
|
45
|
+
timezone: "Europe/Helsinki", locale: "fi-FI", accept_language: "en-US,en;q=0.9,fi;q=0.8" },
|
|
44
46
|
};
|
|
47
|
+
/** What a local browser at this exit looks like: the API's values, else the built-in ones. */
|
|
48
|
+
export function localProfile(s) {
|
|
49
|
+
const k = KNOWN[s.id];
|
|
50
|
+
const timezone = s.timezone ?? k?.timezone;
|
|
51
|
+
if (!timezone)
|
|
52
|
+
return undefined;
|
|
53
|
+
return { timezone, locale: s.locale ?? k?.locale ?? "", accept_language: s.accept_language ?? k?.accept_language ?? "" };
|
|
54
|
+
}
|
|
55
|
+
/** One line an agent can act on so its browser matches the exit. Match the timezone;
|
|
56
|
+
* keep the language English-first so pages stay readable. */
|
|
57
|
+
export function localHint(s) {
|
|
58
|
+
const p = localProfile(s);
|
|
59
|
+
if (!p)
|
|
60
|
+
return "";
|
|
61
|
+
const lang = p.accept_language ? `; keep the browser's language English-first (Accept-Language: ${p.accept_language})` : "";
|
|
62
|
+
const local = p.locale && !p.locale.startsWith("en") ? `; the local language is ${p.locale}, only for pages that should be in it` : "";
|
|
63
|
+
return `timezone ${p.timezone}${lang}${local}`;
|
|
64
|
+
}
|
|
65
|
+
/** Whether the machine's own clock matches its exit (agent machines only). */
|
|
66
|
+
export function clockNote(d, where) {
|
|
67
|
+
if (!d.connected || !d.timezone || !where)
|
|
68
|
+
return "";
|
|
69
|
+
const want = localProfile(where)?.timezone;
|
|
70
|
+
if (!want)
|
|
71
|
+
return "";
|
|
72
|
+
return d.timezone === want
|
|
73
|
+
? `clock ${d.timezone}, matching the exit`
|
|
74
|
+
: `clock set to ${d.timezone}, not the exit's ${want}: sites can see the mismatch, so set a browser on it to ${want}`;
|
|
75
|
+
}
|
|
45
76
|
export function locationLabel(s) {
|
|
46
77
|
const k = KNOWN[s.id];
|
|
47
78
|
return k ? `${k.country} (${k.place})` : s.name;
|
|
@@ -64,6 +95,9 @@ export function describeDevice(d, servers) {
|
|
|
64
95
|
extras.push(`speed ${describeSpeed(d.speed)}`);
|
|
65
96
|
if (d.rotation)
|
|
66
97
|
extras.push(`rotates every ${d.rotation.every_minutes} min`);
|
|
98
|
+
const clock = clockNote(d, where);
|
|
99
|
+
if (clock)
|
|
100
|
+
extras.push(clock);
|
|
67
101
|
if (d.expires_at)
|
|
68
102
|
extras.push(`temporary, deleted ${new Date(d.expires_at * 1000).toISOString().replace(".000Z", "Z")}`);
|
|
69
103
|
return `${d.name} [${d.device_id}] (${d.platform}): ${state}; ${remote}${extras.length ? `; ${extras.join("; ")}` : ""}`;
|
|
@@ -240,7 +274,7 @@ export class Portveil {
|
|
|
240
274
|
return `${device.name} did not move: ${st.status}${st.result ? ` (${st.result})` : ""}. It is still on its previous location.`;
|
|
241
275
|
const confirmed = await this.waitConfirmed(device.device_id, target.id);
|
|
242
276
|
return confirmed
|
|
243
|
-
? `Done. ${device.name} now exits in ${place}, confirmed by the ${place} exit server.`
|
|
277
|
+
? `Done. ${device.name} now exits in ${place}, confirmed by the ${place} exit server.${localHint(target) ? ` If a browser on it should look local, use ${localHint(target)}.` : ""}`
|
|
244
278
|
: `${device.name} switched to ${place}, but that exit hasn't confirmed the connection yet. Check again with get_device in a minute.`;
|
|
245
279
|
}
|
|
246
280
|
}
|
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.
|
|
4
|
+
"version": "0.4.3",
|
|
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",
|