@yawlabs/tailscale-mcp 0.19.3 → 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,26 +2,42 @@
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
  *
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
+ *
25
41
  * ALREADY RUNNING ON OAM
26
42
  * A host can resolve this package's `bin` and launch `oam run <this file>`
27
43
  * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
@@ -33,17 +49,34 @@
33
49
  * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
34
50
  * is not consulted on that path: the host has already chosen which oam runs.
35
51
  *
36
- * Two cases still go through discovery, deliberately. TAILSCALE_MCP_SANDBOX=1,
37
- * because `--permission` is a process-level flag that only a FRESH oam can
38
- * apply -- serving in-process there would drop the sandbox without a word, a
39
- * security downgrade dressed up as an optimisation. And a host oam below the
40
- * floor. Both then run exactly as they did before this shortcut existed,
41
- * fallback included: discovery spawns a fresh oam only when it finds one at or
42
- * above the floor that launches. Otherwise auto serves in-process as before --
43
- * for a sandbox request that means WITHOUT --permission, so pair the sandbox
44
- * with TAILSCALE_MCP_RUNTIME=oam to make that a hard failure instead.
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.
45
69
  *
46
- * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
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)
47
80
  * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
48
81
  * network limited to the one host the bundle actually calls
49
82
  * (api.tailscale.com), filesystem denied.
@@ -58,33 +91,46 @@
58
91
  * shipped bundle; keep it in step.
59
92
  *
60
93
  * MINIMUM OAM VERSION
61
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
62
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
63
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
64
- * behaved as `'pipe'`. This server shells out to a CLI on its
65
- * main paths, so those were reachable bugs rather than theoretical ones: an
66
- * argument containing shell metacharacters was re-split and executed.
67
- * An older oam is not an error: the launcher falls back to Node and says so on
68
- * 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.
69
103
  *
70
104
  * SELECTION
71
- * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
72
- * (already running on oam satisfies it)
73
- * TAILSCALE_MCP_RUNTIME=node never use oam
74
- * 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
75
113
  * anything else warns on stderr, then behaves as auto
76
- * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
77
- * 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.
78
118
  */
79
119
 
80
120
  import { execFileSync, spawn } from "node:child_process";
81
- import { existsSync } from "node:fs";
121
+ import { existsSync, realpathSync } from "node:fs";
82
122
  import { constants, homedir } from "node:os";
83
123
  import { delimiter, join } from "node:path";
84
124
  import { fileURLToPath } from "node:url";
85
125
 
86
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
87
- 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;
88
134
 
89
135
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
90
136
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -94,47 +140,57 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
94
140
  const isWin = process.platform === "win32";
95
141
  const exe = isWin ? "oam.exe" : "oam";
96
142
 
97
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
98
- function findOam() {
99
- // 1. Explicit override wins and is never second-guessed.
100
- const override = process.env.OAM_BIN;
101
- if (override) return existsSync(override) ? override : null;
102
-
103
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
104
- // usually has oam/target/release on PATH, and a build directory is the
105
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
106
- // binary underneath running processes, and the dev build is not the
107
- // release the user installed. Preferring the installed copy makes the
108
- // default path "what a normal user has", and OAM_BIN remains the way to
109
- // point deliberately at a dev build.
110
- //
111
- // Both forms are checked on Windows: the installer defaults to
112
- // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
113
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
114
- // 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() {
115
176
  const installed = [join(homedir(), ".oam", "bin", exe)];
116
177
  if (isWin) {
117
178
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
118
179
  }
119
- for (const candidate of installed) {
120
- 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);
121
192
  }
122
-
123
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
124
- // would cost a subprocess on every launch just to decide whether to spawn.
125
- // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
126
- // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
127
- // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
128
- // the full PATHEXT list would hand back a path this launcher cannot execute.
129
- // Discovery has to agree with execution. A skipped shim is still reported --
130
- // see findOamShim.
131
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
132
- if (!dir) continue;
133
- const candidate = join(dir, exe);
134
- if (existsSync(candidate)) return candidate;
135
- }
136
-
137
- return null;
193
+ return found;
138
194
  }
