@yawlabs/tailscale-mcp 0.22.0 → 0.23.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
@@ -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
 
@@ -401,7 +401,7 @@ function sandboxFlags() {
401
401
  // TEMP), TMP its POSIX-style twin, APPDATA and LOCALAPPDATA where Windows
402
402
  // programs keep per-user state, and HOME the POSIX home directory. Granting a
403
403
  // 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
404
+ // server's own: local-cli-runner.ts reads it to recognise WSL when the CLI is
405
405
  // missing, and its fallback, /proc/version, is a file read the sandbox denies.
406
406
  const env = [
407
407
  "APPDATA",
package/dist/index.js CHANGED
@@ -33263,7 +33263,7 @@ var userTools = [
33263
33263
  openWorldHint: true
33264
33264
  },
33265
33265
  inputSchema: external_exports.object({
33266
- userId: external_exports.string().describe("The user ID")
33266
+ userId: external_exports.string().trim().min(1).describe("The user ID")
33267
33267
  }),
33268
33268
  handler: async (input) => {
33269
33269
  return apiGet(`/users/${encPath(input.userId)}`);
@@ -33280,7 +33280,7 @@ var userTools = [
33280
33280
  openWorldHint: true
33281
33281
  },
33282
33282
  inputSchema: external_exports.object({
33283
- userId: external_exports.string().describe("The user ID to approve")
33283
+ userId: external_exports.string().trim().min(1).describe("The user ID to approve")
33284
33284
  }),
33285
33285
  handler: async (input) => {
33286
33286
  return apiPost(`/users/${encPath(input.userId)}/approve`);
@@ -33297,7 +33297,10 @@ var userTools = [
33297
33297
  openWorldHint: true
33298
33298
  },
33299
33299
  inputSchema: external_exports.object({
33300
- userId: external_exports.string().describe("The user ID to suspend")
33300
+ // `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
33301
+ // to "%20" and suspend returns a 404 that reads like the user is already
33302
+ // gone when it is not (rationale: tailnets.ts delete_tailnet).
33303
+ userId: external_exports.string().trim().min(1).describe("The user ID to suspend")
33301
33304
  }),
33302
33305
  handler: async (input) => {
33303
33306
  return apiPost(`/users/${encPath(input.userId)}/suspend`);
@@ -33314,7 +33317,7 @@ var userTools = [
33314
33317
  openWorldHint: true
33315
33318
  },
33316
33319
  inputSchema: external_exports.object({
33317
- userId: external_exports.string().describe("The user ID to restore")
33320
+ userId: external_exports.string().trim().min(1).describe("The user ID to restore")
33318
33321
  }),
33319
33322
  handler: async (input) => {
33320
33323
  return apiPost(`/users/${encPath(input.userId)}/restore`);
@@ -33331,7 +33334,7 @@ var userTools = [
33331
33334
  openWorldHint: true
33332
33335
  },
33333
33336
  inputSchema: external_exports.object({
33334
- userId: external_exports.string().describe("The user ID"),
33337
+ userId: external_exports.string().trim().min(1).describe("The user ID"),
33335
33338
  role: external_exports.enum(USER_ROLES).describe("The new role to assign")
33336
33339
  }),
33337
33340
  handler: async (input) => {
@@ -33374,7 +33377,7 @@ var inviteTools = [
33374
33377
  openWorldHint: true
33375
33378
  },
33376
33379
  inputSchema: external_exports.object({
33377
- deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33380
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33378
33381
  }),
33379
33382
  handler: async (input) => {
33380
33383
  return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
@@ -33391,7 +33394,7 @@ var inviteTools = [
33391
33394
  openWorldHint: true
33392
33395
  },
33393
33396
  inputSchema: external_exports.object({
33394
- deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33397
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33395
33398
  multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
33396
33399
  allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
33397
33400
  email: external_exports.email().optional().describe("Email address to send the invite to")
@@ -33418,7 +33421,7 @@ var inviteTools = [
33418
33421
  openWorldHint: true
33419
33422
  },
33420
33423
  inputSchema: external_exports.object({
33421
- inviteId: external_exports.string().describe("The device invite ID")
33424
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID")
33422
33425
  }),
33423
33426
  handler: async (input) => {
33424
33427
  return apiGet(`/device-invites/${encPath(input.inviteId)}`);
@@ -33435,7 +33438,10 @@ var inviteTools = [
33435
33438
  openWorldHint: true
33436
33439
  },
33437
33440
  inputSchema: external_exports.object({
33438
- inviteId: external_exports.string().describe("The device invite ID to delete")
33441
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to delete")
33442
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33443
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33444
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33439
33445
  }),
33440
33446
  handler: async (input) => {
33441
33447
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
@@ -33509,7 +33515,7 @@ var inviteTools = [
33509
33515
  openWorldHint: true
33510
33516
  },
33511
33517
  inputSchema: external_exports.object({
33512
- inviteId: external_exports.string().describe("The user invite ID")
33518
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID")
33513
33519
  }),
33514
33520
  handler: async (input) => {
33515
33521
  return apiGet(`/user-invites/${encPath(input.inviteId)}`);
@@ -33526,7 +33532,10 @@ var inviteTools = [
33526
33532
  openWorldHint: true
33527
33533
  },
33528
33534
  inputSchema: external_exports.object({
33529
- inviteId: external_exports.string().describe("The user invite ID to delete")
33535
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to delete")
33536
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33537
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33538
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33530
33539
  }),
33531
33540
  handler: async (input) => {
33532
33541
  return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
@@ -33544,7 +33553,7 @@ var inviteTools = [
33544
33553
  openWorldHint: true
33545
33554
  },
33546
33555
  inputSchema: external_exports.object({
33547
- inviteId: external_exports.string().describe("The device invite ID to resend")
33556
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to resend")
33548
33557
  }),
33549
33558
  handler: async (input) => {
33550
33559
  return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
@@ -33562,7 +33571,7 @@ var inviteTools = [
33562
33571
  openWorldHint: true
33563
33572
  },
33564
33573
  inputSchema: external_exports.object({
33565
- inviteId: external_exports.string().describe("The user invite ID to resend")
33574
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to resend")
33566
33575
  }),
33567
33576
  handler: async (input) => {
33568
33577
  return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
@@ -33863,7 +33872,7 @@ var keyTools = [
33863
33872
  // src/tools/local-cli.ts
33864
33873
  import * as net2 from "node:net";
33865
33874
 
33866
- // src/local-cli.ts
33875
+ // src/local-cli-runner.ts
33867
33876
  import { execFile as execFileCb } from "node:child_process";
33868
33877
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
33869
33878
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -33959,6 +33968,7 @@ async function runTailscaleCli(args, options = {}) {
33959
33968
  if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
33960
33969
  resolve({
33961
33970
  ok: false,
33971
+ kind: "output-limit",
33962
33972
  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
33973
  });
33964
33974
  return;
@@ -33966,6 +33976,7 @@ async function runTailscaleCli(args, options = {}) {
33966
33976
  if (errno.killed) {
33967
33977
  resolve({
33968
33978
  ok: false,
33979
+ kind: "timeout",
33969
33980
  error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
33970
33981
  });
33971
33982
  return;
@@ -34029,10 +34040,10 @@ var localCliTools = [
34029
34040
  if (input.peers === false) args.push("--peers=false");
34030
34041
  if (input.activeOnly) args.push("--active");
34031
34042
  const result = await runTailscaleCli(args, { parseJson: true });
34032
- if (!result.ok && result.error?.includes("output limit")) {
34043
+ if (!result.ok && result.kind === "output-limit") {
34033
34044
  return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
34034
34045
  }
34035
- if (!result.ok && result.error?.includes("timed out after")) {
34046
+ if (!result.ok && result.kind === "timeout") {
34036
34047
  return {
34037
34048
  ...result,
34038
34049
  error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
@@ -34416,7 +34427,7 @@ var postureTools = [
34416
34427
  openWorldHint: true
34417
34428
  },
34418
34429
  inputSchema: external_exports.object({
34419
- integrationId: external_exports.string().describe("The posture integration ID")
34430
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID")
34420
34431
  }),
34421
34432
  handler: async (input) => {
34422
34433
  return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34471,7 +34482,7 @@ var postureTools = [
34471
34482
  openWorldHint: true
34472
34483
  },
34473
34484
  inputSchema: external_exports.object({
34474
- integrationId: external_exports.string().describe("The posture integration ID to update"),
34485
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to update"),
34475
34486
  clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
34476
34487
  clientSecret: external_exports.string().optional().describe(
34477
34488
  "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 +34513,10 @@ var postureTools = [
34502
34513
  openWorldHint: true
34503
34514
  },
34504
34515
  inputSchema: external_exports.object({
34505
- integrationId: external_exports.string().describe("The posture integration ID to delete")
34516
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to delete")
34517
+ // `.trim().min(1)`, not a bare string: a whitespace-only integrationId
34518
+ // encodes to "%20" and this irreversible delete returns a 404 that reads
34519
+ // like the integration is already gone (rationale: tailnets.ts delete_tailnet).
34506
34520
  }),
34507
34521
  handler: async (input) => {
34508
34522
  return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34694,7 +34708,13 @@ var statusTools = [
34694
34708
  handler: async () => {
34695
34709
  const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
34696
34710
  if (!devicesRes.ok && !settingsRes.ok) {
34697
- return devicesRes;
34711
+ if (settingsRes.error === devicesRes.error && settingsRes.status === devicesRes.status) {
34712
+ return devicesRes;
34713
+ }
34714
+ return {
34715
+ ...devicesRes,
34716
+ error: `${devicesRes.error || `HTTP ${devicesRes.status}`} (the settings fetch also failed: ${settingsRes.error || `HTTP ${settingsRes.status}`})`
34717
+ };
34698
34718
  }
34699
34719
  const data = composeTailnetStatusData(devicesRes, settingsRes, {
34700
34720
  connected: true,
@@ -35482,7 +35502,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
35482
35502
  );
35483
35503
  process.exit(1);
35484
35504
  }
35485
- var version2 = true ? "0.22.0" : resolveVersionFallback();
35505
+ var version2 = true ? "0.23.0" : resolveVersionFallback();
35486
35506
  var subcommand = process.argv[2];
35487
35507
  var USAGE = `Usage: tailscale-mcp [command]
35488
35508
 
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.0",
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",