@yawlabs/tailscale-mcp 0.14.0 → 0.16.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
@@ -511,7 +511,15 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
511
511
 
512
512
  ## Running on oam.js (optional)
513
513
 
514
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 89 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
514
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 89 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
515
+
516
+ **oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument. This server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `TAILSCALE_MCP_RUNTIME=oam` turns that into a hard error.
517
+
518
+ ### Sandboxing (opt-in)
519
+
520
+ Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` and `login.tailscale.com`, filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
521
+
522
+ It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `TAILSCALE_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
515
523
 
516
524
  ```jsonc
517
525
  {
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/tailscale-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ *
9
+ * WHY THE FALLBACK COSTS NOTHING
10
+ * npm has already started Node to run this launcher, so falling back is a
11
+ * plain `import()` of the server into THIS process: no extra spawn, no extra
12
+ * startup, byte-identical to invoking dist/index.js directly. Discovery is
13
+ * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
14
+ *
15
+ * WHAT THE OAM PATH COSTS
16
+ * Reaching oam through an npm `bin` means Node boots first and oam boots
17
+ * second, so the launcher is slower than either runtime alone. Measured on
18
+ * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
19
+ * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
20
+ * launcher is the slowest path -- it exists for `npx` convenience.
21
+ *
22
+ * For an MCP host config, point straight at oam and skip this file:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
+ *
25
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
+ * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
27
+ * network limited to the control-plane hosts, filesystem denied.
28
+ *
29
+ * Child-process is granted unconditionally because the local-CLI tools shell out
30
+ * to the `tailscale` binary; that is also why PATH stays in the env grant, since
31
+ * resolving the binary needs it.
32
+ *
33
+ * Opt-in, not default, because a denied environment variable is ABSENT from
34
+ * process.env rather than throwing -- an under-granted TAILSCALE_API_KEY reads as
35
+ * "unauthenticated" rather than "denied". The env list is derived from the
36
+ * shipped bundle; keep it in step.
37
+ *
38
+ * MINIMUM OAM VERSION
39
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
40
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
41
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
42
+ * behaved as `'pipe'`. This server shells out to a CLI on its
43
+ * main paths, so those were reachable bugs rather than theoretical ones: an
44
+ * argument containing shell metacharacters was re-split and executed.
45
+ * An older oam is not an error: the launcher falls back to Node and says so on
46
+ * stderr. Pinning the floor here is what makes that fallback automatic.
47
+ *
48
+ * SELECTION
49
+ * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
50
+ * TAILSCALE_MCP_RUNTIME=node never use oam
51
+ * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
52
+ * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
53
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
54
+ */
55
+
56
+ import { execFileSync, spawn } from "node:child_process";
57
+ import { existsSync } from "node:fs";
58
+ import { constants, homedir } from "node:os";
59
+ import { delimiter, join } from "node:path";
60
+ import { fileURLToPath } from "node:url";
61
+
62
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
63
+ const OAM_MIN = [0, 9, 0];
64
+
65
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
66
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
67
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
68
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
69
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
70
+ const isWin = process.platform === "win32";
71
+ const exe = isWin ? "oam.exe" : "oam";
72
+
73
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
74
+ function findOam() {
75
+ // 1. Explicit override wins and is never second-guessed.
76
+ const override = process.env.OAM_BIN;
77
+ if (override) return existsSync(override) ? override : null;
78
+
79
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
80
+ // usually has oam/target/release on PATH, and a build directory is the
81
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
82
+ // binary underneath running processes, and the dev build is not the
83
+ // release the user installed. Preferring the installed copy makes the
84
+ // default path "what a normal user has", and OAM_BIN remains the way to
85
+ // point deliberately at a dev build.
86
+ //
87
+ // Both forms are checked on Windows: the installer defaults to
88
+ // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
89
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
90
+ // install.
91
+ const installed = [join(homedir(), ".oam", "bin", exe)];
92
+ if (isWin) {
93
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
94
+ }
95
+ for (const candidate of installed) {
96
+ if (existsSync(candidate)) return candidate;
97
+ }
98
+
99
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
100
+ // would cost a subprocess on every launch just to decide whether to spawn.
101
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
102
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
103
+ if (!dir) continue;
104
+ for (const ext of isWin ? pathExt : [""]) {
105
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
106
+ if (existsSync(candidate)) return candidate;
107
+ }
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
115
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
116
+ */
117
+ function oamVersion(cmd) {
118
+ try {
119
+ const out = execFileSync(cmd, ["--version"], {
120
+ encoding: "utf-8",
121
+ stdio: ["ignore", "pipe", "ignore"],
122
+ });
123
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
124
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
125
+ } catch {
126
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
132
+ function atLeast(v, min) {
133
+ if (!v) return false;
134
+ for (let i = 0; i < min.length; i++) {
135
+ if (v[i] > min[i]) return true;
136
+ if (v[i] < min[i]) return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * The `--permission` grant list, or [] when the sandbox is not requested.
143
+ *
144
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
145
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
146
+ * good failure but only because it is loud -- ordering here is load-bearing.
147
+ *
148
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
149
+ * A denied environment variable is ABSENT from process.env rather than throwing,
150
+ * so the env list below is derived from what the bundle actually reads; trimming
151
+ * it produces silent misbehaviour, not a clear denial.
152
+ */
153
+ function sandboxFlags() {
154
+ if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
155
+
156
+ const hosts = ["api.tailscale.com","login.tailscale.com"];
157
+
158
+ const netFlag = `--allow-net=${hosts.join(",")}`;
159
+
160
+ const env = ["PATH","TAILSCALE_API_KEY","TAILSCALE_BINARY","TAILSCALE_DEBUG","TAILSCALE_EXTRA_WEBHOOK_EVENTS","TAILSCALE_MAX_CONCURRENT","TAILSCALE_OAUTH_CLIENT_ID","TAILSCALE_OAUTH_CLIENT_SECRET","TAILSCALE_PROFILE","TAILSCALE_READONLY","TAILSCALE_REQUEST_BUDGET_MS","TAILSCALE_RETRY_BASE_DELAY_MS","TAILSCALE_TAILNET","TAILSCALE_TOOLS"];
161
+
162
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
163
+ flags.push("--allow-child-process");
164
+ return flags;
165
+ }
166
+
167
+ /** Run the server in THIS process. The zero-overhead fallback. */
168
+ async function runInProcess() {
169
+ // A server may gate its bootstrap on being the process ENTRY POINT --
170
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
171
+ // test file can import the module for unit tests without connecting a stdio
172
+ // transport. aws-mcp does exactly this. Importing the server here would leave
173
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
174
+ // server would load but never serve: the MCP handshake just hangs.
175
+ //
176
+ // Point argv[1] at the server first, so the in-process path is
177
+ // indistinguishable from having executed the file directly. The spawn path
178
+ // needs no equivalent -- there argv[1] is already the server.
179
+ process.argv[1] = SERVER_ENTRY;
180
+ await import(SERVER_URL.href);
181
+ }
182
+
183
+ const mode = (process.env.TAILSCALE_MCP_RUNTIME ?? "auto").toLowerCase();
184
+
185
+ if (mode === "node") {
186
+ await runInProcess();
187
+ } else {
188
+ const oam = findOam();
189
+
190
+ if (!oam) {
191
+ if (mode === "oam") {
192
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
193
+ // because stderr is async for TTYs/pipes on Windows and process.exit
194
+ // truncates pending writes.
195
+ const { writeSync } = await import("node:fs");
196
+ writeSync(
197
+ 2,
198
+ "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no oam binary was found.\n" +
199
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
200
+ );
201
+ process.exit(1);
202
+ }
203
+ await runInProcess();
204
+ } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
205
+ // Discovery itself stays stat-only; this is the first subprocess, and it
206
+ // runs only once we have already decided to spawn oam anyway. Measured 26ms
207
+ // median (n=12, windows-arm64), paid once per MCP session.
208
+ const min = OAM_MIN.join(".");
209
+ if (mode === "oam") {
210
+ const { writeSync } = await import("node:fs");
211
+ writeSync(
212
+ 2,
213
+ `tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
214
+ `Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n`,
215
+ );
216
+ process.exit(1);
217
+ }
218
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
219
+ // a silent downgrade is how someone keeps running an oam they meant to
220
+ // update. stderr is safe -- MCP frames travel on stdout.
221
+ process.stderr.write(`tailscale-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
222
+ await runInProcess();
223
+ } else {
224
+ // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
225
+ // --version` and any host-supplied flags survive the hop unchanged.
226
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
227
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
228
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
229
+ // server's shutdown path.
230
+ stdio: "inherit",
231
+ env: process.env,
232
+ windowsHide: true,
233
+ });
234
+
235
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
236
+ // wrong arch, permission), fall back rather than failing the whole server.
237
+ // `spawned` prevents falling back AFTER the child started, which would
238
+ // double-start the server on the same stdio.
239
+ let spawned = false;
240
+ child.on("spawn", () => {
241
+ spawned = true;
242
+ });
243
+ child.on("error", (err) => {
244
+ if (spawned) return;
245
+ if (mode === "oam") {
246
+ process.stderr.write(`tailscale-mcp: failed to launch oam (${err.message})\n`);
247
+ process.exit(1);
248
+ }
249
+ void runInProcess();
250
+ });
251
+
252
+ // Forward termination so the server's own shutdown path runs in the child
253
+ // rather than the child being orphaned. No-op on Windows, harmless to add.
254
+ for (const sig of ["SIGINT", "SIGTERM"]) {
255
+ process.on(sig, () => {
256
+ if (!child.killed) child.kill(sig);
257
+ });
258
+ }
259
+
260
+ child.on("exit", (code, signal) => {
261
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
262
+ // conventional shell exit status rather than a bare 0.
263
+ if (signal) {
264
+ process.exit(128 + (constants.signals[signal] ?? 15));
265
+ }
266
+ process.exit(code ?? 0);
267
+ });
268
+ }
269
+ }
package/dist/index.js CHANGED
@@ -33824,7 +33824,7 @@ async function tailnetDnsResource(uri) {
33824
33824
  }
33825
33825
 
33826
33826
  // src/index.ts
33827
- var version2 = true ? "0.14.0" : resolveVersionFallback();
33827
+ var version2 = true ? "0.16.0" : resolveVersionFallback();
33828
33828
  var subcommand = process.argv[2];
33829
33829
  var cliSubcommandHandled = false;
33830
33830
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",
@@ -20,9 +20,10 @@
20
20
  "type": "module",
21
21
  "main": "dist/index.js",
22
22
  "bin": {
23
- "tailscale-mcp": "dist/index.js"
23
+ "tailscale-mcp": "bin/tailscale-mcp.mjs"
24
24
  },
25
25
  "files": [
26
+ "bin/tailscale-mcp.mjs",
26
27
  "dist/index.js",
27
28
  "LICENSE",
28
29
  "README.md"