139
195
 
140
196
  /**
@@ -157,10 +213,12 @@ function oamVersion(cmd) {
157
213
  const out = execFileSync(cmd, ["--version"], {
158
214
  encoding: "utf-8",
159
215
  stdio: ["ignore", "pipe", "ignore"],
216
+ timeout: VERSION_PROBE_TIMEOUT_MS,
217
+ windowsHide: true,
160
218
  });
161
219
  return parseVersion(out);
162
220
  } catch {
163
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
221
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
164
222
  return null;
165
223
  }
166
224
  }
@@ -175,34 +233,65 @@ function atLeast(v, min) {
175
233
  return true;
176
234
  }
177
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
+
178
252
  /**
179
253
  * Where the server runs, decided BEFORE any discovery:
180
- * "in-process" import it into THIS process
181
- * "discover" find an oam binary, gate its version, spawn it; when that
182
- * fails, auto falls back in-process and `oam` exits 1
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
183
258
  *
184
- * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
185
- * Node every mode but `node` is the discovery path it always was. Only `node`
186
- * is distinguished here: `oam` is satisfied by a host that already is one, and
187
- * an unrecognized value has been warned about and lands with auto, as it does
188
- * in the discovery branch.
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.
189
264
  *
190
265
  * `sandbox` is whether a spawn would carry flags only a fresh oam can apply;
191
- * see ALREADY RUNNING ON OAM above for why that alone forces discovery. It does
192
- * not guarantee a spawn: when discovery finds no launchable oam at the floor,
193
- * auto still serves in-process, without --permission. The
194
- * floor is OAM_MIN itself, not a parameter, so a host oam and a discovered one
195
- * can never be held to different minimums.
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.
196
271
  *
197
272
  * Pure on purpose: every input is passed in, so the whole decision is testable
198
273
  * without booting a runtime.
199
274
  */
200
275
  function runtimePlan({ mode, hostOam, sandbox }) {
201
- if (mode === "node") return "in-process";
276
+ const onOam = hostOam !== undefined;
277
+ if (mode === "node") return onOam ? "handoff-node" : "in-process";
202
278
  if (sandbox) return "discover";
203
279
  return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
204
280
  }
205
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
+
206
295
  /**
207
296
  * The `--permission` grant list, or [] when the sandbox is not requested.
208
297
  *
@@ -290,9 +379,11 @@ async function errSync(message) {
290
379
 
291
380
  /**
292
381
  * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
293
- * cannot spawn. Reported rather than ignored, because "no oam binary was found"
294
- * reads as "install oam" -- the one thing that will not help. Windows only;
295
- * 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.
296
387
  */
