@yawlabs/tailscale-mcp 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -531,7 +531,7 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
531
531
 
532
532
  ## Requirements
533
533
 
534
- - Node.js 20+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
534
+ - Node.js 20.11+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
535
535
  - A Tailscale API key or OAuth client credentials
536
536
 
537
537
  ## Running on oam.js (optional)
@@ -85,7 +85,7 @@ function findOam() {
85
85
  // point deliberately at a dev build.
86
86
  //
87
87
  // Both forms are checked on Windows: the installer defaults to
88
- // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
88
+ // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
89
89
  // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
90
90
  // install.
91
91
  const installed = [join(homedir(), ".oam", "bin", exe)];
@@ -98,13 +98,16 @@ function findOam() {
98
98
 
99
99
  // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
100
100
  // would cost a subprocess on every launch just to decide whether to spawn.
101
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
101
+ // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
102
+ // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
103
+ // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
104
+ // the full PATHEXT list would hand back a path this launcher cannot execute.
105
+ // Discovery has to agree with execution. A skipped shim is still reported --
106
+ // see findOamShim.
102
107
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
103
108
  if (!dir) continue;
104
- for (const ext of isWin ? pathExt : [""]) {
105
- const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
106
- if (existsSync(candidate)) return candidate;
107
- }
109
+ const candidate = join(dir, exe);
110
+ if (existsSync(candidate)) return candidate;
108
111
  }
109
112
 
110
113
  return null;
@@ -170,6 +173,48 @@ function sandboxFlags() {
170
173
  return flags;
171
174
  }
172
175
 
176
+ /**
177
+ * Write a diagnostic to stderr synchronously, so a following process.exit
178
+ * cannot truncate it.
179
+ *
180
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
181
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
182
+ * there rather than blocking the write. Loop over the remaining bytes, and if
183
+ * stderr turns out to be unusable give up quietly -- failing to print a
184
+ * diagnostic is not worth crashing a stdio server over.
185
+ */
186
+ async function errSync(message) {
187
+ const { writeSync } = await import("node:fs");
188
+ const buf = Buffer.from(message);
189
+ let off = 0;
190
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
191
+ try {
192
+ off += writeSync(2, buf, off, buf.length - off);
193
+ } catch (err) {
194
+ if (err?.code !== "EAGAIN") return;
195
+ // Pipe is full and the reader has not drained yet -- retry.
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
202
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
203
+ * reads as "install oam" -- the one thing that will not help. Windows only;
204
+ * there is no such shim concept on POSIX.
205
+ */
206
+ function findOamShim() {
207
+ if (!isWin) return null;
208
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
209
+ if (!dir) continue;
210
+ for (const ext of [".cmd", ".bat"]) {
211
+ const candidate = join(dir, `oam${ext}`);
212
+ if (existsSync(candidate)) return candidate;
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
173
218
  /** Run the server in THIS process. The zero-overhead fallback. */
174
219
  async function runInProcess() {
175
220
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -192,8 +237,21 @@ if (mode === "node") {
192
237
  await runInProcess();
193
238
  } else {
194
239
  const oam = findOam();
240
+ // Read the version ONCE, and only when discovery found something: the
241
+ // gate below has to tell "too old" apart from "could not be read at all",
242
+ // and re-probing inside the branch would cost a second subprocess.
243
+ const found = oam ? oamVersion(oam) : null;
195
244
 
196
245
  if (!oam) {
246
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
247
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
248
+ // which reads as "install oam", the one thing that will not help --
249
+ // into something the user can act on.
250
+ const oamShim = findOamShim();
251
+ const shimNote = oamShim
252
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
253
+ "Install the native oam binary, or point OAM_BIN at one.\n"
254
+ : "";
197
255
  if (mode === "oam") {
198
256
  // Explicitly demanded, so this is a real misconfiguration. writeSync
199
257
  // because stderr is async for TTYs/pipes on Windows and process.exit
@@ -201,75 +259,153 @@ if (mode === "node") {
201
259
  const { writeSync } = await import("node:fs");
202
260
  writeSync(
203
261
  2,
204
- "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no oam binary was found.\n" +
262
+ "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
205
263
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
206
264
  );
207
265
  process.exit(1);
208
266
  }
267
+ // auto: falling back is correct, but silence is how someone never learns
268
+ // their oam install is a shape this launcher skips.
269
+ if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
209
270
  await runInProcess();
210
- } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
211
- // Discovery itself stays stat-only; this is the first subprocess, and it
212
- // runs only once we have already decided to spawn oam anyway. Measured 26ms
213
- // median (n=12, windows-arm64), paid once per MCP session.
271
+ } else if (!atLeast(found, OAM_MIN)) {
214
272
  const min = OAM_MIN.join(".");
273
+ // Two different causes reach this branch and they need different
274
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
275
+ // the binary could not be run at all (not executable, wrong arch, a
276
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
277
+ // when its --version output did not parse. Telling that user to
278
+ // `oam self-update` sends them after the one cause it definitely is not.
279
+ const detail = found
280
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
281
+ : `${oam} could not be run, or did not report a version this launcher understands`;
282
+ const remedy = found
283
+ ? "Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n"
284
+ : "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
215
285
  if (mode === "oam") {
216
- const { writeSync } = await import("node:fs");
217
- writeSync(
218
- 2,
219
- `tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
220
- `Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n`,
221
- );
286
+ await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
222
287
  process.exit(1);
223
288
  }
224
- // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
225
- // a silent downgrade is how someone keeps running an oam they meant to
226
- // update. stderr is safe -- MCP frames travel on stdout.
227
- process.stderr.write(`tailscale-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
289
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
290
+ // because a silent downgrade is how someone keeps running an oam they
291
+ // meant to update, or never learns their oam is unexecutable.
292
+ await errSync(`tailscale-mcp: ${detail}; using Node instead.\n`);
228
293
  await runInProcess();
229
294
  } else {
230
295
  // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
231
296
  // --version` and any host-supplied flags survive the hop unchanged.
232
- const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
233
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
234
- // stdin/stdout is untouched and the host's stdin-close still reaches the
235
- // server's shutdown path.
236
- stdio: "inherit",
237
- env: process.env,
238
- windowsHide: true,
239
- });
240
-
241
- // If oam cannot be executed at all (deleted between the stat and the spawn,
242
- // wrong arch, permission), fall back rather than failing the whole server.
243
- // `spawned` prevents falling back AFTER the child started, which would
244
- // double-start the server on the same stdio.
245
- let spawned = false;
246
- child.on("spawn", () => {
247
- spawned = true;
248
- });
249
- child.on("error", (err) => {
250
- if (spawned) return;
297
+ // Every "oam could not be executed" outcome lands here: the synchronous
298
+ // throw from spawn() and the async 'error' event mean the same thing and
299
+ // must degrade the same way, so the handling lives in one place.
300
+ // errSync rather than process.stderr.write because stderr is async for
301
+ // TTYs and pipes on Windows and the process.exit below truncates pending
302
+ // writes.
303
+ const launchFailed = async (err) => {
251
304
  if (mode === "oam") {
252
- process.stderr.write(`tailscale-mcp: failed to launch oam (${err.message})\n`);
305
+ await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
253
306
  process.exit(1);
254
307
  }
255
- void runInProcess();
256
- });
308
+ await runInProcess();
309
+ };
257
310
 
258
- // Forward termination so the server's own shutdown path runs in the child
259
- // rather than the child being orphaned. No-op on Windows, harmless to add.
260
- for (const sig of ["SIGINT", "SIGTERM"]) {
261
- process.on(sig, () => {
262
- if (!child.killed) child.kill(sig);
311
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
312
+ // path and the 'error'-event path cannot drift apart. Either can reject:
313
+ // runInProcess() is a bare import() that rejects when dist/index.js is
314
+ // missing, and at ESM top level an unhandled rejection is an uncaught
315
+ // exception -- the exact failure this handling exists to prevent.
316
+ const fallbackFailed = (e) => {
317
+ process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
318
+ process.exitCode = 1;
319
+ };
320
+
321
+ let child = null;
322
+ try {
323
+ child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
324
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
325
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
326
+ // server's shutdown path.
327
+ stdio: "inherit",
328
+ env: process.env,
329
+ windowsHide: true,
263
330
  });
331
+ } catch (err) {
332
+ // spawn() THROWS for some failures instead of emitting 'error', and the
333
+ // 'error' listener is registered AFTER this call, so it can never observe
334
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
335
+ // instead of falling back to Node.
336
+ await launchFailed(err).catch(fallbackFailed);
264
337
  }
265
338
 
266
- child.on("exit", (code, signal) => {
267
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
268
- // conventional shell exit status rather than a bare 0.
269
- if (signal) {
270
- process.exit(128 + (constants.signals[signal] ?? 15));
339
+ if (child) {
340
+
341
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
342
+ // wrong arch, permission), fall back rather than failing the whole server.
343
+ // `spawned` prevents falling back AFTER the child started, which would
344
+ // double-start the server on the same stdio.
345
+ let spawned = false;
346
+ child.on("spawn", () => {
347
+ spawned = true;
348
+ });
349
+ child.on("error", (err) => {
350
+ if (spawned) return;
351
+ // Handle the rejection instead of discarding it: a failing in-process
352
+ // fallback would otherwise escape as an unhandled rejection, replacing
353
+ // this launcher's diagnostic with a raw stack trace.
354
+ launchFailed(err).catch(fallbackFailed);
355
+ });
356
+
357
+ // Forward termination so the server's own shutdown path runs in the child
358
+ // rather than the child being orphaned.
359
+ //
360
+ // Registering ANY handler for these suppresses Node's default
361
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
362
+ // `child.killed` only records that kill() was CALLED, never that the child
363
+ // is gone, so gating on it swallows every signal after the first and wedges
364
+ // the launcher with no escape hatch.
365
+ //
366
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
367
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
368
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
369
+ // "a second signal" as impatience hard-kills a child that is already
370
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
371
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
372
+ // a wall-clock step cannot mis-gate the window either.
373
+ //
374
+ // POSIX vs Windows, and why we do NOT forward on Windows.
375
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
376
+ // is what lets the child run its shutdown. On Windows there are no POSIX
377
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
378
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
379
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
380
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
381
+ // child's process.on("exit") cleanup. The console has already notified the
382
+ // child, so on Windows the timer below is the only kill we issue.
383
+ const ESCALATE_AFTER_MS = 2000;
384
+ let escalation = null;
385
+ for (const sig of ["SIGINT", "SIGTERM"]) {
386
+ process.on(sig, () => {
387
+ // No try/catch: kill() on an already-exited child returns false, it does
388
+ // not throw. It throws only for a signal the platform does not know,
389
+ // which SIGINT/SIGTERM/SIGKILL never are.
390
+ if (!isWin) child.kill(sig);
391
+ if (escalation) return; // already counting down; further signals are noise
392
+ escalation = setTimeout(() => {
393
+ // Still here after its grace window. Stop waiting on it.
394
+ child.kill("SIGKILL");
395
+ process.exit(128 + (constants.signals[sig] ?? 15));
396
+ }, ESCALATE_AFTER_MS);
397
+ });
271
398
  }
272
- process.exit(code ?? 0);
273
- });
399
+
400
+ child.on("exit", (code, signal) => {
401
+ if (escalation) clearTimeout(escalation);
402
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
403
+ // conventional shell exit status rather than a bare 0.
404
+ if (signal) {
405
+ process.exit(128 + (constants.signals[signal] ?? 15));
406
+ }
407
+ process.exit(code ?? 0);
408
+ });
409
+ }
274
410
  }
275
411
  }
package/dist/index.js CHANGED
@@ -34047,7 +34047,7 @@ async function tailnetDnsResource(uri) {
34047
34047
  }
34048
34048
 
34049
34049
  // src/index.ts
34050
- var version2 = true ? "0.17.0" : resolveVersionFallback();
34050
+ var version2 = true ? "0.17.1" : resolveVersionFallback();
34051
34051
  var subcommand = process.argv[2];
34052
34052
  var cliSubcommandHandled = false;
34053
34053
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
34
34
  "dev": "tsc --watch",
35
35
  "start": "node dist/index.js",
36
- "test": "npm run build && node --test \"dist/**/*.test.js\"",
36
+ "test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
37
37
  "test:ci": "npm run test",
38
38
  "lint": "biome check src/",
39
39
  "lint:fix": "biome check --write src/",
@@ -59,7 +59,7 @@
59
59
  "zod": "^4.3.6"
60
60
  },
61
61
  "engines": {
62
- "node": ">=20"
62
+ "node": ">=20.11.0"
63
63
  },
64
64
  "devEngines": {
65
65
  "runtime": {