@yawlabs/tailscale-mcp 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![Release](https://img.shields.io/badge/release-local-blue)](./release.sh)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 94 admin-API tools + 6 optional local-CLI diagnostics + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 96 admin-API tools + 6 optional local-CLI diagnostics + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -104,7 +104,7 @@ That's it. Now ask your agent:
104
104
 
105
105
  ## Too many tools? Subset them.
106
106
 
107
- 94 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
107
+ 96 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
108
108
 
109
109
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
110
110
 
@@ -118,8 +118,8 @@ That's it. Now ask your agent:
118
118
  ```
119
119
 
120
120
  - **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
121
- - **`core`** (49 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
- - **`full`** (94 tools, default) — everything. Same as omitting the env var.
121
+ - **`core`** (51 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
+ - **`full`** (96 tools, default) — everything. Same as omitting the env var.
123
123
 
124
124
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
125
125
 
@@ -227,7 +227,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add si
227
227
 
228
228
  Requirements: the `tailscale` binary must be in `PATH`. If it's installed somewhere unusual, set `TAILSCALE_BINARY` to its absolute path. The MCP server doesn't need root to run these — they're all diagnostic, not state-mutating. Operations that would need elevation (`tailscale up`, `set --advertise-routes`, `lock sign`) are deliberately not exposed.
229
229
 
230
- When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (100 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 94.
230
+ When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (102 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 96.
231
231
 
232
232
  ## Resources (4)
233
233
 
@@ -240,7 +240,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
240
240
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
241
241
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
242
242
 
243
- ## Tools (94 + 6 opt-in)
243
+ ## Tools (96 + 6 opt-in)
244
244
 
245
245
  <details>
246
246
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -308,7 +308,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
308
308
  </details>
309
309
 
310
310
  <details>
311
- <summary><strong>Keys / Trust Credentials</strong> (7 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
311
+ <summary><strong>Keys / Trust Credentials</strong> (9 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
312
312
 
313
313
  | Tool | Description |
314
314
  |------|-------------|
@@ -319,6 +319,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
319
319
  | `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
320
320
  | `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
321
321
  | `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
322
+ | `tailscale_list_oauth_apps` | List every OAuth App registered in the tailnet |
323
+ | `tailscale_delete_oauth_app` | Delete an OAuth App, revoking its ability to provision devices |
322
324
 
323
325
  </details>
324
326
 
@@ -531,18 +533,18 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
531
533
 
532
534
  ## Requirements
533
535
 
534
- - Node.js 20+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
536
+ - 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
537
  - A Tailscale API key or OAuth client credentials
536
538
 
537
539
  ## Running on oam.js (optional)
538
540
 
539
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 94 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.
541
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 96 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.
540
542
 
541
543
  **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.
542
544
 
543
545
  ### Sandboxing (opt-in)
544
546
 
545
- Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` and `login.tailscale.com`, filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
547
+ Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` -- the only host the bundle contacts, including the OAuth token exchange -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
546
548
 
547
549
  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.
548
550
 
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
26
  * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
27
- * network limited to the control-plane hosts, filesystem denied.
27
+ * network limited to the one host the bundle actually calls
28
+ * (api.tailscale.com), filesystem denied.
28
29
  *
29
30
  * Child-process is granted unconditionally because the local-CLI tools shell out
30
31
  * to the `tailscale` binary; that is also why PATH stays in the env grant, since
@@ -49,6 +50,7 @@
49
50
  * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
50
51
  * TAILSCALE_MCP_RUNTIME=node never use oam
51
52
  * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
53
+ * anything else warns on stderr, then behaves as auto
52
54
  * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
53
55
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
54
56
  */
@@ -85,7 +87,7 @@ function findOam() {
85
87
  // point deliberately at a dev build.
86
88
  //
87
89
  // Both forms are checked on Windows: the installer defaults to
88
- // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
90
+ // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
89
91
  // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
90
92
  // install.
91
93
  const installed = [join(homedir(), ".oam", "bin", exe)];
@@ -98,13 +100,16 @@ function findOam() {
98
100
 
99
101
  // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
100
102
  // would cost a subprocess on every launch just to decide whether to spawn.
101
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
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.
102
109
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
103
110
  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
- }
111
+ const candidate = join(dir, exe);
112
+ if (existsSync(candidate)) return candidate;
108
113
  }
109
114
 
110
115
  return null;
@@ -153,7 +158,16 @@ function atLeast(v, min) {
153
158
  function sandboxFlags() {
154
159
  if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
155
160
 
156
- const hosts = ["api.tailscale.com","login.tailscale.com"];
161
+ // ONE host, deliberately. Every outbound request the bundle makes targets
162
+ // api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
163
+ // allow-list that refuses to send credentials anywhere else. login.tailscale.com
164
+ // was granted here as well until an audit found no code path that contacts it
165
+ // -- console.tailscale.com appears in error TEXT, never as a request target.
166
+ // An unused grant is the one kind of over-permission nothing ever surfaces:
167
+ // removing it cannot break a call that was never made, and keeping it widens
168
+ // the sandbox for no behaviour. launcher.test.ts pins this list exactly so a
169
+ // future host lands as a reviewed diff rather than a quiet widening.
170
+ const hosts = ["api.tailscale.com"];
157
171
 
158
172
  const netFlag = `--allow-net=${hosts.join(",")}`;
159
173
 
@@ -163,13 +177,73 @@ function sandboxFlags() {
163
177
  // meant the local-CLI tool group silently failed to register under the
164
178
  // sandbox even though --allow-child-process is granted below precisely so
165
179
  // those tools can shell out.
166
- const env = ["PATH","TAILSCALE_API_KEY","TAILSCALE_BINARY","TAILSCALE_DEBUG","TAILSCALE_EXTRA_POSTURE_PROVIDERS","TAILSCALE_EXTRA_WEBHOOK_EVENTS","TAILSCALE_LOCAL_CLI","TAILSCALE_MAX_CONCURRENT","TAILSCALE_OAUTH_CLIENT_ID","TAILSCALE_OAUTH_CLIENT_SECRET","TAILSCALE_OAUTH_TAILNET","TAILSCALE_PROFILE","TAILSCALE_READONLY","TAILSCALE_REQUEST_BUDGET_MS","TAILSCALE_RETRY_BASE_DELAY_MS","TAILSCALE_TAILNET","TAILSCALE_TOOLS"];
180
+ const env = [
181
+ "PATH",
182
+ "TAILSCALE_API_KEY",
183
+ "TAILSCALE_BINARY",
184
+ "TAILSCALE_DEBUG",
185
+ "TAILSCALE_EXTRA_POSTURE_PROVIDERS",
186
+ "TAILSCALE_EXTRA_WEBHOOK_EVENTS",
187
+ "TAILSCALE_LOCAL_CLI",
188
+ "TAILSCALE_MAX_CONCURRENT",
189
+ "TAILSCALE_OAUTH_CLIENT_ID",
190
+ "TAILSCALE_OAUTH_CLIENT_SECRET",
191
+ "TAILSCALE_OAUTH_TAILNET",
192
+ "TAILSCALE_PROFILE",
193
+ "TAILSCALE_READONLY",
194
+ "TAILSCALE_REQUEST_BUDGET_MS",
195
+ "TAILSCALE_RETRY_BASE_DELAY_MS",
196
+ "TAILSCALE_TAILNET",
197
+ "TAILSCALE_TOOLS",
198
+ ];
167
199
 
168
200
  const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
169
201
  flags.push("--allow-child-process");
170
202
  return flags;
171
203
  }
172
204
 
205
+ /**
206
+ * Write a diagnostic to stderr synchronously, so a following process.exit
207
+ * cannot truncate it.
208
+ *
209
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
210
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
211
+ * there rather than blocking the write. Loop over the remaining bytes, and if
212
+ * stderr turns out to be unusable give up quietly -- failing to print a
213
+ * diagnostic is not worth crashing a stdio server over.
214
+ */
215
+ async function errSync(message) {
216
+ const { writeSync } = await import("node:fs");
217
+ const buf = Buffer.from(message);
218
+ let off = 0;
219
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
220
+ try {
221
+ off += writeSync(2, buf, off, buf.length - off);
222
+ } catch (err) {
223
+ if (err?.code !== "EAGAIN") return;
224
+ // Pipe is full and the reader has not drained yet -- retry.
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
231
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
232
+ * reads as "install oam" -- the one thing that will not help. Windows only;
233
+ * there is no such shim concept on POSIX.
234
+ */
235
+ function findOamShim() {
236
+ if (!isWin) return null;
237
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
238
+ if (!dir) continue;
239
+ for (const ext of [".cmd", ".bat"]) {
240
+ const candidate = join(dir, `oam${ext}`);
241
+ if (existsSync(candidate)) return candidate;
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
173
247
  /** Run the server in THIS process. The zero-overhead fallback. */
174
248
  async function runInProcess() {
175
249
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -186,14 +260,43 @@ async function runInProcess() {
186
260
  await import(SERVER_URL.href);
187
261
  }
188
262
 
189
- const mode = (process.env.TAILSCALE_MCP_RUNTIME ?? "auto").toLowerCase();
263
+ // Every value below is compared against `mode` after lowercasing, so an
264
+ // unrecognized one matched nothing and fell through to the auto branch --
265
+ // `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
266
+ // which is the opposite of what was asked for. Same handling as index.ts gives
267
+ // an unknown subcommand: auto is still the right landing place, it just stops
268
+ // being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
269
+ // wrapper script is how it usually gets there.
270
+ const RUNTIMES = ["auto", "node", "oam"];
271
+ const requested = process.env.TAILSCALE_MCP_RUNTIME;
272
+ const mode = (requested ?? "auto").toLowerCase();
273
+ if (requested && !RUNTIMES.includes(mode)) {
274
+ // Echo what was SET, not the lowercased form, so the typo is recognisable in
275
+ // the host's log next to the config line that produced it.
276
+ await errSync(
277
+ `tailscale-mcp: unrecognized TAILSCALE_MCP_RUNTIME "${requested}" -- known values: ${RUNTIMES.join(", ")}. Using auto.\n`,
278
+ );
279
+ }
190
280
 
191
281
  if (mode === "node") {
192
282
  await runInProcess();
193
283
  } else {
194
284
  const oam = findOam();
285
+ // Read the version ONCE, and only when discovery found something: the
286
+ // gate below has to tell "too old" apart from "could not be read at all",
287
+ // and re-probing inside the branch would cost a second subprocess.
288
+ const found = oam ? oamVersion(oam) : null;
195
289
 
196
290
  if (!oam) {
291
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
292
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
293
+ // which reads as "install oam", the one thing that will not help --
294
+ // into something the user can act on.
295
+ const oamShim = findOamShim();
296
+ const shimNote = oamShim
297
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
298
+ "Install the native oam binary, or point OAM_BIN at one.\n"
299
+ : "";
197
300
  if (mode === "oam") {
198
301
  // Explicitly demanded, so this is a real misconfiguration. writeSync
199
302
  // because stderr is async for TTYs/pipes on Windows and process.exit
@@ -201,75 +304,153 @@ if (mode === "node") {
201
304
  const { writeSync } = await import("node:fs");
202
305
  writeSync(
203
306
  2,
204
- "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no oam binary was found.\n" +
307
+ "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
308
+ shimNote +
205
309
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
206
310
  );
207
311
  process.exit(1);
208
312
  }
313
+ // auto: falling back is correct, but silence is how someone never learns
314
+ // their oam install is a shape this launcher skips.
315
+ if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
209
316
  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.
317
+ } else if (!atLeast(found, OAM_MIN)) {
214
318
  const min = OAM_MIN.join(".");
319
+ // Two different causes reach this branch and they need different
320
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
321
+ // the binary could not be run at all (not executable, wrong arch, a
322
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
323
+ // when its --version output did not parse. Telling that user to
324
+ // `oam self-update` sends them after the one cause it definitely is not.
325
+ const detail = found
326
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
327
+ : `${oam} could not be run, or did not report a version this launcher understands`;
328
+ const remedy = found
329
+ ? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
330
+ : "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
215
331
  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
- );
332
+ await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
222
333
  process.exit(1);
223
334
  }
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`);
335
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
336
+ // because a silent downgrade is how someone keeps running an oam they
337
+ // meant to update, or never learns their oam is unexecutable.
338
+ await errSync(`tailscale-mcp: ${detail}; using Node instead.\n`);
228
339
  await runInProcess();
229
340
  } else {
230
341
  // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
231
342
  // --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;
343
+ // Every "oam could not be executed" outcome lands here: the synchronous
344
+ // throw from spawn() and the async 'error' event mean the same thing and
345
+ // must degrade the same way, so the handling lives in one place.
346
+ // errSync rather than process.stderr.write because stderr is async for
347
+ // TTYs and pipes on Windows and the process.exit below truncates pending
348
+ // writes.
349
+ const launchFailed = async (err) => {
251
350
  if (mode === "oam") {
252
- process.stderr.write(`tailscale-mcp: failed to launch oam (${err.message})\n`);
351
+ await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
253
352
  process.exit(1);
254
353
  }
255
- void runInProcess();
256
- });
354
+ await runInProcess();
355
+ };
356
+
357
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
358
+ // path and the 'error'-event path cannot drift apart. Either can reject:
359
+ // runInProcess() is a bare import() that rejects when dist/index.js is
360
+ // missing, and at ESM top level an unhandled rejection is an uncaught
361
+ // exception -- the exact failure this handling exists to prevent.
362
+ const fallbackFailed = (e) => {
363
+ process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
364
+ process.exitCode = 1;
365
+ };
257
366
 
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);
367
+ let child = null;
368
+ try {
369
+ child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
370
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
371
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
372
+ // server's shutdown path.
373
+ stdio: "inherit",
374
+ env: process.env,
375
+ windowsHide: true,
263
376
  });
377
+ } catch (err) {
378
+ // spawn() THROWS for some failures instead of emitting 'error', and the
379
+ // 'error' listener is registered AFTER this call, so it can never observe
380
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
381
+ // instead of falling back to Node.
382
+ await launchFailed(err).catch(fallbackFailed);
264
383
  }
265
384
 
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));
385
+ if (child) {
386
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
387
+ // wrong arch, permission), fall back rather than failing the whole server.
388
+ // `spawned` prevents falling back AFTER the child started, which would
389
+ // double-start the server on the same stdio.
390
+ let spawned = false;
391
+ child.on("spawn", () => {
392
+ spawned = true;
393
+ });
394
+ child.on("error", (err) => {
395
+ if (spawned) return;
396
+ // Handle the rejection instead of discarding it: a failing in-process
397
+ // fallback would otherwise escape as an unhandled rejection, replacing
398
+ // this launcher's diagnostic with a raw stack trace.
399
+ launchFailed(err).catch(fallbackFailed);
400
+ });
401
+
402
+ // Forward termination so the server's own shutdown path runs in the child
403
+ // rather than the child being orphaned.
404
+ //
405
+ // Registering ANY handler for these suppresses Node's default
406
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
407
+ // `child.killed` only records that kill() was CALLED, never that the child
408
+ // is gone, so gating on it swallows every signal after the first and wedges
409
+ // the launcher with no escape hatch.
410
+ //
411
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
412
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
413
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
414
+ // "a second signal" as impatience hard-kills a child that is already
415
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
416
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
417
+ // a wall-clock step cannot mis-gate the window either.
418
+ //
419
+ // POSIX vs Windows, and why we do NOT forward on Windows.
420
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
421
+ // is what lets the child run its shutdown. On Windows there are no POSIX
422
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
423
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
424
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
425
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
426
+ // child's process.on("exit") cleanup. The console has already notified the
427
+ // child, so on Windows the timer below is the only kill we issue.
428
+ const ESCALATE_AFTER_MS = 2000;
429
+ let escalation = null;
430
+ for (const sig of ["SIGINT", "SIGTERM"]) {
431
+ process.on(sig, () => {
432
+ // No try/catch: kill() on an already-exited child returns false, it does
433
+ // not throw. It throws only for a signal the platform does not know,
434
+ // which SIGINT/SIGTERM/SIGKILL never are.
435
+ if (!isWin) child.kill(sig);
436
+ if (escalation) return; // already counting down; further signals are noise
437
+ escalation = setTimeout(() => {
438
+ // Still here after its grace window. Stop waiting on it.
439
+ child.kill("SIGKILL");
440
+ process.exit(128 + (constants.signals[sig] ?? 15));
441
+ }, ESCALATE_AFTER_MS);
442
+ });
271
443
  }
272
- process.exit(code ?? 0);
273
- });
444
+
445
+ child.on("exit", (code, signal) => {
446
+ if (escalation) clearTimeout(escalation);
447
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
448
+ // conventional shell exit status rather than a bare 0.
449
+ if (signal) {
450
+ process.exit(128 + (constants.signals[signal] ?? 15));
451
+ }
452
+ process.exit(code ?? 0);
453
+ });
454
+ }
274
455
  }
275
456
  }
package/dist/index.js CHANGED
@@ -31035,6 +31035,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31035
31035
  })();
31036
31036
  return oauthRefreshPromise;
31037
31037
  }
31038
+ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
31039
+ if (!oauthToken) return;
31040
+ if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
31041
+ oauthToken = null;
31042
+ }
31038
31043
  async function getAuthHeader() {
31039
31044
  const config2 = getAuthConfig();
31040
31045
  if (config2.kind === "apiKey") {
@@ -31130,14 +31135,17 @@ function getRetryBaseDelayMs() {
31130
31135
  }
31131
31136
  async function withConcurrencyLimit(fn) {
31132
31137
  const limit = getConcurrencyLimit();
31133
- if (limit === 0) return fn();
31138
+ if (limit === 0) return fn(0);
31139
+ let queuedForMs = 0;
31134
31140
  if (inFlight >= limit) {
31141
+ const queueStartedAt = Date.now();
31135
31142
  await new Promise((resolve) => concurrencyQueue.push(resolve));
31143
+ queuedForMs = Date.now() - queueStartedAt;
31136
31144
  } else {
31137
31145
  inFlight++;
31138
31146
  }
31139
31147
  try {
31140
- return await fn();
31148
+ return await fn(queuedForMs);
31141
31149
  } finally {
31142
31150
  const next = concurrencyQueue.shift();
31143
31151
  if (next) {
@@ -31188,6 +31196,15 @@ function describeTransportError(err, method, attemptTimeoutMs) {
31188
31196
  }
31189
31197
  return `${method} request failed: ${String(err)}`;
31190
31198
  }
31199
+ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
31200
+ if (lastTransportError) {
31201
+ return `${lastTransportError}; request budget of ${budgetMs}ms exhausted before next attempt could begin.`;
31202
+ }
31203
+ if (queuedForMs > 0) {
31204
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin: ${queuedForMs}ms of it was spent waiting for a free slot under TAILSCALE_MAX_CONCURRENT, so no request was ever sent. Raise TAILSCALE_MAX_CONCURRENT or TAILSCALE_REQUEST_BUDGET_MS -- this is queueing, not a network fault.`;
31205
+ }
31206
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
31207
+ }
31191
31208
  async function apiRequest(method, path, body, options) {
31192
31209
  const headers = {};
31193
31210
  if (options?.accept) {
@@ -31217,7 +31234,7 @@ async function apiRequest(method, path, body, options) {
31217
31234
  debugLog(`${method} ${url2}`);
31218
31235
  const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
31219
31236
  const requestBudgetMs = getRequestBudgetMs();
31220
- return withConcurrencyLimit(async () => {
31237
+ return withConcurrencyLimit(async (queuedForMs) => {
31221
31238
  headers.Authorization = await getAuthHeader();
31222
31239
  let res;
31223
31240
  let lastTransportError;
@@ -31227,7 +31244,7 @@ async function apiRequest(method, path, body, options) {
31227
31244
  return {
31228
31245
  ok: false,
31229
31246
  status: 0,
31230
- error: lastTransportError ? `${lastTransportError}; request budget of ${requestBudgetMs}ms exhausted before next attempt could begin.` : `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
31247
+ error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
31231
31248
  };
31232
31249
  }
31233
31250
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
@@ -31268,6 +31285,9 @@ async function apiRequest(method, path, body, options) {
31268
31285
  const etag = response.headers.get("etag") || void 0;
31269
31286
  const elapsed = Date.now() - startedAt;
31270
31287
  debugLog(` <- ${response.status} (${elapsed}ms)`);
31288
+ if (response.status === 401) {
31289
+ invalidateOAuthTokenOnUnauthorized(headers.Authorization);
31290
+ }
31271
31291
  try {
31272
31292
  if (options?.acceptRaw) {
31273
31293
  const rawBody = await response.text();
@@ -31442,6 +31462,18 @@ function filterTools(groups, options) {
31442
31462
  }
31443
31463
 
31444
31464
  // src/tools/acl.ts
31465
+ var ETAG_FOOTER_MARKER = "// ETag: ";
31466
+ function stripEtagFooter(body) {
31467
+ const lines = body.split("\n");
31468
+ let cut = lines.length;
31469
+ for (let i = lines.length - 1; i >= 0; i--) {
31470
+ const line = lines[i].trim();
31471
+ if (line === "") continue;
31472
+ if (!line.startsWith("//")) break;
31473
+ if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
31474
+ }
31475
+ return lines.slice(0, cut).join("\n");
31476
+ }
31445
31477
  var aclTools = [
31446
31478
  {
31447
31479
  name: "tailscale_get_acl",
@@ -31462,12 +31494,12 @@ var aclTools = [
31462
31494
  if (res.ok && res.etag) {
31463
31495
  const footer = [
31464
31496
  "",
31465
- `// ETag: ${res.etag}`,
31497
+ `${ETAG_FOOTER_MARKER}${res.etag}`,
31466
31498
  "// Pass this ETag to tailscale_update_acl when updating the policy.",
31467
31499
  "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
31468
31500
  ""
31469
31501
  ].join("\n");
31470
- return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
31502
+ return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
31471
31503
  }
31472
31504
  return res;
31473
31505
  }
@@ -31478,7 +31510,10 @@ var aclTools = [
31478
31510
  annotations: {
31479
31511
  title: "Update ACL policy",
31480
31512
  readOnlyHint: false,
31481
- destructiveHint: false,
31513
+ // Overwrites the whole policy file in one call, and a bad push can lock
31514
+ // every device out of the tailnet -- the widest blast radius of any write
31515
+ // here, so clients must gate it rather than auto-approve it.
31516
+ destructiveHint: true,
31482
31517
  idempotentHint: true,
31483
31518
  openWorldHint: true
31484
31519
  },
@@ -31486,8 +31521,16 @@ var aclTools = [
31486
31521
  policy: external_exports.string().describe(
31487
31522
  "The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
31488
31523
  ),
31489
- etag: external_exports.string().describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
31524
+ etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
31490
31525
  }),
31526
+ // `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
31527
+ // `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
31528
+ // omitted entirely -- the write then overwrites a concurrent admin edit instead
31529
+ // of coming back 412, on the widest-blast-radius write in the package, with
31530
+ // no diagnostic anywhere. `.trim()` is load-bearing for the same reason it is
31531
+ // on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
31532
+ // header goes out carrying a precondition that cannot match any real ETag --
31533
+ // a confusing 412 instead of a local validation error naming the field.
31491
31534
  handler: async (input) => {
31492
31535
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31493
31536
  rawBody: input.policy,
@@ -31805,7 +31848,9 @@ var deviceTools = [
31805
31848
  annotations: {
31806
31849
  title: "Set device routes",
31807
31850
  readOnlyHint: false,
31808
- destructiveHint: false,
31851
+ // Replace-all: the routes array is the new enabled set, so `[]` silently
31852
+ // withdraws every subnet the device currently routes.
31853
+ destructiveHint: true,
31809
31854
  idempotentHint: true,
31810
31855
  openWorldHint: true
31811
31856
  },
@@ -31890,7 +31935,9 @@ var deviceTools = [
31890
31935
  annotations: {
31891
31936
  title: "Set device tags",
31892
31937
  readOnlyHint: false,
31893
- destructiveHint: false,
31938
+ // Replace-all: `[]` strips every ACL tag, which can drop the device out of
31939
+ // the policy rules that grant it access.
31940
+ destructiveHint: true,
31894
31941
  idempotentHint: true,
31895
31942
  openWorldHint: true
31896
31943
  },
@@ -31969,7 +32016,7 @@ var deviceTools = [
31969
32016
  const failed = {};
31970
32017
  for (const { deviceId, res } of results) {
31971
32018
  if (res.ok) succeeded.push(deviceId);
31972
- else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
32019
+ else failed[deviceId] = { status: res.status, error: res.error || `HTTP ${res.status}` };
31973
32020
  }
31974
32021
  const failedCount = Object.keys(failed).length;
31975
32022
  if (failedCount === unique.length) {
@@ -32053,7 +32100,9 @@ var dnsTools = [
32053
32100
  annotations: {
32054
32101
  title: "Set nameservers",
32055
32102
  readOnlyHint: false,
32056
- destructiveHint: false,
32103
+ // Replace-all: `[]` clears tailnet DNS resolution rather than leaving the
32104
+ // current nameservers in place.
32105
+ destructiveHint: true,
32057
32106
  idempotentHint: true,
32058
32107
  openWorldHint: true
32059
32108
  },
@@ -32085,7 +32134,8 @@ var dnsTools = [
32085
32134
  annotations: {
32086
32135
  title: "Set DNS search paths",
32087
32136
  readOnlyHint: false,
32088
- destructiveHint: false,
32137
+ // Replace-all: `[]` clears every configured search domain.
32138
+ destructiveHint: true,
32089
32139
  idempotentHint: true,
32090
32140
  openWorldHint: true
32091
32141
  },
@@ -32119,7 +32169,9 @@ var dnsTools = [
32119
32169
  annotations: {
32120
32170
  title: "Set split DNS",
32121
32171
  readOnlyHint: false,
32122
- destructiveHint: false,
32172
+ // Replace-all (PUT): every domain absent from the map is dropped, so `{}`
32173
+ // clears the whole split DNS config. The PATCH sibling below merges instead.
32174
+ destructiveHint: true,
32123
32175
  idempotentHint: true,
32124
32176
  openWorldHint: true
32125
32177
  },
@@ -32206,7 +32258,9 @@ var dnsTools = [
32206
32258
  annotations: {
32207
32259
  title: "Set DNS configuration (unified)",
32208
32260
  readOnlyHint: false,
32209
- destructiveHint: false,
32261
+ // Replace-all across every DNS setting at once -- a superset of the wipe
32262
+ // the individual setters above can do.
32263
+ destructiveHint: true,
32210
32264
  idempotentHint: true,
32211
32265
  openWorldHint: true
32212
32266
  },
@@ -32663,6 +32717,48 @@ var keyTools = [
32663
32717
  handler: async (input) => {
32664
32718
  return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32665
32719
  }
32720
+ },
32721
+ {
32722
+ name: "tailscale_list_oauth_apps",
32723
+ description: "List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an `oauthApps` array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.",
32724
+ annotations: {
32725
+ title: "List OAuth apps",
32726
+ readOnlyHint: true,
32727
+ destructiveHint: false,
32728
+ idempotentHint: true,
32729
+ openWorldHint: true
32730
+ },
32731
+ // No limit/cursor: the live endpoint takes no parameters and returns the
32732
+ // whole collection. Advertising pagination the API ignores would let a
32733
+ // caller believe it had paged through the list when it had not.
32734
+ inputSchema: external_exports.object({}),
32735
+ handler: async () => {
32736
+ return apiGet(`/tailnet/${getTailnet()}/oauth-apps`);
32737
+ }
32738
+ },
32739
+ {
32740
+ name: "tailscale_delete_oauth_app",
32741
+ description: "Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.",
32742
+ annotations: {
32743
+ title: "Delete OAuth app",
32744
+ readOnlyHint: false,
32745
+ destructiveHint: true,
32746
+ idempotentHint: true,
32747
+ openWorldHint: true
32748
+ },
32749
+ inputSchema: external_exports.object({
32750
+ // `.trim().min(1)` rather than the bare `.min(1)` on
32751
+ // tailscale_get_oauth_app's appId, for the reason tailnets.ts spells out
32752
+ // on tailscale_delete_tailnet: a bare min(1) accepts " ", which encPath
32753
+ // then sends as the literal segment "%20". On a read that costs a wasted
32754
+ // round-trip; on an irreversible revoke it returns a 404 that reads like
32755
+ // the app is already gone. Trimming at the schema makes it a validation
32756
+ // error instead.
32757
+ appId: external_exports.string().trim().min(1).describe("The OAuth app ID to delete (see tailscale_list_oauth_apps)")
32758
+ }),
32759
+ handler: async (input) => {
32760
+ return apiDelete(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32761
+ }
32666
32762
  }
32667
32763
  ];
32668
32764
 
@@ -32866,8 +32962,8 @@ var logStreamingTools = [
32866
32962
  apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
32867
32963
  ]);
32868
32964
  const errors = {};
32869
- if (!configuration.ok) errors.configuration = configuration.error ?? `HTTP ${configuration.status}`;
32870
- if (!network.ok) errors.network = network.error ?? `HTTP ${network.status}`;
32965
+ if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
32966
+ if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
32871
32967
  if (!configuration.ok && !network.ok) {
32872
32968
  return {
32873
32969
  ok: false,
@@ -33353,8 +33449,8 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
33353
33449
  settings: settingsRes.ok ? settingsRes.data : null
33354
33450
  };
33355
33451
  const errors = {};
33356
- if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
33357
- if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
33452
+ if (!devicesRes.ok) errors.devices = devicesRes.error || `HTTP ${devicesRes.status}`;
33453
+ if (!settingsRes.ok) errors.settings = settingsRes.error || `HTTP ${settingsRes.status}`;
33358
33454
  if (Object.keys(errors).length > 0) data.errors = errors;
33359
33455
  return data;
33360
33456
  }
@@ -33486,7 +33582,7 @@ var tailnetTools = [
33486
33582
  const failed = {};
33487
33583
  for (const { contactType, res } of results) {
33488
33584
  if (res.ok) applied[contactType] = res.data;
33489
- else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
33585
+ else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
33490
33586
  }
33491
33587
  const hasFailed = Object.keys(failed).length > 0;
33492
33588
  const hasApplied = Object.keys(applied).length > 0;
@@ -33573,7 +33669,7 @@ var tailnetsTools = [
33573
33669
  },
33574
33670
  {
33575
33671
  name: "tailscale_delete_tailnet",
33576
- description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33672
+ description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. That check is a typo guard, not an authorization gate: when you also pass `tailnet` you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-`tailnet` path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33577
33673
  annotations: {
33578
33674
  title: "Delete tailnet",
33579
33675
  readOnlyHint: false,
@@ -33586,7 +33682,7 @@ var tailnetsTools = [
33586
33682
  "Tailnet to delete (e.g. an id from tailscale_list_org_tailnets). Omit to target the configured tailnet. Requires credentials scoped to reach it."
33587
33683
  ),
33588
33684
  confirmTailnet: external_exports.string().trim().min(1).describe(
33589
- "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A deliberate second look before an irreversible org-wide delete."
33685
+ "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A typo guard, not an authorization gate: on the explicit-`tailnet` path the caller writes both halves of the comparison, so it proves only self-agreement. It is a real second look only when `tailnet` is omitted and the value has to match the operator's environment."
33590
33686
  )
33591
33687
  }),
33592
33688
  // `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
@@ -33604,8 +33700,9 @@ var tailnetsTools = [
33604
33700
  );
33605
33701
  }
33606
33702
  if (input.confirmTailnet !== target) {
33703
+ const source = input.tailnet?.trim() ? "tailnet you named" : "configured tailnet";
33607
33704
  throw new Error(
33608
- `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the configured tailnet ${JSON.stringify(target)}. Refusing to delete.`
33705
+ `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the ${source} ${JSON.stringify(target)}. Refusing to delete.`
33609
33706
  );
33610
33707
  }
33611
33708
  return apiDelete(`/tailnet/${encPath(target)}`);
@@ -34011,7 +34108,7 @@ async function tailnetStatusResource(uri) {
34011
34108
  }
34012
34109
  async function tailnetDevicesResource(uri) {
34013
34110
  const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
34014
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
34111
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error || `HTTP ${res.status}` }, null, 2);
34015
34112
  return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
34016
34113
  }
34017
34114
  async function tailnetAclResource(uri) {
@@ -34019,7 +34116,7 @@ async function tailnetAclResource(uri) {
34019
34116
  if (res.ok) {
34020
34117
  return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
34021
34118
  }
34022
- const lines = `Error: ${res.error ?? `HTTP ${res.status}`}`.split("\n");
34119
+ const lines = `Error: ${res.error || `HTTP ${res.status}`}`.split("\n");
34023
34120
  const text = `${lines.map((l) => `// ${l}`).join("\n")}
34024
34121
  `;
34025
34122
  return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
@@ -34038,16 +34135,16 @@ async function tailnetDnsResource(uri) {
34038
34135
  preferences: preferences.ok ? preferences.data : null
34039
34136
  };
34040
34137
  const errors = {};
34041
- if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
34042
- if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
34043
- if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
34044
- if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
34138
+ if (!nameservers.ok) errors.nameservers = nameservers.error || `HTTP ${nameservers.status}`;
34139
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error || `HTTP ${searchPaths.status}`;
34140
+ if (!splitDns.ok) errors.splitDns = splitDns.error || `HTTP ${splitDns.status}`;
34141
+ if (!preferences.ok) errors.preferences = preferences.error || `HTTP ${preferences.status}`;
34045
34142
  if (Object.keys(errors).length > 0) data.errors = errors;
34046
34143
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
34047
34144
  }
34048
34145
 
34049
34146
  // src/index.ts
34050
- var version2 = true ? "0.17.0" : resolveVersionFallback();
34147
+ var version2 = true ? "0.18.0" : resolveVersionFallback();
34051
34148
  var subcommand = process.argv[2];
34052
34149
  var cliSubcommandHandled = false;
34053
34150
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -34104,7 +34201,7 @@ if (!cliSubcommandHandled) {
34104
34201
  }
34105
34202
  if (unknownProfile) {
34106
34203
  console.error(
34107
- `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
34204
+ `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
34108
34205
  );
34109
34206
  }
34110
34207
  const server = new McpServer({
@@ -34165,7 +34262,7 @@ if (!cliSubcommandHandled) {
34165
34262
  const coreCount = profileCount(PROFILES.core);
34166
34263
  const minimalCount = profileCount(PROFILES.minimal);
34167
34264
  console.error(
34168
- `@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34265
+ `@yawlabs/tailscale-mcp: tip -- set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34169
34266
  );
34170
34267
  }
34171
34268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",
@@ -33,10 +33,11 @@
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
- "lint": "biome check src/",
39
- "lint:fix": "biome check --write src/",
38
+ "test:coverage": "npm run build && node --test-timeout=300000 --experimental-test-coverage --test-coverage-exclude=\"**/*.test.js\" --test-coverage-exclude=\"**/dist/index.js\" --test \"dist/**/*.test.js\"",
39
+ "lint": "biome check src/ bin/ scripts/ build.mjs",
40
+ "lint:fix": "biome check --write src/ bin/ scripts/ build.mjs",
40
41
  "check:oam": "oam check src/index.ts",
41
42
  "build:binary": "node scripts/build-binary.mjs",
42
43
  "build:binary:oam": "node scripts/build-binary-oam.mjs",
@@ -59,7 +60,7 @@
59
60
  "zod": "^4.3.6"
60
61
  },
61
62
  "engines": {
62
- "node": ">=20"
63
+ "node": ">=20.11.0"
63
64
  },
64
65
  "devEngines": {
65
66
  "runtime": {