@yawlabs/tailscale-mcp 0.19.2 → 0.19.4

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
@@ -677,9 +677,17 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
677
677
 
678
678
  ## Running on oam.js (optional)
679
679
 
680
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 97 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.
680
+ [oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.15.2**. Verified against oam 0.15.2: 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.
681
681
 
682
- **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.
682
+ **oam 0.15.2 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.
683
+
684
+ How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
685
+
686
+ - **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.15.2 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.15.2 or newer, else asks every oam binary it can find — `%LOCALAPPDATA%\oam\bin` then `~/.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.15.2 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.
687
+ - **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node.
688
+ - **`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`.
689
+
690
+ 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.
683
691
 
684
692
  ### Sandboxing (opt-in)
685
693
 
@@ -687,6 +695,8 @@ Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network r
687
695
 
688
696
  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.
689
697
 
698
+ The sandbox is applied by the `tailscale-mcp` command, which spawns a fresh oam for it -- even when a client launched the command with `oam run` -- because `--permission` is a process-level flag. If it finds no usable oam to spawn, `TAILSCALE_MCP_RUNTIME=auto` still starts the server, without the sandbox; set `TAILSCALE_MCP_RUNTIME=oam` to make that an error. `TAILSCALE_MCP_RUNTIME=node` runs on Node, so it never applies the sandbox.
699
+
690
700
  ```jsonc
691
701
  {
692
702
  "mcpServers": {
@@ -698,7 +708,9 @@ It is opt-in rather than default because a wrong grant does not fail loudly. oam
698
708
  }
699
709
  ```
700
710
 
701
- **Node stays the default, deliberately.** 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). Preferring oam automatically would mean either a launcher that probes for it on every start — a cost paid by everyone, including the majority who do not have oam — or making oam a hard requirement. Neither is worth it to reach a runtime that is not faster here. Measure on your own hardware before concluding anything; if oam wins on yours, the config above is all you need.
711
+ **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 used an oam that predates the 0.15.2 floor and have not been repeated since, so do not read them as a current ranking.
712
+
713
+ 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.
702
714
 
703
715
  Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
704
716
 
@@ -2,27 +2,81 @@
2
2
  /**
3
3
  * Runtime launcher for @yawlabs/tailscale-mcp.
4
4
  *
5
- * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
- * process already running this file.
5
+ * Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
6
+ * Node. It never serves on an oam older than the floor below.
7
7
  *
8
8
  *
9
9
  * WHY THE FALLBACK COSTS NOTHING
10
10
  * npm has already started Node to run this launcher, so falling back is a
11
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.
12
+ * startup, byte-identical to invoking dist/index.js directly. Finding the
13
+ * candidates is stat-only, so a machine without oam never pays for a
14
+ * subprocess.
14
15
  *
15
16
  * 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
17
+ * Reaching oam through an npm `bin` means Node boots first, every oam binary
18
+ * found is asked for its version, and then oam boots to serve -- so the
19
+ * launcher is slower than pointing a host at oam directly. Measured on
18
20
  * 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
+ * oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
21
22
  *
22
23
  * For an MCP host config, point straight at oam and skip this file:
23
24
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
25
  *
25
- * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
+ * WHICH OAM
27
+ * OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
28
+ * discovery can see -- the installed locations, then PATH -- is asked for its
29
+ * version, and the NEWEST one at or above the floor wins; a tie keeps search
30
+ * order. Taking the first binary found instead let a stale copy early in the
31
+ * search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
32
+ * and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
33
+ * are searched first.
34
+ *
35
+ * An OAM_BIN that does not exist, is below the floor, or will not run is always
36
+ * named on stderr, and discovery carries on. It used to stop everything: a typo
37
+ * in OAM_BIN meant Node, with no hint why. The binaries discovery passes over,
38
+ * and any oam.cmd/oam.bat shim, are named only when NO usable oam is found --
39
+ * choosing a newer oam over an older one is not news.
40
+ *
41
+ * ALREADY RUNNING ON OAM
42
+ * A host can resolve this package's `bin` and launch `oam run <this file>`
43
+ * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
44
+ * regression matrix. This launcher used to discover oam and spawn it anyway,
45
+ * so one server cost two runtime boots: measured on Windows, oam.exe with a
46
+ * NESTED oam.exe + conhost.exe underneath it. Now, when `process.versions.oam`
47
+ * clears the same MINIMUM OAM VERSION a discovered binary has to, the server is
48
+ * imported into THIS process exactly as the Node fallback is -- no discovery,
49
+ * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
50
+ * is not consulted on that path: the host has already chosen which oam runs.
51
+ *
52
+ * TAILSCALE_MCP_SANDBOX=1 still takes the discovery path on such a host,
53
+ * deliberately: `--permission` is a process-level flag that only a FRESH oam
54
+ * can apply, so serving in-process there would drop the sandbox without a
55
+ * word -- a security downgrade dressed up as an optimisation.
56
+ *
57
+ * A host oam BELOW the floor never serves. It used to, whenever discovery came
58
+ * up empty. It now hands the server off to the newest usable oam, or to Node
59
+ * found on PATH, or exits with an error when there is neither.
60
+ *
61
+ * Every handoff from an oam host PIPES stdio rather than inheriting it. Before
62
+ * 0.9.0 oam treated `stdio: 'inherit'` as `'pipe'`, so an inherited handoff
63
+ * from such a host connects the child to pipes nobody reads: measured with a
64
+ * real oam 0.8.2 host on aws-mcp's copy of this launcher, the MCP handshake
65
+ * never answered. Piping the streams explicitly completes it, to both oam and
66
+ * Node, and is equally correct from a current oam, so the rule does not depend
67
+ * on the host's version. A Node host keeps `inherit`, which hands over the
68
+ * same fds untouched.
69
+ *
70
+ * Discovery asks for a spawn; it does not guarantee one. If it finds no usable
71
+ * oam, or the spawn itself fails, the default TAILSCALE_MCP_RUNTIME=auto falls
72
+ * back. A Node host runs the server in-process, and so does an oam host at the
73
+ * floor -- which only reaches discovery for the sandbox -- so under
74
+ * TAILSCALE_MCP_SANDBOX=1 that fallback serves WITHOUT `--permission`, and
75
+ * nothing on stderr mentions the sandbox. A host below the floor hands off to
76
+ * Node instead, which has no `--permission` to apply either. Pair the sandbox
77
+ * with TAILSCALE_MCP_RUNTIME=oam to make a sandbox that cannot be applied fatal.
78
+ *
79
+ * THE `--permission` SANDBOX (opt-in)
26
80
  * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
27
81
  * network limited to the one host the bundle actually calls
28
82
  * (api.tailscale.com), filesystem denied.
@@ -37,32 +91,46 @@
37
91
  * shipped bundle; keep it in step.
38
92
  *
39
93
  * MINIMUM OAM VERSION
40
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
41
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
42
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
43
- * behaved as `'pipe'`. This server shells out to a CLI on its
44
- * main paths, so those were reachable bugs rather than theoretical ones: an
45
- * argument containing shell metacharacters was re-split and executed.
46
- * An older oam is not an error: the launcher falls back to Node and says so on
47
- * stderr. Pinning the floor here is what makes that fallback automatic.
94
+ * The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
95
+ * Only the current oam is used and verified; an older one is passed over for a
96
+ * newer oam, or for Node. The floor is not cosmetic: before 0.9.0
97
+ * `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
98
+ * `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
99
+ * reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
100
+ * `'pipe'`. This server shells out to the `tailscale` CLI on its local-CLI
101
+ * tools, so those were reachable bugs rather than theoretical ones: an argument
102
+ * containing shell metacharacters was re-split and executed.
48
103
  *
49
104
  * SELECTION
50
- * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
51
- * TAILSCALE_MCP_RUNTIME=node never use oam
52
- * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
105
+ * TAILSCALE_MCP_RUNTIME=auto newest usable oam, else Node (default)
106
+ * TAILSCALE_MCP_RUNTIME=oam newest usable oam, else exit with an error
107
+ * (already running on oam at the floor satisfies
108
+ * it, unless TAILSCALE_MCP_SANDBOX=1 needs a
109
+ * fresh one)
110
+ * TAILSCALE_MCP_RUNTIME=node Node: in THIS process on Node, handed off to
111
+ * Node on PATH when THIS process is oam; never
112
+ * sandboxed
53
113
  * anything else warns on stderr, then behaves as auto
54
- * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
55
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
114
+ * TAILSCALE_MCP_SANDBOX=1 spawn oam under --permission (see above)
115
+ * OAM_BIN=/path/to/oam use this oam when it is usable, before discovery
116
+ * The TAILSCALE_MCP_RUNTIME value is case-insensitive, and an empty value counts
117
+ * as unset.
56
118
  */
57
119
 
58
120
  import { execFileSync, spawn } from "node:child_process";
59
- import { existsSync } from "node:fs";
121
+ import { existsSync, realpathSync } from "node:fs";
60
122
  import { constants, homedir } from "node:os";
61
123
  import { delimiter, join } from "node:path";
62
124
  import { fileURLToPath } from "node:url";
63
125
 
64
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
65
- const OAM_MIN = [0, 9, 0];
126
+ /** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
127
+ const OAM_MIN = [0, 15, 2];
128
+
129
+ /**
130
+ * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
131
+ * the bound only exists so a wedged binary on PATH cannot hang the launch.
132
+ */
133
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
66
134
 
67
135
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
68
136
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -72,63 +140,85 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
72
140
  const isWin = process.platform === "win32";
73
141
  const exe = isWin ? "oam.exe" : "oam";
74
142
 
75
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
76
- function findOam() {
77
- // 1. Explicit override wins and is never second-guessed.
78
- const override = process.env.OAM_BIN;
79
- if (override) return existsSync(override) ? override : null;
80
-
81
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
82
- // usually has oam/target/release on PATH, and a build directory is the
83
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
84
- // binary underneath running processes, and the dev build is not the
85
- // release the user installed. Preferring the installed copy makes the
86
- // default path "what a normal user has", and OAM_BIN remains the way to
87
- // point deliberately at a dev build.
88
- //
89
- // Both forms are checked on Windows: the installer defaults to
90
- // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
91
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
92
- // install.
143
+ /** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
144
+ function pathKey(p) {
145
+ let key = p;
146
+ try {
147
+ key = realpathSync(p);
148
+ } catch {
149
+ // Unresolvable: fall back to the literal path.
150
+ }
151
+ return isWin ? key.toLowerCase() : key;
152
+ }
153
+
154
+ /**
155
+ * Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
156
+ * never a subprocess.
157
+ *
158
+ * Installed locations come BEFORE PATH, so when two binaries report the same
159
+ * version the installed copy wins the tie. Someone who develops oam itself
160
+ * usually has oam/target/release on PATH, and cargo replaces that binary
161
+ * underneath running processes; OAM_BIN remains the way to point deliberately
162
+ * at a dev build. Both forms are checked on Windows: the installer defaults to
163
+ * %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
164
+ * OAM_INSTALL_DIR can pick either.
165
+ *
166
+ * PATH is walked manually rather than by spawning `which`/`where`, which would
167
+ * cost a subprocess on every launch just to decide whether to spawn.
168
+ *
169
+ * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
170
+ * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
171
+ * spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
172
+ * full PATHEXT list would hand back a path this launcher cannot execute. When
173
+ * no usable oam is found, a skipped shim is still named -- see findOamShim.
174
+ */
175
+ function discoverOamPaths() {
93
176
  const installed = [join(homedir(), ".oam", "bin", exe)];
94
177
  if (isWin) {
95
178
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
96
179
  }
97
- for (const candidate of installed) {
98
- if (existsSync(candidate)) return candidate;
99
- }
100
-
101
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
102
- // would cost a subprocess on every launch just to decide whether to spawn.
103
- // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
104
- // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
105
- // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
106
- // the full PATHEXT list would hand back a path this launcher cannot execute.
107
- // Discovery has to agree with execution. A skipped shim is still reported --
108
- // see findOamShim.
109
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
110
- if (!dir) continue;
111
- const candidate = join(dir, exe);
112
- if (existsSync(candidate)) return candidate;
180
+ const onPath = (process.env.PATH ?? "")
181
+ .split(delimiter)
182
+ .filter(Boolean)
183
+ .map((dir) => join(dir, exe));
184
+ const seen = new Set();
185
+ const found = [];
186
+ for (const candidate of [...installed, ...onPath]) {
187
+ if (!existsSync(candidate)) continue;
188
+ const key = pathKey(candidate);
189
+ if (seen.has(key)) continue;
190
+ seen.add(key);
191
+ found.push(candidate);
113
192
  }
114
-
115
- return null;
193
+ return found;
116
194
  }
117
195
 
118
196
  /**
119
- * `oam --version` -> [major, minor, patch], or null when it cannot be read.
197
+ * Version text -> [major, minor, patch], or null when it holds no version.
120
198
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
199
+ *
200
+ * Shared by the two places a version is read -- a discovered binary's
201
+ * `oam --version` output ("oam 0.15.1") and the host's own
202
+ * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
203
+ * version string means, or which floor it has to clear.
121
204
  */
205
+ function parseVersion(text) {
206
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
207
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
208
+ }
209
+
210
+ /** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
122
211
  function oamVersion(cmd) {
123
212
  try {
124
213
  const out = execFileSync(cmd, ["--version"], {
125
214
  encoding: "utf-8",
126
215
  stdio: ["ignore", "pipe", "ignore"],
216
+ timeout: VERSION_PROBE_TIMEOUT_MS,
217
+ windowsHide: true,
127
218
  });
128
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
129
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
219
+ return parseVersion(out);
130
220
  } catch {
131
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
221
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
132
222
  return null;
133
223
  }
134
224
  }
@@ -143,6 +233,65 @@ function atLeast(v, min) {
143
233
  return true;
144
234
  }
145
235
 
236
+ /**
237
+ * The newest candidate at or above the floor, or null. `candidates` is
238
+ * `{ path, version }[]` in search order, `version` null when unreadable.
239
+ * Strictly-greater replaces, so a tie keeps the earlier candidate.
240
+ *
241
+ * Pure on purpose, like runtimePlan: the choice is testable without binaries.
242
+ */
243
+ function pickNewest(candidates) {
244
+ let best = null;
245
+ for (const candidate of candidates) {
246
+ if (!atLeast(candidate.version, OAM_MIN)) continue;
247
+ if (!best || !atLeast(best.version, candidate.version)) best = candidate;
248
+ }
249
+ return best;
250
+ }
251
+
252
+ /**
253
+ * Where the server runs, decided BEFORE any discovery:
254
+ * "in-process" import it into THIS process
255
+ * "discover" choose an oam and spawn it, or fall back (see fallBack)
256
+ * "handoff-node" hand it off to Node on PATH: THIS process is an oam and
257
+ * TAILSCALE_MCP_RUNTIME=node asked for Node
258
+ *
259
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
260
+ * host whose version cannot be read is treated as below the floor -- it never
261
+ * proved it is a supported oam. Every mode but `node` is decided the same way:
262
+ * `oam` is satisfied by a host that already is one, and an unrecognized value
263
+ * has been warned about and lands with auto.
264
+ *
265
+ * `sandbox` is whether a spawn would carry flags only a fresh oam can apply;
266
+ * see ALREADY RUNNING ON OAM above for why that alone forces the discovery
267
+ * path, and why discovery can still end in-process without those flags. Under
268
+ * `node` it is moot: Node has no `--permission` to apply. The floor is OAM_MIN
269
+ * itself, not a parameter, so a host oam and a discovered one can never be held
270
+ * to different minimums.
271
+ *
272
+ * Pure on purpose: every input is passed in, so the whole decision is testable
273
+ * without booting a runtime.
274
+ */
275
+ function runtimePlan({ mode, hostOam, sandbox }) {
276
+ const onOam = hostOam !== undefined;
277
+ if (mode === "node") return onOam ? "handoff-node" : "in-process";
278
+ if (sandbox) return "discover";
279
+ return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
280
+ }
281
+
282
+ /**
283
+ * Whether a fallback may serve in THIS process: on Node, or on an oam host at
284
+ * the floor. The only host at the floor that ever reaches a fallback is one
285
+ * that took the discovery path for TAILSCALE_MCP_SANDBOX=1, and it serves
286
+ * without `--permission`, as it always has. A host below the floor never
287
+ * serves.
288
+ *
289
+ * Pure on purpose, like runtimePlan.
290
+ */
291
+ function fallbackInProcess(hostOam) {
292
+ return hostOam === undefined || atLeast(parseVersion(hostOam), OAM_MIN);
293
+ }
294
+
146
295
  /**
147
296
  * The `--permission` grant list, or [] when the sandbox is not requested.
148
297
  *
@@ -230,9 +379,11 @@ async function errSync(message) {
230
379
 
231
380
  /**
232
381
  * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
233
- * cannot spawn. Reported rather than ignored, because "no oam binary was found"
234
- * reads as "install oam" -- the one thing that will not help. Windows only;
235
- * there is no such shim concept on POSIX.
382
+ * cannot spawn. Named in the no-usable-oam note rather than ignored, because
383
+ * "no usable oam was found" alone reads as "install oam" -- the one thing that
384
+ * will not help. Only consulted when no usable oam was found: with one chosen,
385
+ * the shim is irrelevant and goes unmentioned. Windows only; there is no such
386
+ * shim concept on POSIX.
236
387
  */
237
388
  function findOamShim() {
238
389
  if (!isWin) return null;
@@ -246,6 +397,56 @@ function findOamShim() {
246
397
  return null;
247
398
  }
248
399
 
400
+ /** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
401
+ function findNodeOnPath() {
402
+ const name = isWin ? "node.exe" : "node";
403
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
404
+ if (!dir) continue;
405
+ const candidate = join(dir, name);
406
+ if (existsSync(candidate)) return candidate;
407
+ }
408
+ return null;
409
+ }
410
+
411
+ /**
412
+ * Why a candidate was passed over, for stderr. "Too old" and "could not be run"
413
+ * stay distinct: a binary that is not executable, is the wrong arch, or printed
414
+ * no parseable version is not outdated, and `oam self-update` will not fix it.
415
+ */
416
+ function unusableReason(path, version, label = path) {
417
+ const min = OAM_MIN.join(".");
418
+ return version
419
+ ? `${label} is oam ${version.join(".")}, older than ${min}`
420
+ : `${label} could not be run, or did not report a version this launcher understands`;
421
+ }
422
+
423
+ /**
424
+ * Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
425
+ * binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
426
+ * an unusable OAM_BIN, and `skipped` describing what was found and rejected
427
+ * when nothing was usable.
428
+ */
429
+ function chooseOam() {
430
+ const override = process.env.OAM_BIN;
431
+ let overrideNote = null;
432
+ if (override) {
433
+ if (!existsSync(override)) {
434
+ overrideNote = `OAM_BIN=${override} does not exist`;
435
+ } else {
436
+ const version = oamVersion(override);
437
+ if (atLeast(version, OAM_MIN)) return { chosen: { path: override, version }, overrideNote, skipped: [] };
438
+ overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
439
+ }
440
+ }
441
+ const overrideKey = override ? pathKey(override) : null;
442
+ const candidates = discoverOamPaths()
443
+ .filter((path) => pathKey(path) !== overrideKey)
444
+ .map((path) => ({ path, version: oamVersion(path) }));
445
+ const chosen = pickNewest(candidates);
446
+ const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
447
+ return { chosen, overrideNote, skipped };
448
+ }
449
+
249
450
  /** Run the server in THIS process. The zero-overhead fallback. */
250
451
  async function runInProcess() {
251
452
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -262,6 +463,183 @@ async function runInProcess() {
262
463
  await import(SERVER_URL.href);
263
464
  }
264
465
 
466
+ // ONE reporter for every failed in-process fallback. runInProcess() is a bare
467
+ // import() that rejects when dist/index.js is missing, and at ESM top level an
468
+ // unhandled rejection is an uncaught exception -- replacing this launcher's
469
+ // diagnostic with a raw stack trace.
470
+ const fallbackFailed = (e) => {
471
+ process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
472
+ process.exitCode = 1;
473
+ };
474
+
475
+ /**
476
+ * Spawn the server in a child runtime and mirror its lifetime.
477
+ *
478
+ * `onLaunchFailed(err)` runs when the child could not be started at all; it is
479
+ * never called once the child is running, which would double-start the server
480
+ * on the same stdio.
481
+ */
482
+ async function launchChild(cmd, args, onLaunchFailed) {
483
+ // Every handoff from an oam host pipes; see ALREADY RUNNING ON OAM. That is a
484
+ // host below the floor, one at the floor spawning a fresh oam for the
485
+ // sandbox, or any oam under TAILSCALE_MCP_RUNTIME=node.
486
+ const piped = process.versions.oam !== undefined;
487
+ let child = null;
488
+ try {
489
+ child = spawn(cmd, args, {
490
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
491
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
492
+ // server's shutdown path. Piping preserves both as well: bytes are copied
493
+ // unchanged, and stdin's end propagates to the child.
494
+ stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
495
+ env: process.env,
496
+ windowsHide: true,
497
+ });
498
+ } catch (err) {
499
+ // spawn() THROWS for some failures instead of emitting 'error', and the
500
+ // 'error' listener is registered AFTER this call, so it can never observe
501
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
502
+ // instead of falling back.
503
+ await onLaunchFailed(err).catch(fallbackFailed);
504
+ return;
505
+ }
506
+
507
+ // If the runtime cannot be executed at all (deleted between the version probe
508
+ // and the spawn, wrong arch, permission), fall back rather than failing the
509
+ // whole server. `spawned` prevents falling back AFTER the child started.
510
+ //
511
+ // Everything that assumes a live child waits for 'spawn'. A failed spawn
512
+ // still emits 'close' (after 'error', with the negative errno as its code), so
513
+ // an unguarded close handler would process.exit() out from under the fallback
514
+ // onLaunchFailed has just started. Stdin piped into a child that never ran is
515
+ // the other half: an in-process fallback still answers the host's first
516
+ // request, but the pipe's write to the dead child fails, stdin stops flowing,
517
+ // and the next request is never read. Until 'spawn', process.stdin has no
518
+ // reader and simply stays paused.
519
+ let spawned = false;
520
+ child.on("spawn", () => {
521
+ spawned = true;
522
+ if (piped) {
523
+ process.stdin.pipe(child.stdin);
524
+ child.stdout.pipe(process.stdout);
525
+ child.stderr.pipe(process.stderr);
526
+ }
527
+ forwardSignals();
528
+ });
529
+ child.on("error", (err) => {
530
+ if (spawned) return;
531
+ // Handle the rejection instead of discarding it: a failing in-process
532
+ // fallback would otherwise escape as an unhandled rejection, replacing
533
+ // this launcher's diagnostic with a raw stack trace.
534
+ onLaunchFailed(err).catch(fallbackFailed);
535
+ });
536
+ // A child that exits before reading everything closes its stdin; the
537
+ // resulting EPIPE is not worth crashing over. Null when stdio is inherited.
538
+ child.stdin?.on("error", () => {});
539
+
540
+ // Forward termination so the server's own shutdown path runs in the child
541
+ // rather than the child being orphaned.
542
+ //
543
+ // Registering ANY handler for these suppresses Node's default
544
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
545
+ // `child.killed` only records that kill() was CALLED, never that the child
546
+ // is gone, so gating on it swallows every signal after the first and wedges
547
+ // the launcher with no escape hatch.
548
+ //
549
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
550
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
551
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
552
+ // "a second signal" as impatience hard-kills a child that is already
553
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
554
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
555
+ // a wall-clock step cannot mis-gate the window either.
556
+ //
557
+ // POSIX vs Windows, and why we do NOT forward on Windows.
558
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
559
+ // is what lets the child run its shutdown. On Windows there are no POSIX
560
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
561
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
562
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
563
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
564
+ // child's process.on("exit") cleanup. The console has already notified the
565
+ // child, so on Windows the timer below is the only kill we issue.
566
+ const ESCALATE_AFTER_MS = 2000;
567
+ let escalation = null;
568
+ // Called from the 'spawn' handler above: a child that never ran has nothing
569
+ // to forward to, and handlers registered for it would still replace Node's
570
+ // default terminate-on-signal for whatever fallback serves instead.
571
+ function forwardSignals() {
572
+ for (const sig of ["SIGINT", "SIGTERM"]) {
573
+ process.on(sig, () => {
574
+ // No try/catch: kill() on an already-exited child returns false, it does
575
+ // not throw. It throws only for a signal the platform does not know,
576
+ // which SIGINT/SIGTERM/SIGKILL never are.
577
+ if (!isWin) child.kill(sig);
578
+ if (escalation) return; // already counting down; further signals are noise
579
+ escalation = setTimeout(() => {
580
+ // Still here after its grace window. Stop waiting on it.
581
+ child.kill("SIGKILL");
582
+ process.exit(128 + (constants.signals[sig] ?? 15));
583
+ }, ESCALATE_AFTER_MS);
584
+ });
585
+ }
586
+ }
587
+
588
+ // Piped: wait for 'close', so the child's last stdout bytes are copied out
589
+ // before this process exits. Inherited: 'exit' is enough, the fds were never
590
+ // ours to drain. Either way, only for a child that actually ran -- see the
591
+ // 'spawn' handler above.
592
+ child.on(piped ? "close" : "exit", (code, signal) => {
593
+ if (!spawned) return;
594
+ if (escalation) clearTimeout(escalation);
595
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
596
+ // conventional shell exit status rather than a bare 0.
597
+ if (signal) {
598
+ process.exit(128 + (constants.signals[signal] ?? 15));
599
+ }
600
+ process.exit(code ?? 0);
601
+ });
602
+ }
603
+
604
+ /**
605
+ * Hand the server to Node on PATH. Only reachable when THIS process is oam --
606
+ * one below the floor, or any oam under TAILSCALE_MCP_RUNTIME=node -- so there
607
+ * is no in-process option left. An empty `reason` prints no note: the host is a
608
+ * supported oam and TAILSCALE_MCP_RUNTIME=node asked for Node, which is not news.
609
+ */
610
+ async function handOffToNode(reason) {
611
+ const node = findNodeOnPath();
612
+ if (!node) {
613
+ const remedy =
614
+ mode === "node"
615
+ ? "Put Node on PATH, or launch this command with node.\n"
616
+ : `Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`;
617
+ await errSync(
618
+ `tailscale-mcp: ${reason || `TAILSCALE_MCP_RUNTIME=node on oam ${process.versions.oam}`}, and no Node was found on PATH to run the server.\n${remedy}`,
619
+ );
620
+ process.exit(1);
621
+ }
622
+ if (reason) await errSync(`tailscale-mcp: ${reason}; running on ${node} instead.\n`);
623
+ await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
624
+ await errSync(`tailscale-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
625
+ process.exit(1);
626
+ });
627
+ }
628
+
629
+ /** What a fallback serves on, for stderr. */
630
+ function fallbackTarget(hostOam) {
631
+ return hostOam !== undefined && fallbackInProcess(hostOam) ? `this oam ${hostOam} process` : "Node";
632
+ }
633
+
634
+ /** No usable oam, or it would not start, under a mode that allows a fallback. */
635
+ async function fallBack(hostOam, why) {
636
+ if (fallbackInProcess(hostOam)) {
637
+ await runInProcess();
638
+ return;
639
+ }
640
+ await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}`);
641
+ }
642
+
265
643
  // Every value below is compared against `mode` after lowercasing, so an
266
644
  // unrecognized one matched nothing and fell through to the auto branch --
267
645
  // `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
