@yawlabs/tailscale-mcp 0.23.0 → 0.23.1
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 +6 -6
- package/bin/tailscale-mcp.mjs +25 -7
- package/dist/index.js +20 -6
- package/package.json +1 -3
package/README.md
CHANGED
|
@@ -732,17 +732,17 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
732
732
|
|
|
733
733
|
## Running on oam.js (optional)
|
|
734
734
|
|
|
735
|
-
[oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.
|
|
735
|
+
[oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.19.1**. Verified against the published oam 0.19.1: full MCP handshake, all 97 admin-API tools plus `tailscale_tool_groups`, all 4 resources, identical error responses, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
|
|
736
736
|
|
|
737
|
-
**oam 0.
|
|
737
|
+
**oam 0.19.1 is the minimum.** A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument, and this server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one.
|
|
738
738
|
|
|
739
739
|
How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
|
|
740
740
|
|
|
741
|
-
- **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.
|
|
741
|
+
- **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.19.1 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.19.1 or newer, else asks every oam binary it can find — `OAM_INSTALL_DIR` when that is set, then `%LOCALAPPDATA%\oam\bin` and `~/.oam/bin` on Windows, `~/.oam/bin` elsewhere, then `PATH` — for its version and uses the newest at or above the floor (on a tie the installed copy wins). With none, it runs on Node. An oam host older than 0.19.1 never serves the server itself: it hands off to the newest usable oam, or to Node on `PATH`, or exits with an error when there is neither. Whenever it looks for an oam, stderr names an `OAM_BIN` that was passed over and why; the oam binaries it found and passed over are named, each with its reason, only when no usable oam turns up.
|
|
742
742
|
- **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node. The error names the remedy for what it actually found: `oam self-update` for an oam older than the floor, a check of the binary for one that would not run, and installing oam only when none was found (and on linux-arm64, which oam publishes no build for, not even then).
|
|
743
743
|
- **`TAILSCALE_MCP_RUNTIME=node`** — always Node: in-process under `npx`, handed off to Node on `PATH` when a client launches the command with `oam run`.
|
|
744
744
|
|
|
745
|
-
The value is case-insensitive; anything else is warned about on stderr and treated as `auto`. On Windows only `oam.exe` counts: an `oam.cmd` / `oam.bat` shim is never run, and it is named on stderr only when no usable oam turns up.
|
|
745
|
+
The value is case-insensitive and surrounding whitespace is ignored; anything else is warned about on stderr and treated as `auto`. On Windows only `oam.exe` counts: an `oam.cmd` / `oam.bat` shim is never run, and it is named on stderr only when no usable oam turns up.
|
|
746
746
|
|
|
747
747
|
### Sandboxing (opt-in)
|
|
748
748
|
|
|
@@ -767,14 +767,14 @@ The sandbox applies to the CLI subcommands too, and with the filesystem denied `
|
|
|
767
767
|
}
|
|
768
768
|
```
|
|
769
769
|
|
|
770
|
-
**Measure startup on your own hardware.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs
|
|
770
|
+
**Measure startup on your own hardware.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs were taken with an oam older than 0.18.0 and have not been repeated since, so do not read them as a current ranking.
|
|
771
771
|
|
|
772
772
|
The published `tailscale-mcp` command prefers the newest usable oam it finds (see above). Without oam that costs almost nothing: discovery is file-existence checks only, never a subprocess, and the fallback runs the server inside the Node process npm already started. With oam installed, though, the command boots Node, runs `--version` on every oam binary it found to pick the newest, and only then boots oam, so it is always slower than pointing your client at a runtime directly — the config above for oam, `node /path/to/tailscale-mcp/dist/index.js` for Node. `TAILSCALE_MCP_RUNTIME=node` skips oam entirely.
|
|
773
773
|
|
|
774
774
|
Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
|
|
775
775
|
|
|
776
776
|
- **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 4015ms against 7680ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default.
|
|
777
|
-
- **`npm run build:binary:oam`** — builds the standalone binary via `oam compile`
|
|
777
|
+
- **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` (~57.7 MB measured). Writes `bin/<platform>-<arch>/` (or `bin/$TAILSCALE_MCP_BINARY_TARGET/` for a cross-build), which `scripts/stage-release-asset.mjs` then stages under the public asset name. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it. A cross-build (`TAILSCALE_MCP_BINARY_TARGET=<platform>-<arch>`) downloads the target's oam release binary as its carrier and checks it against the release's signed `RELEASE-MANIFEST`, verified with `ssh-keygen -Y verify` (OpenSSH 8.1+) against oam's release keys in `scripts/oam-release-keys/`; it aborts when the signature, the manifest or the checksum does not check out. An `OAM_VERSION` below v0.18.0, released before oam signed its releases, is checked against its `SHA256SUMS` only once that file matches the digest pinned for the tag in `scripts/oam-release-keys/presigning-sums`.
|
|
778
778
|
|
|
779
779
|
The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal. Note that any `oam` invocation writes a bytecode cache to `oam/` in the working directory — already in `.gitignore`.
|
|
780
780
|
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
* shipped bundle; keep it in step.
|
|
106
106
|
*
|
|
107
107
|
* MINIMUM OAM VERSION
|
|
108
|
-
* The latest oam release, 0.
|
|
108
|
+
* The latest oam release, 0.19.1 -- bump OAM_MIN when oam ships a newer one.
|
|
109
109
|
* Only the current oam is used and verified; an older one is passed over for a
|
|
110
110
|
* newer oam, or for Node. The floor is not cosmetic: before 0.9.0
|
|
111
111
|
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
@@ -138,7 +138,7 @@ import { delimiter, join } from "node:path";
|
|
|
138
138
|
import { fileURLToPath } from "node:url";
|
|
139
139
|
|
|
140
140
|
/** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
|
|
141
|
-
const OAM_MIN = [0,
|
|
141
|
+
const OAM_MIN = [0, 19, 1];
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* The oldest Node this package supports, matching package.json `engines.node`.
|
|
@@ -353,6 +353,15 @@ function fallbackInProcess(hostOam) {
|
|
|
353
353
|
return hostOam === undefined || atLeast(parseVersion(hostOam), OAM_MIN);
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/**
|
|
357
|
+
* True when an on/off opt-in is set: "1" or "true", any case, surrounding
|
|
358
|
+
* whitespace ignored -- cmd.exe's `set FOO=1 && ...` stores "1 ". The same rule
|
|
359
|
+
* as envFlagOn in src/env-flag.ts; this file is plain .mjs and cannot import it.
|
|
360
|
+
*/
|
|
361
|
+
function envFlagOn(value) {
|
|
362
|
+
return /^(1|true)$/i.test((value ?? "").trim());
|
|
363
|
+
}
|
|
364
|
+
|
|
356
365
|
/**
|
|
357
366
|
* The `--permission` grant list, or [] when the sandbox is not requested.
|
|
358
367
|
*
|
|
@@ -360,7 +369,7 @@ function fallbackInProcess(hostOam) {
|
|
|
360
369
|
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
361
370
|
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
362
371
|
*
|
|
363
|
-
* Net grants,
|
|
372
|
+
* Net grants, as of oam 0.18.0: an entry without a port admits that
|
|
364
373
|
* host on every port, and a `host:port` entry admits that port alone -- for
|
|
365
374
|
* fetch and https.request exactly as for sockets. Up to 0.17.1 a port-scoped
|
|
366
375
|
* entry admitted no HTTP request at all, which is why this used to be a bare
|
|
@@ -370,7 +379,7 @@ function fallbackInProcess(hostOam) {
|
|
|
370
379
|
* it produces silent misbehaviour, not a clear denial.
|
|
371
380
|
*/
|
|
372
381
|
function sandboxFlags() {
|
|
373
|
-
if (process.env.TAILSCALE_MCP_SANDBOX
|
|
382
|
+
if (!envFlagOn(process.env.TAILSCALE_MCP_SANDBOX)) return [];
|
|
374
383
|
|
|
375
384
|
// ONE host, deliberately. Every outbound request the bundle makes targets
|
|
376
385
|
// api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
|
|
@@ -858,11 +867,12 @@ if (nodeFloorMessage) {
|
|
|
858
867
|
// which is the opposite of what was asked for. Same handling as index.ts gives
|
|
859
868
|
// an unknown subcommand: auto is still the right landing place, it just stops
|
|
860
869
|
// being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
|
|
861
|
-
// wrapper script is how it usually gets there
|
|
870
|
+
// wrapper script is how it usually gets there, and surrounding whitespace is
|
|
871
|
+
// dropped first: cmd.exe's `set TAILSCALE_MCP_RUNTIME=node && ...` stores "node ".
|
|
862
872
|
const RUNTIMES = ["auto", "node", "oam"];
|
|
863
873
|
const requested = process.env.TAILSCALE_MCP_RUNTIME;
|
|
864
|
-
const mode = (requested
|
|
865
|
-
if (requested && !RUNTIMES.includes(mode)) {
|
|
874
|
+
const mode = (requested?.trim() || "auto").toLowerCase();
|
|
875
|
+
if (requested?.trim() && !RUNTIMES.includes(mode)) {
|
|
866
876
|
// Echo what was SET, not the lowercased form, so the typo is recognisable in
|
|
867
877
|
// the host's log next to the config line that produced it.
|
|
868
878
|
errSync(
|
|
@@ -876,6 +886,14 @@ const hostOam = process.versions.oam;
|
|
|
876
886
|
// "would the spawn carry --permission" cannot drift from what the spawn below
|
|
877
887
|
// actually passes.
|
|
878
888
|
const sandbox = sandboxFlags();
|
|
889
|
+
// A security opt-in that did not take must not be silent: "yes" or "on" reads as
|
|
890
|
+
// a request for the sandbox, and serving unsandboxed without a word would hide it.
|
|
891
|
+
const sandboxRaw = (process.env.TAILSCALE_MCP_SANDBOX ?? "").trim();
|
|
892
|
+
if (sandbox.length === 0 && sandboxRaw !== "") {
|
|
893
|
+
errSync(
|
|
894
|
+
`tailscale-mcp: TAILSCALE_MCP_SANDBOX="${process.env.TAILSCALE_MCP_SANDBOX}" is not 1 or true -- the sandbox is NOT enabled.\n`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
879
897
|
const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
|
|
880
898
|
|
|
881
899
|
if (plan === "in-process") {
|
package/dist/index.js
CHANGED
|
@@ -31350,6 +31350,15 @@ var StdioServerTransport = class {
|
|
|
31350
31350
|
}
|
|
31351
31351
|
};
|
|
31352
31352
|
|
|
31353
|
+
// src/env-flag.ts
|
|
31354
|
+
function envFlagOn(value) {
|
|
31355
|
+
return /^(1|true)$/i.test((value ?? "").trim());
|
|
31356
|
+
}
|
|
31357
|
+
function securityOptInWarning(name, value) {
|
|
31358
|
+
if (envFlagOn(value) || (value ?? "").trim() === "") return null;
|
|
31359
|
+
return `${name}=${JSON.stringify(value)} is not 1 or true, so it is NOT enabled.`;
|
|
31360
|
+
}
|
|
31361
|
+
|
|
31353
31362
|
// src/api.ts
|
|
31354
31363
|
var BASE_URL = "https://api.tailscale.com/api/v2";
|
|
31355
31364
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
@@ -31646,7 +31655,7 @@ async function withConcurrencyLimit(fn) {
|
|
|
31646
31655
|
}
|
|
31647
31656
|
}
|
|
31648
31657
|
function debugLog(...parts) {
|
|
31649
|
-
if (process.env.TAILSCALE_DEBUG
|
|
31658
|
+
if (envFlagOn(process.env.TAILSCALE_DEBUG)) {
|
|
31650
31659
|
console.error("[tailscale-mcp]", ...parts);
|
|
31651
31660
|
}
|
|
31652
31661
|
}
|
|
@@ -32026,7 +32035,7 @@ function parseGroupList(value) {
|
|
|
32026
32035
|
return parsed.length > 0 ? parsed : null;
|
|
32027
32036
|
}
|
|
32028
32037
|
function parseReadonlyFlag(value) {
|
|
32029
|
-
return value
|
|
32038
|
+
return envFlagOn(value);
|
|
32030
32039
|
}
|
|
32031
32040
|
function filterTools(groups, options) {
|
|
32032
32041
|
const validNames = new Set(Object.keys(groups));
|
|
@@ -33948,7 +33957,8 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33948
33957
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33949
33958
|
await acquireCliSlot();
|
|
33950
33959
|
return new Promise((resolve) => {
|
|
33951
|
-
|
|
33960
|
+
const execOptions = { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES, windowsHide: true };
|
|
33961
|
+
execFileImpl(binary, args, execOptions, (err, stdout, stderr) => {
|
|
33952
33962
|
releaseCliSlot();
|
|
33953
33963
|
const stdoutStr = stdout == null ? "" : String(stdout);
|
|
33954
33964
|
const stderrStr = stderr == null ? "" : String(stderr);
|
|
@@ -35161,10 +35171,10 @@ var webhookTools = [
|
|
|
35161
35171
|
|
|
35162
35172
|
// src/server-wiring.ts
|
|
35163
35173
|
function isLocalCliEnabled(env) {
|
|
35164
|
-
return env.TAILSCALE_LOCAL_CLI
|
|
35174
|
+
return envFlagOn(env.TAILSCALE_LOCAL_CLI);
|
|
35165
35175
|
}
|
|
35166
35176
|
function isRequireApprovalEnabled(env) {
|
|
35167
|
-
return env.TAILSCALE_REQUIRE_APPROVAL
|
|
35177
|
+
return envFlagOn(env.TAILSCALE_REQUIRE_APPROVAL);
|
|
35168
35178
|
}
|
|
35169
35179
|
var FORCED_APPROVAL_TOOLS = [
|
|
35170
35180
|
"tailscale_delete_device",
|
|
@@ -35502,7 +35512,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
|
|
|
35502
35512
|
);
|
|
35503
35513
|
process.exit(1);
|
|
35504
35514
|
}
|
|
35505
|
-
var version2 = true ? "0.23.
|
|
35515
|
+
var version2 = true ? "0.23.1" : resolveVersionFallback();
|
|
35506
35516
|
var subcommand = process.argv[2];
|
|
35507
35517
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
35508
35518
|
|
|
@@ -35575,6 +35585,10 @@ if (!cliSubcommandHandled) {
|
|
|
35575
35585
|
if (tailnetMismatch) {
|
|
35576
35586
|
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35577
35587
|
}
|
|
35588
|
+
const readonlyWarning = securityOptInWarning("TAILSCALE_READONLY", process.env.TAILSCALE_READONLY);
|
|
35589
|
+
if (readonlyWarning) {
|
|
35590
|
+
console.error(`@yawlabs/tailscale-mcp: ${readonlyWarning} Write tools are served.`);
|
|
35591
|
+
}
|
|
35578
35592
|
if (unknownGroups.length > 0) {
|
|
35579
35593
|
const validNames = Object.keys(toolGroups);
|
|
35580
35594
|
const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"mcpName": "io.github.YawLabs/tailscale-mcp",
|
|
5
5
|
"description": "Tailscale MCP server: admin-API tools for devices, ACLs, DNS, auth keys, users, and audit logs, plus a CLI to validate and deploy ACLs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,7 +58,6 @@
|
|
|
58
58
|
"lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/ build.mjs",
|
|
59
59
|
"check:oam": "oam check src/index.ts",
|
|
60
60
|
"check:oam-floor": "node scripts/check-oam-floor.mjs",
|
|
61
|
-
"build:binary": "node scripts/build-binary.mjs",
|
|
62
61
|
"build:binary:oam": "node scripts/build-binary-oam.mjs",
|
|
63
62
|
"prepublishOnly": "npm run clean && npm run build"
|
|
64
63
|
},
|
|
@@ -74,7 +73,6 @@
|
|
|
74
73
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
75
74
|
"@types/node": "^26.0.0",
|
|
76
75
|
"esbuild": "^0.28.1",
|
|
77
|
-
"postject": "^1.0.0-alpha.6",
|
|
78
76
|
"typescript": "^7.0.2",
|
|
79
77
|
"zod": "^4.3.6"
|
|
80
78
|
},
|