@yawlabs/tailscale-mcp 0.8.6 → 0.8.8

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 +14 -10
  2. package/dist/index.js +37 -18
  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) [![Integration](https://github.com/YawLabs/tailscale-mcp/actions/workflows/integration.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/integration.yml)
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)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 99 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 735 tests and a nightly integration run against a real tailnet.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 99 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 736 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
 
@@ -33,9 +33,9 @@ Reasonable question. Both have their place. Where this MCP is better:
33
33
 
34
34
  - **Full admin API coverage.** The `tailscale` CLI is scoped to the node it runs on. Admin concerns — ACLs, users, invites, webhooks, log streaming, workload identity, OAuth clients, posture — live in the v2 HTTP API. You'd be shelling out to `curl` anyway.
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
- - **Cross-client, no user rewriting.** A Claude Code skill is tied to 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.
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.** 735 unit tests + an integration suite hitting a live tailnet on every tag. 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.** 736 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,10 +43,10 @@ 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
- - **735 tests** (179 suites, `node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
46
+ - **736 tests** (179 suites, `node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
47
  - **3 CI workflows** on GitHub Actions:
48
48
  - [`ci.yml`](.github/workflows/ci.yml) — lint + typecheck + build + unit tests on every push and PR.
49
- - [`integration.yml`](.github/workflows/integration.yml) — runs the full tool surface against a real tailnet.
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
50
  - [`release.yml`](.github/workflows/release.yml) — publishes to npm from a signed tag.
51
51
  - **Dependabot alerts** surface on this repo and get fixed, not ignored.
52
52
  - **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.
@@ -91,7 +91,7 @@ Windows:
91
91
  }
92
92
  ```
93
93
 
94
- > **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround.
94
+ > **Why the extra step on Windows?** On Windows, `npx` is a `.cmd` file, and Node 20+ refuses to spawn `.cmd` files directly. Wrapping with `cmd /c` is the standard workaround.
95
95
 
96
96
  **3. Restart and approve**
97
97
 
@@ -158,7 +158,7 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
158
158
  The server logs the active filter to stderr on startup:
159
159
 
160
160
  ```
161
- @yawlabs/tailscale-mcp v0.8.x ready (19 tools, profile=core, readonly)
161
+ @yawlabs/tailscale-mcp v0.8.8 ready (19 tools, profile=core, readonly)
162
162
  ```
163
163
 
164
164
  If you don't set any filter, startup prints a tip pointing you at the profiles.
@@ -453,7 +453,7 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
453
453
 
454
454
  ## Contributing
455
455
 
456
- Contributions welcome. Please [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) to discuss before a PR for anything beyond a typo fix.
456
+ Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the PR workflow and AI-agent guidelines. Please [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) to discuss before a PR for anything beyond a typo fix.
457
457
 
458
458
  ```bash
459
459
  git clone https://github.com/YawLabs/tailscale-mcp.git
@@ -462,11 +462,15 @@ npm install
462
462
  npm run lint # Biome check
463
463
  npm run lint:fix # Auto-fix
464
464
  npm run build # tsc + esbuild bundle
465
- npm test # node --test (735 tests)
465
+ npm test # node --test (736 tests)
466
466
  ```
467
467
 
468
468
  For integration testing against your own tailnet: set `TAILSCALE_API_KEY` and run `node dist/index.js`.
469
469
 
470
+ ## Security
471
+
472
+ Found a vulnerability? See [SECURITY.md](SECURITY.md) — please use GitHub's private vulnerability reporting, not a public issue.
473
+
470
474
  ## License
471
475
 
472
476
  MIT
package/dist/index.js CHANGED
@@ -31376,7 +31376,7 @@ var keyTools = [
31376
31376
  },
31377
31377
  {
31378
31378
  name: "tailscale_update_key",
31379
- description: "Update an existing key's description or configuration. For OAuth clients and federated identities, scopes, tags, and identity fields can also be updated. Auth keys only support description updates.",
31379
+ description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description' \u2014 the Tailscale API will reject other fields.",
31380
31380
  annotations: {
31381
31381
  title: "Update key",
31382
31382
  readOnlyHint: false,
@@ -32061,16 +32061,24 @@ var tailnetTools = [
32061
32061
  security: external_exports3.object({ email: external_exports3.string() }).optional().describe("Security contact email")
32062
32062
  }),
32063
32063
  handler: async (input) => {
32064
- const results = {};
32064
+ const applied = {};
32065
+ const failed = {};
32065
32066
  for (const contactType of ["account", "support", "security"]) {
32066
32067
  const value = input[contactType];
32067
- if (value !== void 0) {
32068
- const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32069
- if (!res.ok) return res;
32070
- results[contactType] = res.data;
32071
- }
32068
+ if (value === void 0) continue;
32069
+ const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32070
+ if (res.ok) applied[contactType] = res.data;
32071
+ else failed[contactType] = res.error ?? `HTTP ${res.status}`;
32072
+ }
32073
+ const hasFailed = Object.keys(failed).length > 0;
32074
+ const hasApplied = Object.keys(applied).length > 0;
32075
+ if (hasFailed && !hasApplied) {
32076
+ return { ok: false, status: 500, error: `Contact update failed: ${JSON.stringify(failed)}` };
32077
+ }
32078
+ if (hasFailed) {
32079
+ return { ok: true, status: 207, data: { applied, failed } };
32072
32080
  }
32073
- return { ok: true, status: 200, data: results };
32081
+ return { ok: true, status: 200, data: applied };
32074
32082
  }
32075
32083
  },
32076
32084
  {
@@ -32393,7 +32401,7 @@ var workloadIdentityTools = [
32393
32401
  },
32394
32402
  {
32395
32403
  name: "tailscale_get_workload_identity",
32396
- description: "Get details for a specific workload identity provider.",
32404
+ description: "Get details for a specific federated workload identity provider, including issuer URL, audience, and the subject patterns it accepts for OIDC token exchange.",
32397
32405
  annotations: {
32398
32406
  title: "Get workload identity",
32399
32407
  readOnlyHint: true,
@@ -32479,7 +32487,7 @@ var workloadIdentityTools = [
32479
32487
  ];
32480
32488
 
32481
32489
  // src/index.ts
32482
- var version2 = true ? "0.8.6" : (await null).createRequire(import.meta.url)("../package.json").version;
32490
+ var version2 = true ? "0.8.8" : (await null).createRequire(import.meta.url)("../package.json").version;
32483
32491
  var subcommand = process.argv[2];
32484
32492
  if (subcommand === "deploy-acl") {
32485
32493
  const filePath = process.argv[3];
@@ -32584,9 +32592,13 @@ server.resource(
32584
32592
  ]);
32585
32593
  const data = {
32586
32594
  tailnet: getTailnet(),
32587
- deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : "error",
32588
- settings: settingsRes.ok ? settingsRes.data : void 0
32595
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
32596
+ settings: settingsRes.ok ? settingsRes.data : null
32589
32597
  };
32598
+ const errors = {};
32599
+ if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
32600
+ if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
32601
+ if (Object.keys(errors).length > 0) data.errors = errors;
32590
32602
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
32591
32603
  }
32592
32604
  );
@@ -32596,7 +32608,7 @@ server.resource(
32596
32608
  { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
32597
32609
  async (uri) => {
32598
32610
  const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
32599
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error });
32611
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
32600
32612
  return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
32601
32613
  }
32602
32614
  );
@@ -32606,7 +32618,8 @@ server.resource(
32606
32618
  { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
32607
32619
  async (uri) => {
32608
32620
  const res = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
32609
- const text = res.ok ? res.rawBody ?? "" : `Error: ${res.error}`;
32621
+ const text = res.ok ? res.rawBody ?? "" : `// Error: ${res.error ?? `HTTP ${res.status}`}
32622
+ `;
32610
32623
  return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
32611
32624
  }
32612
32625
  );
@@ -32625,11 +32638,17 @@ server.resource(
32625
32638
  apiGet(`/tailnet/${getTailnet()}/dns/preferences`)
32626
32639
  ]);
32627
32640
  const data = {
32628
- nameservers: nameservers.ok ? nameservers.data : void 0,
32629
- searchPaths: searchPaths.ok ? searchPaths.data : void 0,
32630
- splitDns: splitDns.ok ? splitDns.data : void 0,
32631
- preferences: preferences.ok ? preferences.data : void 0
32641
+ nameservers: nameservers.ok ? nameservers.data : null,
32642
+ searchPaths: searchPaths.ok ? searchPaths.data : null,
32643
+ splitDns: splitDns.ok ? splitDns.data : null,
32644
+ preferences: preferences.ok ? preferences.data : null
32632
32645
  };
32646
+ const errors = {};
32647
+ if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
32648
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
32649
+ if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
32650
+ if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
32651
+ if (Object.keys(errors).length > 0) data.errors = errors;
32633
32652
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
32634
32653
  }
32635
32654
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",