@@ -280,179 +658,63 @@ if (requested && !RUNTIMES.includes(mode)) {
280
658
  );
281
659
  }
282
660
 
283
- if (mode === "node") {
661
+ const hostOam = process.versions.oam;
662
+
663
+ // The sandbox is read off the grant list rather than TAILSCALE_MCP_SANDBOX, so
664
+ // "would the spawn carry --permission" cannot drift from what the spawn below
665
+ // actually passes.
666
+ const sandbox = sandboxFlags();
667
+ const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
668
+
669
+ if (plan === "in-process") {
284
670
  await runInProcess();
671
+ } else if (plan === "handoff-node") {
672
+ const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
673
+ await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
285
674
  } else {
286
- const oam = findOam();
287
- // Read the version ONCE, and only when discovery found something: the
288
- // gate below has to tell "too old" apart from "could not be read at all",
289
- // and re-probing inside the branch would cost a second subprocess.
290
- const found = oam ? oamVersion(oam) : null;
291
-
292
- if (!oam) {
293
- // An oam-named .cmd/.bat on PATH is a real install in a shape this
294
- // launcher cannot spawn. Naming it turns "no oam binary was found" --
295
- // which reads as "install oam", the one thing that will not help --
296
- // into something the user can act on.
297
- const oamShim = findOamShim();
298
- const shimNote = oamShim
299
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
300
- "Install the native oam binary, or point OAM_BIN at one.\n"
301
- : "";
302
- if (mode === "oam") {
303
- // Explicitly demanded, so this is a real misconfiguration. writeSync
304
- // because stderr is async for TTYs/pipes on Windows and process.exit
305
- // truncates pending writes.
306
- const { writeSync } = await import("node:fs");
307
- writeSync(
308
- 2,
309
- "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
310
- shimNote +
311
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
312
- );
313
- process.exit(1);
314
- }
315
- // auto: falling back is correct, but silence is how someone never learns
316
- // their oam install is a shape this launcher skips.
317
- if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
318
- await runInProcess();
319
- } else if (!atLeast(found, OAM_MIN)) {
320
- const min = OAM_MIN.join(".");
321
- // Two different causes reach this branch and they need different
322
- // remedies. `found === null` is NOT "old": oamVersion returns null when
323
- // the binary could not be run at all (not executable, wrong arch, a
324
- // .cmd/.bat Node refuses, deleted between the stat and the probe) or
325
- // when its --version output did not parse. Telling that user to
326
- // `oam self-update` sends them after the one cause it definitely is not.
327
- const detail = found
328
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
329
- : `${oam} could not be run, or did not report a version this launcher understands`;
330
- const remedy = found
331
- ? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
332
- : "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
333
- if (mode === "oam") {
334
- await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
335
- process.exit(1);
675
+ const { chosen, overrideNote, skipped } = chooseOam();
676
+
677
+ if (chosen) {
678
+ if (overrideNote) {
679
+ await errSync(`tailscale-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
336
680
  }
337
- // auto: neither cause is worth failing over -- prefer Node. Say so,
338
- // because a silent downgrade is how someone keeps running an oam they
339
- // meant to update, or never learns their oam is unexecutable.
340
- await errSync(`tailscale-mcp: ${detail}; using Node instead.\n`);
341
- await runInProcess();
342
- } else {
343
- // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
344
- // --version` and any host-supplied flags survive the hop unchanged.
345
- // Every "oam could not be executed" outcome lands here: the synchronous
346
- // throw from spawn() and the async 'error' event mean the same thing and
347
- // must degrade the same way, so the handling lives in one place.
348
- // errSync rather than process.stderr.write because stderr is async for
349
- // TTYs and pipes on Windows and the process.exit below truncates pending
350
- // writes.
351
- const launchFailed = async (err) => {
681
+ // The sandbox flags go BEFORE `run` (see sandboxFlags), and `--` separates
682
+ // oam's own flags from the script's argv, so `tailscale-mcp --version` and
683
+ // any host-supplied flags survive the hop unchanged.
684
+ await launchChild(chosen.path, [...sandbox, "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], async (err) => {
352
685
  if (mode === "oam") {
353
- await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
686
+ await errSync(`tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`);
354
687
  process.exit(1);
355
688
  }
356
- await runInProcess();
357
- };
358
-
359
- // ONE reporter shared by both launchFailed call sites, so the sync-throw
360
- // path and the 'error'-event path cannot drift apart. Either can reject:
361
- // runInProcess() is a bare import() that rejects when dist/index.js is
362
- // missing, and at ESM top level an unhandled rejection is an uncaught
363
- // exception -- the exact failure this handling exists to prevent.
364
- const fallbackFailed = (e) => {
365
- process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
366
- process.exitCode = 1;
367
- };
368
-
369
- let child = null;
370
- try {
371
- child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
372
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
373
- // stdin/stdout is untouched and the host's stdin-close still reaches the
374
- // server's shutdown path.
375
- stdio: "inherit",
376
- env: process.env,
377
- windowsHide: true,
378
- });
379
- } catch (err) {
380
- // spawn() THROWS for some failures instead of emitting 'error', and the
381
- // 'error' listener is registered AFTER this call, so it can never observe
382
- // one -- an uncaught throw here kills the launcher with a raw stack trace
383
- // instead of falling back to Node.
384
- await launchFailed(err).catch(fallbackFailed);
689
+ await errSync(
690
+ `tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err}); using ${fallbackTarget(hostOam)} instead.\n`,
691
+ );
692
+ await fallBack(hostOam, "the newer oam would not start");
693
+ });
694
+ } else {
695
+ const shim = findOamShim();
696
+ const notes = [
697
+ ...(overrideNote ? [overrideNote] : []),
698
+ ...skipped,
699
+ ...(shim
700
+ ? [
701
+ `found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
702
+ ]
703
+ : []),
704
+ ];
705
+ if (mode === "oam") {
706
+ await errSync(
707
+ `tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
708
+ notes.map((note) => ` ${note}\n`).join("") +
709
+ "Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
710
+ );
711
+ process.exit(1);
385
712
  }
386
-
387
- if (child) {
388
- // If oam cannot be executed at all (deleted between the stat and the spawn,
389
- // wrong arch, permission), fall back rather than failing the whole server.
390
- // `spawned` prevents falling back AFTER the child started, which would
391
- // double-start the server on the same stdio.
392
- let spawned = false;
393
- child.on("spawn", () => {
394
- spawned = true;
395
- });
396
- child.on("error", (err) => {
397
- if (spawned) return;
398
- // Handle the rejection instead of discarding it: a failing in-process
399
- // fallback would otherwise escape as an unhandled rejection, replacing
400
- // this launcher's diagnostic with a raw stack trace.
401
- launchFailed(err).catch(fallbackFailed);
402
- });
403
-
404
- // Forward termination so the server's own shutdown path runs in the child
405
- // rather than the child being orphaned.
406
- //
407
- // Registering ANY handler for these suppresses Node's default
408
- // terminate-on-signal, so the parent's exit has to be arranged explicitly.
409
- // `child.killed` only records that kill() was CALLED, never that the child
410
- // is gone, so gating on it swallows every signal after the first and wedges
411
- // the launcher with no escape hatch.
412
- //
413
- // Escalation is driven by a TIMER, not by counting signals. Counting is
414
- // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
415
- // apart, and a terminal Ctrl-C reaches the whole process group, so reading
416
- // "a second signal" as impatience hard-kills a child that is already
417
- // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
418
- // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
419
- // a wall-clock step cannot mis-gate the window either.
420
- //
421
- // POSIX vs Windows, and why we do NOT forward on Windows.
422
- // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
423
- // is what lets the child run its shutdown. On Windows there are no POSIX
424
- // signals: child.kill IGNORES the name and calls TerminateProcess -- an
425
- // immediate hard kill (verified: a child with a SIGTERM handler never runs
426
- // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
427
- // graceful shutdown the console's own Ctrl-C just started, skipping the
428
- // child's process.on("exit") cleanup. The console has already notified the
429
- // child, so on Windows the timer below is the only kill we issue.
430
- const ESCALATE_AFTER_MS = 2000;
431
- let escalation = null;
432
- for (const sig of ["SIGINT", "SIGTERM"]) {
433
- process.on(sig, () => {
434
- // No try/catch: kill() on an already-exited child returns false, it does
435
- // not throw. It throws only for a signal the platform does not know,
436
- // which SIGINT/SIGTERM/SIGKILL never are.
437
- if (!isWin) child.kill(sig);
438
- if (escalation) return; // already counting down; further signals are noise
439
- escalation = setTimeout(() => {
440
- // Still here after its grace window. Stop waiting on it.
441
- child.kill("SIGKILL");
442
- process.exit(128 + (constants.signals[sig] ?? 15));
443
- }, ESCALATE_AFTER_MS);
444
- });
445
- }
446
-
447
- child.on("exit", (code, signal) => {
448
- if (escalation) clearTimeout(escalation);
449
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
450
- // conventional shell exit status rather than a bare 0.
451
- if (signal) {
452
- process.exit(128 + (constants.signals[signal] ?? 15));
453
- }
454
- process.exit(code ?? 0);
455
- });
713
+ // auto: falling back is correct, but silence is how someone never learns
714
+ // their OAM_BIN is wrong or their oam is too old to use.
715
+ if (notes.length > 0) {
716
+ await errSync(`tailscale-mcp: ${notes.join("; ")}; using ${fallbackTarget(hostOam)} instead.\n`);
456
717
  }
718
+ await fallBack(hostOam, "no newer oam was found").catch(fallbackFailed);
457
719
  }
458
720
  }
package/dist/index.js CHANGED
@@ -34551,12 +34551,29 @@ function buildMetaTools(state) {
34551
34551
  }
34552
34552
 
34553
34553
  // src/index.ts
34554
- var version2 = true ? "0.19.2" : resolveVersionFallback();
34554
+ var version2 = true ? "0.19.4" : resolveVersionFallback();
34555
34555
  var subcommand = process.argv[2];
34556
+ var USAGE = `Usage: tailscale-mcp [command]
34557
+
34558
+ Commands:
34559
+ deploy-acl <path-to-acl.json> Deploy an ACL policy
34560
+ validate-acl <path-to-acl.json> Validate an ACL policy
34561
+ version Print the installed version
34562
+ help Print this message
34563
+
34564
+ Flags:
34565
+ --version, -V Print the installed version
34566
+ --help, -h Print this message
34567
+
34568
+ Run without a command to start the MCP server on stdio.`;
34556
34569
  var cliSubcommandHandled = false;
34557
34570
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
34558
34571
  cliSubcommandHandled = true;
34559
34572
  const filePath = process.argv[3];
34573
+ if (filePath === "--help" || filePath === "-h") {
34574
+ console.log(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
34575
+ process.exit(0);
34576
+ }
34560
34577
  if (!filePath) {
34561
34578
  console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
34562
34579
  process.exit(1);
@@ -34566,12 +34583,15 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
34566
34583
  console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
34567
34584
  process.exit(1);
34568
34585
  });
34569
- } else if (subcommand === "version" || subcommand === "--version") {
34586
+ } else if (subcommand === "version" || subcommand === "--version" || subcommand === "-V") {
34570
34587
  console.log(version2);
34571
34588
  process.exit(0);
34589
+ } else if (subcommand === "--help" || subcommand === "-h" || subcommand === "help") {
34590
+ console.log(USAGE);
34591
+ process.exit(0);
34572
34592
  } else if (subcommand !== void 0) {
34573
34593
  console.error(
34574
- `@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version. Starting the MCP server.`
34594
+ `@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version, help (or --help). Starting the MCP server.`
34575
34595
  );
34576
34596
  }
34577
34597
  if (!cliSubcommandHandled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.19.2",
3
+ "version": "0.19.4",
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",