@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 +1 -1
- package/bin/tailscale-mcp.mjs +191 -55
- package/dist/index.js +1 -1
- package/package.json +3 -3
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)
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
225
|
-
// a silent downgrade is how someone keeps running an oam they
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
305
|
+
await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
253
306
|
process.exit(1);
|
|
254
307
|
}
|
|
255
|
-
|
|
256
|
-
}
|
|
308
|
+
await runInProcess();
|
|
309
|
+
};
|
|
257
310
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": {
|