@yawlabs/tailscale-mcp 0.12.6 → 0.12.7

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.
Files changed (3) hide show
  1. package/README.md +8 -11
  2. package/dist/index.js +51 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@yawlabs/tailscale-mcp)](https://www.npmjs.com/package/@yawlabs/tailscale-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
- [![CI](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml)
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.** 89 admin-API tools + 4 optional local-CLI diagnostics + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ 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.** 93 admin-API tools + 4 optional local-CLI diagnostics + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 1000+ 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
 
@@ -35,7 +35,7 @@ Reasonable question. Both have their place. Where this MCP is better:
35
35
  - **Typed tool surface, not string parsing.** Every tool has a Zod-validated input schema and a structured response. No brittle `tailscale status --json | jq` pipelines that break when the schema evolves.
36
36
  - **Cross-client, no user rewriting.** A Claude Code skill only loads in Claude Code. An MCP server works in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and anything else that speaks MCP. Version bumps ship through `npx` — users don't re-author their skill when Tailscale adds an endpoint.
37
37
  - **Safe-by-default writes.** Every tool declares `readOnlyHint` / `destructiveHint` / `idempotentHint` so clients can skip confirmation on reads and require it on mutations. A skill that shells out to the CLI can't express that.
38
- - **Real tests.** 700+ unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
38
+ - **Real tests.** 1000+ unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
39
39
 
40
40
  If you already have a skill that covers your 10% of Tailscale workflows, great — keep it. The MCP is for the other 90%.
41
41
 
@@ -43,11 +43,8 @@ If you already have a skill that covers your 10% of Tailscale workflows, great
43
43
 
44
44
  Fair critique from Reddit: a new repo claiming "actively maintained" with no visible tests is worth exactly zero trust. Here's what's actually verifiable:
45
45
 
46
- - **700+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
- - **3 CI workflows** on GitHub Actions:
48
- - [`ci.yml`](.github/workflows/ci.yml) — lint + typecheck + build + unit tests on every push and PR.
49
- - [`integration.yml`](.github/workflows/integration.yml) — read-only live-API smoke tests against a real tailnet. Wired up with three triggers (nightly schedule, every tag push via `release.yml`, manual dispatch); skips gracefully when no test-tailnet secret is configured, so forks aren't blocked.
50
- - [`release.yml`](.github/workflows/release.yml) — publishes to npm from a signed tag.
46
+ - **1000+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
+ - **Local release flow** via [`release.sh`](./release.sh): lint + test + bump + tag + push + npm publish + MCP Registry publish, all from the workstation. No CI workflow to babysit.
51
48
  - **Dependabot alerts** surface on this repo and get fixed, not ignored.
52
49
  - **Every tool verified against the live API.** If it's in the tool list, it calls a real endpoint that exists in the current v2 API. No placeholder 404 tools.
53
50
 
@@ -107,7 +104,7 @@ That's it. Now ask your agent:
107
104
 
108
105
  ## Too many tools? Subset them.
109
106
 
110
- 89 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
+ 93 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:
111
108
 
112
109
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
113
110
 
@@ -122,7 +119,7 @@ That's it. Now ask your agent:
122
119
 
123
120
  - **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
124
121
  - **`core`** (47 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
125
- - **`full`** (89 tools, default) — everything. Same as omitting the env var.
122
+ - **`full`** (93 tools, default) — everything. Same as omitting the env var.
126
123
 
127
124
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
128
125
 
@@ -237,7 +234,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
237
234
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
238
235
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
239
236
 
240
- ## Tools (89 + 4 opt-in)
237
+ ## Tools (93 + 4 opt-in)
241
238
 
242
239
  <details>
243
240
  <summary><strong>Status</strong> (1 tool)</summary>
package/dist/index.js CHANGED
@@ -6885,6 +6885,9 @@ var require_dist = __commonJS({
6885
6885
  }
6886
6886
  });
6887
6887
 
6888
+ // src/index.ts
6889
+ import { createRequire } from "node:module";
6890
+
6888
6891
  // node_modules/zod/v3/helpers/util.js
6889
6892
  var util;
6890
6893
  (function(util2) {
@@ -31054,7 +31057,12 @@ function validateAndSanitizeDescription(value) {
31054
31057
  );
31055
31058
  }
31056
31059
  function formatAuthError(status, apiBody) {
31057
- const usingOAuth = !process.env.TAILSCALE_API_KEY && !!process.env.TAILSCALE_OAUTH_CLIENT_ID;
31060
+ let usingOAuth = false;
31061
+ try {
31062
+ usingOAuth = getAuthConfig().kind === "oauth";
31063
+ } catch {
31064
+ usingOAuth = false;
31065
+ }
31058
31066
  const headline = status === 401 ? "Authentication failed (HTTP 401)." : "Authorization failed (HTTP 403): the request was authenticated but not permitted for this resource.";
31059
31067
  const cause = status === 401 ? usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked" : usingOAuth ? " - OAuth client is missing a scope required for this endpoint" : " - API key lacks the permission required for this endpoint";
31060
31068
  const lines = [headline, "", "Possible causes:", cause];
@@ -31135,7 +31143,8 @@ function compute429DelayMs(retryAfter, attempt) {
31135
31143
  }
31136
31144
  const asDate = Date.parse(retryAfter);
31137
31145
  if (Number.isFinite(asDate)) {
31138
- return Math.max(0, Math.min(asDate - Date.now(), MAX_429_DELAY_MS));
31146
+ const delta = asDate - Date.now();
31147
+ if (delta > 0) return Math.min(delta, MAX_429_DELAY_MS);
31139
31148
  }
31140
31149
  }
31141
31150
  const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
@@ -31238,6 +31247,23 @@ async function apiDelete(path, options) {
31238
31247
  }
31239
31248
 
31240
31249
  // src/cli.ts
31250
+ function parseValidationError(rawBody) {
31251
+ const trimmed = rawBody?.trim();
31252
+ if (!trimmed) return void 0;
31253
+ let parsed;
31254
+ try {
31255
+ parsed = JSON.parse(trimmed);
31256
+ } catch {
31257
+ return trimmed;
31258
+ }
31259
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
31260
+ return trimmed;
31261
+ }
31262
+ const obj = parsed;
31263
+ if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
31264
+ if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
31265
+ return void 0;
31266
+ }
31241
31267
  async function deployAcl(filePath) {
31242
31268
  let policy;
31243
31269
  try {
@@ -31261,8 +31287,9 @@ async function deployAcl(filePath) {
31261
31287
  console.error(`ACL validation failed: ${validateRes.error}`);
31262
31288
  process.exit(1);
31263
31289
  }
31264
- if (validateRes.rawBody?.trim()) {
31265
- console.error(`ACL validation failed: ${extractErrorMessage(validateRes.rawBody.trim())}`);
31290
+ const validationError = parseValidationError(validateRes.rawBody);
31291
+ if (validationError) {
31292
+ console.error(`ACL validation failed: ${validationError}`);
31266
31293
  process.exit(1);
31267
31294
  }
31268
31295
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
@@ -31306,9 +31333,11 @@ function filterTools(groups, options) {
31306
31333
  }
31307
31334
  const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
31308
31335
  const explicitTools2 = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31309
- const effectiveGroups = explicitTools2 ?? profileGroups ?? null;
31336
+ const explicitToolsAllUnknown = explicitTools2?.every((g) => !validNames.has(g)) ?? false;
31337
+ const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools2;
31338
+ const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
31310
31339
  const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
31311
- const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31340
+ const unknownGroups2 = explicitTools2 ? explicitTools2.filter((g) => !validNames.has(g)) : enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31312
31341
  const readonly2 = parseReadonlyFlag(options.readonly);
31313
31342
  const out = [];
31314
31343
  for (const [name, tools] of Object.entries(groups)) {
@@ -31320,9 +31349,10 @@ function filterTools(groups, options) {
31320
31349
  }
31321
31350
  const result = { tools: out, unknownGroups: unknownGroups2 };
31322
31351
  if (unknownProfile2) result.unknownProfile = unknownProfile2;
31323
- if (profileGroups && !explicitTools2) result.profileGroups = profileGroups;
31324
- if (explicitTools2) result.explicitTools = explicitTools2;
31352
+ if (profileGroups && !effectiveExplicitTools) result.profileGroups = profileGroups;
31353
+ if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
31325
31354
  if (profileWouldFilter2) result.profileWouldFilter = true;
31355
+ if (explicitToolsAllUnknown) result.toolsAllUnknown = true;
31326
31356
  return result;
31327
31357
  }
31328
31358
 
@@ -31863,7 +31893,7 @@ var deviceTools = [
31863
31893
  inputSchema: external_exports.object({
31864
31894
  deviceId: external_exports.string().describe("The device ID"),
31865
31895
  attributeKey: external_exports.string().describe("The attribute key (must start with 'custom:', e.g. 'custom:lastAuditDate')"),
31866
- value: external_exports.string().describe("The attribute value"),
31896
+ value: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()]).describe("The attribute value (string, number, or boolean)"),
31867
31897
  expiry: external_exports.string().optional().describe(
31868
31898
  "Optional expiry time in RFC3339 format (e.g. '2026-12-01T00:00:00Z'). Attribute is automatically removed after expiry."
31869
31899
  )
@@ -31957,7 +31987,7 @@ var deviceTools = [
31957
31987
  },
31958
31988
  {
31959
31989
  name: "tailscale_set_devices_authorized",
31960
- description: "Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
31990
+ description: "Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
31961
31991
  annotations: {
31962
31992
  title: "Set devices authorized (bulk)",
31963
31993
  readOnlyHint: false,
@@ -32023,7 +32053,7 @@ var deviceTools = [
32023
32053
  ).describe(
32024
32054
  'Map of device ID to attribute config map (e.g. { "12345": { "custom:compliant": { "value": "true" } }, "67890": { "custom:compliant": { "value": false, "expiry": "2026-12-01T00:00:00Z" } } }). Pass null as the config to delete an attribute.'
32025
32055
  ),
32026
- comment: external_exports.string().optional().describe("Optional comment added to the audit log explaining why attributes are being set (max 200 chars)")
32056
+ comment: external_exports.string().max(200).optional().describe("Optional comment added to the audit log explaining why attributes are being set (max 200 chars)")
32027
32057
  }),
32028
32058
  handler: async (input) => {
32029
32059
  const invalidKeys = [];
@@ -33056,15 +33086,15 @@ var postureTools = [
33056
33086
  cloudId: external_exports.string().optional().describe("Updated cloud identifier (e.g. 'us-1', 'global', or provider FQDN)")
33057
33087
  }),
33058
33088
  handler: async (input) => {
33059
- const { integrationId, ...body } = input;
33060
33089
  const cleanBody = {};
33061
- for (const [key, value] of Object.entries(body)) {
33062
- if (value !== void 0) cleanBody[key] = value;
33063
- }
33090
+ if (input.clientId !== void 0) cleanBody.clientId = input.clientId;
33091
+ if (input.clientSecret !== void 0) cleanBody.clientSecret = input.clientSecret;
33092
+ if (input.tenantId !== void 0) cleanBody.tenantId = input.tenantId;
33093
+ if (input.cloudId !== void 0) cleanBody.cloudId = input.cloudId;
33064
33094
  if (Object.keys(cleanBody).length === 0) {
33065
33095
  throw new Error("No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudId.");
33066
33096
  }
33067
- return apiPatch(`/posture/integrations/${encPath(integrationId)}`, cleanBody);
33097
+ return apiPatch(`/posture/integrations/${encPath(input.integrationId)}`, cleanBody);
33068
33098
  }
33069
33099
  },
33070
33100
  {
@@ -33676,7 +33706,7 @@ var webhookTools = [
33676
33706
  ];
33677
33707
 
33678
33708
  // src/index.ts
33679
- var version2 = true ? "0.12.6" : (await null).createRequire(import.meta.url)("../package.json").version;
33709
+ var version2 = true ? "0.12.7" : resolveVersionFallback();
33680
33710
  var subcommand = process.argv[2];
33681
33711
  if (subcommand === "deploy-acl") {
33682
33712
  const filePath = process.argv[3];
@@ -33717,7 +33747,8 @@ var {
33717
33747
  unknownGroups,
33718
33748
  unknownProfile,
33719
33749
  explicitTools,
33720
- profileWouldFilter
33750
+ profileWouldFilter,
33751
+ toolsAllUnknown
33721
33752
  } = filterTools(toolGroups, {
33722
33753
  tools: process.env.TAILSCALE_TOOLS,
33723
33754
  readonly: process.env.TAILSCALE_READONLY,
@@ -33725,8 +33756,9 @@ var {
33725
33756
  });
33726
33757
  if (unknownGroups.length > 0) {
33727
33758
  const validNames = Object.keys(toolGroups);
33759
+ const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
33728
33760
  console.error(
33729
- `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}`
33761
+ `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
33730
33762
  );
33731
33763
  }
33732
33764
  if (unknownProfile) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
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",