297
388
  function findOamShim() {
298
389
  if (!isWin) return null;
@@ -306,6 +397,56 @@ function findOamShim() {
306
397
  return null;
307
398
  }
308
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
+
309
450
  /** Run the server in THIS process. The zero-overhead fallback. */
310
451
  async function runInProcess() {
311
452
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -322,6 +463,183 @@ async function runInProcess() {
322
463
  await import(SERVER_URL.href);
323
464
  }
324
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
+
325
643
  // Every value below is compared against `mode` after lowercasing, so an
326
644
  // unrecognized one matched nothing and fell through to the auto branch --
327
645
  // `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
@@ -340,184 +658,63 @@ if (requested && !RUNTIMES.includes(mode)) {
340
658
  );
341
659
  }
342
660
 
661
+ const hostOam = process.versions.oam;
662
+
343
663
  // The sandbox is read off the grant list rather than TAILSCALE_MCP_SANDBOX, so
344
664
  // "would the spawn carry --permission" cannot drift from what the spawn below
345
665
  // actually passes.
346
- const plan = runtimePlan({ mode, hostOam: process.versions.oam, sandbox: sandboxFlags().length > 0 });
666
+ const sandbox = sandboxFlags();
667
+ const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
347
668
 
348
669
  if (plan === "in-process") {
349
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(".")}` : "");
350
674
  } else {
351
- const oam = findOam();
352
- // Read the version ONCE, and only when discovery found something: the
353
- // gate below has to tell "too old" apart from "could not be read at all",
354
- // and re-probing inside the branch would cost a second subprocess.
355
- const found = oam ? oamVersion(oam) : null;
356
-
357
- if (!oam) {
358
- // An oam-named .cmd/.bat on PATH is a real install in a shape this
359
- // launcher cannot spawn. Naming it turns "no oam binary was found" --
360
- // which reads as "install oam", the one thing that will not help --
361
- // into something the user can act on.
362
- const oamShim = findOamShim();
363
- const shimNote = oamShim
364
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
365
- "Install the native oam binary, or point OAM_BIN at one.\n"
366
- : "";
367
- if (mode === "oam") {
368
- // Explicitly demanded, so this is a real misconfiguration. writeSync
369
- // because stderr is async for TTYs/pipes on Windows and process.exit
370
- // truncates pending writes.
371
- const { writeSync } = await import("node:fs");
372
- writeSync(
373
- 2,
374
- "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
375
- shimNote +
376
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
377
- );
378
- process.exit(1);
379
- }
380
- // auto: falling back is correct, but silence is how someone never learns
381
- // their oam install is a shape this launcher skips.
382
- if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
383
- await runInProcess();
384
- } else if (!atLeast(found, OAM_MIN)) {
385
- const min = OAM_MIN.join(".");
386
- // Two different causes reach this branch and they need different
387
- // remedies. `found === null` is NOT "old": oamVersion returns null when
388
- // the binary could not be run at all (not executable, wrong arch, a
389
- // .cmd/.bat Node refuses, deleted between the stat and the probe) or
390
- // when its --version output did not parse. Telling that user to
391
- // `oam self-update` sends them after the one cause it definitely is not.
392
- const detail = found
393
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
394
- : `${oam} could not be run, or did not report a version this launcher understands`;
395
- const remedy = found
396
- ? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
397
- : "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
398
- if (mode === "oam") {
399
- await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
400
- 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`);
401
680
  }
