@yawlabs/tailscale-mcp 0.22.0 → 0.23.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
@@ -152,7 +152,7 @@ Profile names are case-insensitive and ignore surrounding whitespace (`Core` sel
152
152
 
153
153
  Comma-separated group names. Overrides `TAILSCALE_PROFILE` when both are set — use this when the presets aren't quite right.
154
154
 
155
- Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`. The `local-cli` group is also available, but only when `TAILSCALE_LOCAL_CLI=1` is set — see [Local CLI integration](#local-cli-integration-opt-in).
155
+ Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `org-tailnets`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`. The `local-cli` group is also available, but only when `TAILSCALE_LOCAL_CLI=1` is set — see [Local CLI integration](#local-cli-integration-opt-in).
156
156
 
157
157
  ### Option 3: `TAILSCALE_READONLY` (drop mutations)
158
158
 
@@ -172,13 +172,13 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
172
172
  The server logs the active filter to stderr on startup:
173
173
 
174
174
  ```
175
- @yawlabs/tailscale-mcp v0.12.0 ready (20 tools, profile=minimal, readonly)
175
+ @yawlabs/tailscale-mcp v0.22.0 ready (20 tools, profile=minimal, readonly)
176
176
  ```
177
177
 
178
178
  When both `TAILSCALE_PROFILE` and `TAILSCALE_TOOLS` are set, `TAILSCALE_TOOLS` wins. The banner marks the profile as overridden so the precedence is obvious at a glance — no need to guess which filter actually applied:
179
179
 
180
180
  ```
181
- @yawlabs/tailscale-mcp v0.12.0 ready (22 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)
181
+ @yawlabs/tailscale-mcp v0.22.0 ready (22 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)
182
182
  ```
183
183
 
184
184
  The "(overridden)" marker only fires for substantive profiles (`minimal` / `core`); `profile=full` is a no-op preset, so it's shown without the marker when `TAILSCALE_TOOLS` is also set.
@@ -286,7 +286,7 @@ Names are case-sensitive, matching `TAILSCALE_TOOLS`.
286
286
  The startup banner shows what applied:
287
287
 
288
288
  ```
289
- @yawlabs/tailscale-mcp v0.19.0 ready (59 tools, write=devices,keys)
289
+ @yawlabs/tailscale-mcp v0.22.0 ready (59 tools, write=devices,keys)
290
290
  ```
291
291
 
292
292
  ## Requiring approval on irreversible tools
@@ -417,7 +417,7 @@ Requirements: the `tailscale` binary has to be findable. It's looked up on `PATH
417
417
 
418
418
  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.
419
419
 
420
- When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (103 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 97.
420
+ When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.22.0 ready (103 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 97.
421
421
 
422
422
  ## Resources (4)
423
423
 
@@ -732,17 +732,17 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
732
732
 
733
733
  ## Running on oam.js (optional)
734
734
 
735
- [oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.18.0**. Verified against the published oam 0.18.0: 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.
735
+ [oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.19.1**. Verified against the published oam 0.19.1: 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.
736
736
 
737
- **oam 0.18.0 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.
737
+ **oam 0.19.1 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.
738
738
 
739
739
  How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
740
740
 
741
- - **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.18.0 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.18.0 or newer, else asks every oam binary it can find — `OAM_INSTALL_DIR` when that is set, then `%LOCALAPPDATA%\oam\bin` and `~/.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.18.0 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.
741
+ - **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.19.1 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.19.1 or newer, else asks every oam binary it can find — `OAM_INSTALL_DIR` when that is set, then `%LOCALAPPDATA%\oam\bin` and `~/.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.19.1 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.
742
742
  - **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node. The error names the remedy for what it actually found: `oam self-update` for an oam older than the floor, a check of the binary for one that would not run, and installing oam only when none was found (and on linux-arm64, which oam publishes no build for, not even then).
743
743
  - **`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`.
744
744
 
745
- 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.
745
+ The value is case-insensitive and surrounding whitespace is ignored; 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.
746
746
 
747
747
  ### Sandboxing (opt-in)
748
748
 
@@ -767,14 +767,14 @@ The sandbox applies to the CLI subcommands too, and with the filesystem denied `
767
767
  }
768
768
  ```
769
769
 
770
- **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.18.0 floor and have not been repeated since, so do not read them as a current ranking.
770
+ **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 were taken with an oam older than 0.18.0 and have not been repeated since, so do not read them as a current ranking.
771
771
 
772
772
  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.
773
773
 
774
774
  Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
775
775
 
776
776
  - **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 4015ms against 7680ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default.
777
- - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of Node SEA. Measured ~57.7 MB against ~73.6 MB for the Node SEA carrier *before* its blob is injected. Writes `bin/<platform>-<arch>/` (or `bin/$TAILSCALE_MCP_BINARY_TARGET/` for a cross-build), while `npm run build:binary` adds a `-glibc` / `-musl` suffix on Linux; `scripts/stage-release-asset.mjs` looks in both and stages the newer build — run one or the other per target. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it. A cross-build (`TAILSCALE_MCP_BINARY_TARGET=<platform>-<arch>`) downloads the target's oam release binary as its carrier and checks it against the release's signed `RELEASE-MANIFEST`, verified with `ssh-keygen -Y verify` (OpenSSH 8.1+) against oam's release keys in `scripts/oam-release-keys/`; it aborts when the signature, the manifest or the checksum does not check out. An `OAM_VERSION` older than v0.18.0, released before oam signed its releases, is checked against its `SHA256SUMS` only once that file matches the digest pinned for the tag in `scripts/oam-release-keys/presigning-sums`.
777
+ - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` (~57.7 MB measured). Writes `bin/<platform>-<arch>/` (or `bin/$TAILSCALE_MCP_BINARY_TARGET/` for a cross-build), which `scripts/stage-release-asset.mjs` then stages under the public asset name. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it. A cross-build (`TAILSCALE_MCP_BINARY_TARGET=<platform>-<arch>`) downloads the target's oam release binary as its carrier and checks it against the release's signed `RELEASE-MANIFEST`, verified with `ssh-keygen -Y verify` (OpenSSH 8.1+) against oam's release keys in `scripts/oam-release-keys/`; it aborts when the signature, the manifest or the checksum does not check out. An `OAM_VERSION` below v0.18.0, released before oam signed its releases, is checked against its `SHA256SUMS` only once that file matches the digest pinned for the tag in `scripts/oam-release-keys/presigning-sums`.
778
778
 
779
779
  The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal. Note that any `oam` invocation writes a bytecode cache to `oam/` in the working directory — already in `.gitignore`.
780
780
 
@@ -105,7 +105,7 @@
105
105
  * shipped bundle; keep it in step.
106
106
  *
107
107
  * MINIMUM OAM VERSION
108
- * The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
108
+ * The latest oam release, 0.19.1 -- bump OAM_MIN when oam ships a newer one.
109
109
  * Only the current oam is used and verified; an older one is passed over for a
110
110
  * newer oam, or for Node. The floor is not cosmetic: before 0.9.0
111
111
  * `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
@@ -138,7 +138,7 @@ import { delimiter, join } from "node:path";
138
138
  import { fileURLToPath } from "node:url";
139
139
 
140
140
  /** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
141
- const OAM_MIN = [0, 18, 0];
141
+ const OAM_MIN = [0, 19, 1];
142
142
 
143
143
  /**
144
144
  * The oldest Node this package supports, matching package.json `engines.node`.
@@ -353,6 +353,15 @@ function fallbackInProcess(hostOam) {
353
353
  return hostOam === undefined || atLeast(parseVersion(hostOam), OAM_MIN);
354
354
  }
355
355
 
356
+ /**
357
+ * True when an on/off opt-in is set: "1" or "true", any case, surrounding
358
+ * whitespace ignored -- cmd.exe's `set FOO=1 && ...` stores "1 ". The same rule
359
+ * as envFlagOn in src/env-flag.ts; this file is plain .mjs and cannot import it.
360
+ */
361
+ function envFlagOn(value) {
362
+ return /^(1|true)$/i.test((value ?? "").trim());
363
+ }
364
+
356
365
  /**
357
366
  * The `--permission` grant list, or [] when the sandbox is not requested.
358
367
  *
@@ -360,7 +369,7 @@ function fallbackInProcess(hostOam) {
360
369
  * not after it. `oam run --permission file.js` is rejected outright, which is a
361
370
  * good failure but only because it is loud -- ordering here is load-bearing.
362
371
  *
363
- * Net grants, since oam 0.18.0 (the floor): an entry without a port admits that
372
+ * Net grants, as of oam 0.18.0: an entry without a port admits that
364
373
  * host on every port, and a `host:port` entry admits that port alone -- for
365
374
  * fetch and https.request exactly as for sockets. Up to 0.17.1 a port-scoped
366
375
  * entry admitted no HTTP request at all, which is why this used to be a bare
@@ -370,7 +379,7 @@ function fallbackInProcess(hostOam) {
370
379
  * it produces silent misbehaviour, not a clear denial.
371
380
  */
372
381
  function sandboxFlags() {
373
- if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
382
+ if (!envFlagOn(process.env.TAILSCALE_MCP_SANDBOX)) return [];
374
383
 
375
384
  // ONE host, deliberately. Every outbound request the bundle makes targets
376
385
  // api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
@@ -401,7 +410,7 @@ function sandboxFlags() {
401
410
  // TEMP), TMP its POSIX-style twin, APPDATA and LOCALAPPDATA where Windows
402
411
  // programs keep per-user state, and HOME the POSIX home directory. Granting a
403
412
  // name that is not set is harmless: it stays absent. WSL_DISTRO_NAME is the
404
- // server's own: local-cli.ts reads it to recognise WSL when the CLI is
413
+ // server's own: local-cli-runner.ts reads it to recognise WSL when the CLI is
405
414
  // missing, and its fallback, /proc/version, is a file read the sandbox denies.
406
415
  const env = [
407
416
  "APPDATA",
@@ -858,11 +867,12 @@ if (nodeFloorMessage) {
858
867
  // which is the opposite of what was asked for. Same handling as index.ts gives
859
868
  // an unknown subcommand: auto is still the right landing place, it just stops
860
869
  // being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
861
- // wrapper script is how it usually gets there.
870
+ // wrapper script is how it usually gets there, and surrounding whitespace is
871
+ // dropped first: cmd.exe's `set TAILSCALE_MCP_RUNTIME=node && ...` stores "node ".
862
872
  const RUNTIMES = ["auto", "node", "oam"];
863
873
  const requested = process.env.TAILSCALE_MCP_RUNTIME;
864
- const mode = (requested ?? "auto").toLowerCase();
865
- if (requested && !RUNTIMES.includes(mode)) {
874
+ const mode = (requested?.trim() || "auto").toLowerCase();
875
+ if (requested?.trim() && !RUNTIMES.includes(mode)) {
866
876
  // Echo what was SET, not the lowercased form, so the typo is recognisable in
867
877
  // the host's log next to the config line that produced it.
868
878
  errSync(
@@ -876,6 +886,14 @@ const hostOam = process.versions.oam;
876
886
  // "would the spawn carry --permission" cannot drift from what the spawn below
877
887
  // actually passes.
878
888
  const sandbox = sandboxFlags();
889
+ // A security opt-in that did not take must not be silent: "yes" or "on" reads as
890
+ // a request for the sandbox, and serving unsandboxed without a word would hide it.
891
+ const sandboxRaw = (process.env.TAILSCALE_MCP_SANDBOX ?? "").trim();
892
+ if (sandbox.length === 0 && sandboxRaw !== "") {
893
+ errSync(
894
+ `tailscale-mcp: TAILSCALE_MCP_SANDBOX="${process.env.TAILSCALE_MCP_SANDBOX}" is not 1 or true -- the sandbox is NOT enabled.\n`,
895
+ );
896
+ }
879
897
  const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
880
898
 
881
899
  if (plan === "in-process") {
package/dist/index.js CHANGED
@@ -31350,6 +31350,15 @@ var StdioServerTransport = class {
31350
31350
  }
31351
31351
  };
31352
31352
 
31353
+ // src/env-flag.ts
31354
+ function envFlagOn(value) {
31355
+ return /^(1|true)$/i.test((value ?? "").trim());
31356
+ }
31357
+ function securityOptInWarning(name, value) {
31358
+ if (envFlagOn(value) || (value ?? "").trim() === "") return null;
31359
+ return `${name}=${JSON.stringify(value)} is not 1 or true, so it is NOT enabled.`;
31360
+ }
31361
+
31353
31362
  // src/api.ts
31354
31363
  var BASE_URL = "https://api.tailscale.com/api/v2";
31355
31364
  var REQUEST_TIMEOUT_MS = 3e4;
@@ -31646,7 +31655,7 @@ async function withConcurrencyLimit(fn) {
31646
31655
  }
31647
31656
  }
31648
31657
  function debugLog(...parts) {
31649
- if (process.env.TAILSCALE_DEBUG === "1" || process.env.TAILSCALE_DEBUG === "true") {
31658
+ if (envFlagOn(process.env.TAILSCALE_DEBUG)) {
31650
31659
  console.error("[tailscale-mcp]", ...parts);
31651
31660
  }
31652
31661
  }
@@ -32026,7 +32035,7 @@ function parseGroupList(value) {
32026
32035
  return parsed.length > 0 ? parsed : null;
32027
32036
  }
32028
32037
  function parseReadonlyFlag(value) {
32029
- return value === "1" || value === "true";
32038
+ return envFlagOn(value);
32030
32039
  }
32031
32040
  function filterTools(groups, options) {
32032
32041
  const validNames = new Set(Object.keys(groups));
@@ -33263,7 +33272,7 @@ var userTools = [
33263
33272
  openWorldHint: true
33264
33273
  },
33265
33274
  inputSchema: external_exports.object({
33266
- userId: external_exports.string().describe("The user ID")
33275
+ userId: external_exports.string().trim().min(1).describe("The user ID")
33267
33276
  }),
33268
33277
  handler: async (input) => {
33269
33278
  return apiGet(`/users/${encPath(input.userId)}`);
@@ -33280,7 +33289,7 @@ var userTools = [
33280
33289
  openWorldHint: true
33281
33290
  },
33282
33291
  inputSchema: external_exports.object({
33283
- userId: external_exports.string().describe("The user ID to approve")
33292
+ userId: external_exports.string().trim().min(1).describe("The user ID to approve")
33284
33293
  }),
33285
33294
  handler: async (input) => {
33286
33295
  return apiPost(`/users/${encPath(input.userId)}/approve`);
@@ -33297,7 +33306,10 @@ var userTools = [
33297
33306
  openWorldHint: true
33298
33307
  },
33299
33308
  inputSchema: external_exports.object({
33300
- userId: external_exports.string().describe("The user ID to suspend")
33309
+ // `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
33310
+ // to "%20" and suspend returns a 404 that reads like the user is already
33311
+ // gone when it is not (rationale: tailnets.ts delete_tailnet).
33312
+ userId: external_exports.string().trim().min(1).describe("The user ID to suspend")
33301
33313
  }),
33302
33314
  handler: async (input) => {
33303
33315
  return apiPost(`/users/${encPath(input.userId)}/suspend`);
@@ -33314,7 +33326,7 @@ var userTools = [
33314
33326
  openWorldHint: true
33315
33327
  },
33316
33328
  inputSchema: external_exports.object({
33317
- userId: external_exports.string().describe("The user ID to restore")
33329
+ userId: external_exports.string().trim().min(1).describe("The user ID to restore")
33318
33330
  }),
33319
33331
  handler: async (input) => {
33320
33332
  return apiPost(`/users/${encPath(input.userId)}/restore`);
@@ -33331,7 +33343,7 @@ var userTools = [
33331
33343
  openWorldHint: true
33332
33344
  },
33333
33345
  inputSchema: external_exports.object({
33334
- userId: external_exports.string().describe("The user ID"),
33346
+ userId: external_exports.string().trim().min(1).describe("The user ID"),
33335
33347
  role: external_exports.enum(USER_ROLES).describe("The new role to assign")
33336
33348
  }),
33337
33349
  handler: async (input) => {
@@ -33374,7 +33386,7 @@ var inviteTools = [
33374
33386
  openWorldHint: true
33375
33387
  },
33376
33388
  inputSchema: external_exports.object({
33377
- deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33389
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33378
33390
  }),
33379
33391
  handler: async (input) => {
33380
33392
  return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
@@ -33391,7 +33403,7 @@ var inviteTools = [
33391
33403
  openWorldHint: true
33392
33404
  },
33393
33405
  inputSchema: external_exports.object({
33394
- deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33406
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33395
33407
  multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
33396
33408
  allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
33397
33409
  email: external_exports.email().optional().describe("Email address to send the invite to")
@@ -33418,7 +33430,7 @@ var inviteTools = [
33418
33430
  openWorldHint: true
33419
33431
  },
33420
33432
  inputSchema: external_exports.object({
33421
- inviteId: external_exports.string().describe("The device invite ID")
33433
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID")
33422
33434
  }),
33423
33435
  handler: async (input) => {
33424
33436
  return apiGet(`/device-invites/${encPath(input.inviteId)}`);
@@ -33435,7 +33447,10 @@ var inviteTools = [
33435
33447
  openWorldHint: true
33436
33448
  },
33437
33449
  inputSchema: external_exports.object({
33438
- inviteId: external_exports.string().describe("The device invite ID to delete")
33450
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to delete")
33451
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33452
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33453
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33439
33454
  }),
33440
33455
  handler: async (input) => {
33441
33456
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
@@ -33509,7 +33524,7 @@ var inviteTools = [
33509
33524
  openWorldHint: true
33510
33525
  },
33511
33526
  inputSchema: external_exports.object({
33512
- inviteId: external_exports.string().describe("The user invite ID")
33527
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID")
33513
33528
  }),
33514
33529
  handler: async (input) => {
33515
33530
  return apiGet(`/user-invites/${encPath(input.inviteId)}`);
@@ -33526,7 +33541,10 @@ var inviteTools = [
33526
33541
  openWorldHint: true
33527
33542
  },
33528
33543
  inputSchema: external_exports.object({
33529
- inviteId: external_exports.string().describe("The user invite ID to delete")
33544
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to delete")
33545
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33546
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33547
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33530
33548
  }),
33531
33549
  handler: async (input) => {
33532
33550
  return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
@@ -33544,7 +33562,7 @@ var inviteTools = [
33544
33562
  openWorldHint: true
33545
33563
  },
33546
33564
  inputSchema: external_exports.object({
33547
- inviteId: external_exports.string().describe("The device invite ID to resend")
33565
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to resend")
33548
33566
  }),
33549
33567
  handler: async (input) => {
33550
33568
  return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
@@ -33562,7 +33580,7 @@ var inviteTools = [
33562
33580
  openWorldHint: true
33563
33581
  },
33564
33582
  inputSchema: external_exports.object({
33565
- inviteId: external_exports.string().describe("The user invite ID to resend")
33583
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to resend")
33566
33584
  }),
33567
33585
  handler: async (input) => {
33568
33586
  return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
@@ -33863,7 +33881,7 @@ var keyTools = [
33863
33881
  // src/tools/local-cli.ts
33864
33882
  import * as net2 from "node:net";
33865
33883
 
33866
- // src/local-cli.ts
33884
+ // src/local-cli-runner.ts
33867
33885
  import { execFile as execFileCb } from "node:child_process";
33868
33886
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
33869
33887
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -33939,7 +33957,8 @@ async function runTailscaleCli(args, options = {}) {
33939
33957
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33940
33958
  await acquireCliSlot();
33941
33959
  return new Promise((resolve) => {
33942
- execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
33960
+ const execOptions = { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES, windowsHide: true };
33961
+ execFileImpl(binary, args, execOptions, (err, stdout, stderr) => {
33943
33962
  releaseCliSlot();
33944
33963
  const stdoutStr = stdout == null ? "" : String(stdout);
33945
33964
  const stderrStr = stderr == null ? "" : String(stderr);
@@ -33959,6 +33978,7 @@ async function runTailscaleCli(args, options = {}) {
33959
33978
  if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
33960
33979
  resolve({
33961
33980
  ok: false,
33981
+ kind: "output-limit",
33962
33982
  error: `'${binary} ${args.join(" ")}' exceeded the ${MAX_BUFFER_BYTES / 1024 / 1024} MB output limit and was aborted -- no output was captured. This usually means a very large tailnet; narrow the query if the command supports it.`
33963
33983
  });
33964
33984
  return;
@@ -33966,6 +33986,7 @@ async function runTailscaleCli(args, options = {}) {
33966
33986
  if (errno.killed) {
33967
33987
  resolve({
33968
33988
  ok: false,
33989
+ kind: "timeout",
33969
33990
  error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
33970
33991
  });
33971
33992
  return;
@@ -34029,10 +34050,10 @@ var localCliTools = [
34029
34050
  if (input.peers === false) args.push("--peers=false");
34030
34051
  if (input.activeOnly) args.push("--active");
34031
34052
  const result = await runTailscaleCli(args, { parseJson: true });
34032
- if (!result.ok && result.error?.includes("output limit")) {
34053
+ if (!result.ok && result.kind === "output-limit") {
34033
34054
  return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
34034
34055
  }
34035
- if (!result.ok && result.error?.includes("timed out after")) {
34056
+ if (!result.ok && result.kind === "timeout") {
34036
34057
  return {
34037
34058
  ...result,
34038
34059
  error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
@@ -34416,7 +34437,7 @@ var postureTools = [
34416
34437
  openWorldHint: true
34417
34438
  },
34418
34439
  inputSchema: external_exports.object({
34419
- integrationId: external_exports.string().describe("The posture integration ID")
34440
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID")
34420
34441
  }),
34421
34442
  handler: async (input) => {
34422
34443
  return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34471,7 +34492,7 @@ var postureTools = [
34471
34492
  openWorldHint: true
34472
34493
  },
34473
34494
  inputSchema: external_exports.object({
34474
- integrationId: external_exports.string().describe("The posture integration ID to update"),
34495
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to update"),
34475
34496
  clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
34476
34497
  clientSecret: external_exports.string().optional().describe(
34477
34498
  "Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
@@ -34502,7 +34523,10 @@ var postureTools = [
34502
34523
  openWorldHint: true
34503
34524
  },
34504
34525
  inputSchema: external_exports.object({
34505
- integrationId: external_exports.string().describe("The posture integration ID to delete")
34526
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to delete")
34527
+ // `.trim().min(1)`, not a bare string: a whitespace-only integrationId
34528
+ // encodes to "%20" and this irreversible delete returns a 404 that reads
34529
+ // like the integration is already gone (rationale: tailnets.ts delete_tailnet).
34506
34530
  }),
34507
34531
  handler: async (input) => {
34508
34532
  return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34694,7 +34718,13 @@ var statusTools = [
34694
34718
  handler: async () => {
34695
34719
  const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
34696
34720
  if (!devicesRes.ok && !settingsRes.ok) {
34697
- return devicesRes;
34721
+ if (settingsRes.error === devicesRes.error && settingsRes.status === devicesRes.status) {
34722
+ return devicesRes;
34723
+ }
34724
+ return {
34725
+ ...devicesRes,
34726
+ error: `${devicesRes.error || `HTTP ${devicesRes.status}`} (the settings fetch also failed: ${settingsRes.error || `HTTP ${settingsRes.status}`})`
34727
+ };
34698
34728
  }
34699
34729
  const data = composeTailnetStatusData(devicesRes, settingsRes, {
34700
34730
  connected: true,
@@ -35141,10 +35171,10 @@ var webhookTools = [
35141
35171
 
35142
35172
  // src/server-wiring.ts
35143
35173
  function isLocalCliEnabled(env) {
35144
- return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
35174
+ return envFlagOn(env.TAILSCALE_LOCAL_CLI);
35145
35175
  }
35146
35176
  function isRequireApprovalEnabled(env) {
35147
- return env.TAILSCALE_REQUIRE_APPROVAL === "1" || env.TAILSCALE_REQUIRE_APPROVAL === "true";
35177
+ return envFlagOn(env.TAILSCALE_REQUIRE_APPROVAL);
35148
35178
  }
35149
35179
  var FORCED_APPROVAL_TOOLS = [
35150
35180
  "tailscale_delete_device",
@@ -35482,7 +35512,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
35482
35512
  );
35483
35513
  process.exit(1);
35484
35514
  }
35485
- var version2 = true ? "0.22.0" : resolveVersionFallback();
35515
+ var version2 = true ? "0.23.1" : resolveVersionFallback();
35486
35516
  var subcommand = process.argv[2];
35487
35517
  var USAGE = `Usage: tailscale-mcp [command]
35488
35518
 
@@ -35555,6 +35585,10 @@ if (!cliSubcommandHandled) {
35555
35585
  if (tailnetMismatch) {
35556
35586
  console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
35557
35587
  }
35588
+ const readonlyWarning = securityOptInWarning("TAILSCALE_READONLY", process.env.TAILSCALE_READONLY);
35589
+ if (readonlyWarning) {
35590
+ console.error(`@yawlabs/tailscale-mcp: ${readonlyWarning} Write tools are served.`);
35591
+ }
35558
35592
  if (unknownGroups.length > 0) {
35559
35593
  const validNames = Object.keys(toolGroups);
35560
35594
  const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
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",
@@ -58,7 +58,6 @@
58
58
  "lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/ build.mjs",
59
59
  "check:oam": "oam check src/index.ts",
60
60
  "check:oam-floor": "node scripts/check-oam-floor.mjs",
61
- "build:binary": "node scripts/build-binary.mjs",
62
61
  "build:binary:oam": "node scripts/build-binary-oam.mjs",
63
62
  "prepublishOnly": "npm run clean && npm run build"
64
63
  },
@@ -74,7 +73,6 @@
74
73
  "@modelcontextprotocol/sdk": "^1.32.1",
75
74
  "@types/node": "^26.0.0",
76
75
  "esbuild": "^0.28.1",
77
- "postject": "^1.0.0-alpha.6",
78
76
  "typescript": "^7.0.2",
79
77
  "zod": "^4.3.6"
80
78
  },