402
- // auto: neither cause is worth failing over -- prefer Node. Say so,
403
- // because a silent downgrade is how someone keeps running an oam they
404
- // meant to update, or never learns their oam is unexecutable.
405
- await errSync(`tailscale-mcp: ${detail}; using Node instead.\n`);
406
- await runInProcess();
407
- } else {
408
- // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
409
- // --version` and any host-supplied flags survive the hop unchanged.
410
- // Every "oam could not be executed" outcome lands here: the synchronous
411
- // throw from spawn() and the async 'error' event mean the same thing and
412
- // must degrade the same way, so the handling lives in one place.
413
- // errSync rather than process.stderr.write because stderr is async for
414
- // TTYs and pipes on Windows and the process.exit below truncates pending
415
- // writes.
416
- 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) => {
417
685
  if (mode === "oam") {
418
- 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`);
419
687
  process.exit(1);
420
688
  }
421
- await runInProcess();
422
- };
423
-
424
- // ONE reporter shared by both launchFailed call sites, so the sync-throw
425
- // path and the 'error'-event path cannot drift apart. Either can reject:
426
- // runInProcess() is a bare import() that rejects when dist/index.js is
427
- // missing, and at ESM top level an unhandled rejection is an uncaught
428
- // exception -- the exact failure this handling exists to prevent.
429
- const fallbackFailed = (e) => {
430
- process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
431
- process.exitCode = 1;
432
- };
433
-
434
- let child = null;
435
- try {
436
- child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
437
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
438
- // stdin/stdout is untouched and the host's stdin-close still reaches the
439
- // server's shutdown path.
440
- stdio: "inherit",
441
- env: process.env,
442
- windowsHide: true,
443
- });
444
- } catch (err) {
445
- // spawn() THROWS for some failures instead of emitting 'error', and the
446
- // 'error' listener is registered AFTER this call, so it can never observe
447
- // one -- an uncaught throw here kills the launcher with a raw stack trace
448
- // instead of falling back to Node.
449
- 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);
450
712
  }
451
-
452
- if (child) {
453
- // If oam cannot be executed at all (deleted between the stat and the spawn,
454
- // wrong arch, permission), fall back rather than failing the whole server.
455
- // `spawned` prevents falling back AFTER the child started, which would
456
- // double-start the server on the same stdio.
457
- let spawned = false;
458
- child.on("spawn", () => {
459
- spawned = true;
460
- });
461
- child.on("error", (err) => {
462
- if (spawned) return;
463
- // Handle the rejection instead of discarding it: a failing in-process
464
- // fallback would otherwise escape as an unhandled rejection, replacing
465
- // this launcher's diagnostic with a raw stack trace.
466
- launchFailed(err).catch(fallbackFailed);
467
- });
468
-
469
- // Forward termination so the server's own shutdown path runs in the child
470
- // rather than the child being orphaned.
471
- //
472
- // Registering ANY handler for these suppresses Node's default
473
- // terminate-on-signal, so the parent's exit has to be arranged explicitly.
474
- // `child.killed` only records that kill() was CALLED, never that the child
475
- // is gone, so gating on it swallows every signal after the first and wedges
476
- // the launcher with no escape hatch.
477
- //
478
- // Escalation is driven by a TIMER, not by counting signals. Counting is
479
- // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
480
- // apart, and a terminal Ctrl-C reaches the whole process group, so reading
481
- // "a second signal" as impatience hard-kills a child that is already
482
- // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
483
- // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
484
- // a wall-clock step cannot mis-gate the window either.
485
- //
486
- // POSIX vs Windows, and why we do NOT forward on Windows.
487
- // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
488
- // is what lets the child run its shutdown. On Windows there are no POSIX
489
- // signals: child.kill IGNORES the name and calls TerminateProcess -- an
490
- // immediate hard kill (verified: a child with a SIGTERM handler never runs
491
- // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
492
- // graceful shutdown the console's own Ctrl-C just started, skipping the
493
- // child's process.on("exit") cleanup. The console has already notified the
494
- // child, so on Windows the timer below is the only kill we issue.
495
- const ESCALATE_AFTER_MS = 2000;
496
- let escalation = null;
497
- for (const sig of ["SIGINT", "SIGTERM"]) {
498
- process.on(sig, () => {
499
- // No try/catch: kill() on an already-exited child returns false, it does
500
- // not throw. It throws only for a signal the platform does not know,
501
- // which SIGINT/SIGTERM/SIGKILL never are.
502
- if (!isWin) child.kill(sig);
503
- if (escalation) return; // already counting down; further signals are noise
504
- escalation = setTimeout(() => {
505
- // Still here after its grace window. Stop waiting on it.
506
- child.kill("SIGKILL");
507
- process.exit(128 + (constants.signals[sig] ?? 15));
508
- }, ESCALATE_AFTER_MS);
509
- });
510
- }
511
-
512
- child.on("exit", (code, signal) => {
513
- if (escalation) clearTimeout(escalation);
514
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
515
- // conventional shell exit status rather than a bare 0.
516
- if (signal) {
517
- process.exit(128 + (constants.signals[signal] ?? 15));
518
- }
519
- process.exit(code ?? 0);
520
- });
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`);
521
717
  }
718
+ await fallBack(hostOam, "no newer oam was found").catch(fallbackFailed);
522
719
  }
523
720
  }
package/dist/index.js CHANGED
@@ -34551,7 +34551,7 @@ function buildMetaTools(state) {
34551
34551
  }
34552
34552
 
34553
34553
  // src/index.ts
34554
- var version2 = true ? "0.19.3" : resolveVersionFallback();
34554
+ var version2 = true ? "0.19.4" : resolveVersionFallback();
34555
34555
  var subcommand = process.argv[2];
34556
34556
  var USAGE = `Usage: tailscale-mcp [command]
34557
34557
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.19.3",
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",