@yawlabs/tailscale-mcp 0.12.8 → 0.13.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.
Files changed (3) hide show
  1. package/README.md +39 -2
  2. package/dist/index.js +36 -15
  3. package/package.json +12 -4
package/README.md CHANGED
@@ -454,10 +454,47 @@ MCP Resources expose read-only data clients can browse without a tool call.
454
454
  For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent — use the built-in CLI:
455
455
 
456
456
  ```bash
457
+ # PR check: validate the proposed policy without touching the tailnet
458
+ npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
459
+
460
+ # On merge: ETag fetch + validate + deploy with If-Match, fail-closed at every step
457
461
  npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
458
462
  ```
459
463
 
460
- Handles ETag fetching, validation, and deployment in one command. Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars.
464
+ Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
465
+
466
+ A complete GitHub Actions workflow — validate on PR, deploy on merge:
467
+
468
+ ```yaml
469
+ name: tailscale-acl
470
+ on:
471
+ pull_request:
472
+ paths: ["tailscale/acl.json"]
473
+ push:
474
+ branches: [main]
475
+ paths: ["tailscale/acl.json"]
476
+
477
+ jobs:
478
+ acl:
479
+ runs-on: ubuntu-latest
480
+ env:
481
+ TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
482
+ TAILSCALE_TAILNET: your-tailnet.ts.net # or omit: defaults to the key's tailnet
483
+ steps:
484
+ - uses: actions/checkout@v4
485
+ - uses: actions/setup-node@v4
486
+ with: { node-version: 22 }
487
+ - name: Validate ACL
488
+ if: github.event_name == 'pull_request'
489
+ run: npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
490
+ - name: Deploy ACL
491
+ if: github.event_name == 'push'
492
+ run: npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
493
+ ```
494
+
495
+ For reproducible deploys, replace `@latest` with a pinned version.
496
+
497
+ > **If you hand-roll this with `curl` instead:** Tailscale's ACL endpoint only returns the `ETag` header on **GET**, not HEAD. A `curl -I` (HEAD) ETag fetch silently yields an empty value — and an empty `If-Match` either deploys unguarded (clobbering concurrent edits) or trips your guard and fails the deploy. Fetch the ETag with a GET (`curl -fsS -D - -o /dev/null ...`), and fail the job if it comes back empty. The CLI above does all of this for you.
461
498
 
462
499
  **Optional:** Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
463
500
 
@@ -467,7 +504,7 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
467
504
 
468
505
  ## Requirements
469
506
 
470
- - Node.js 20+
507
+ - Node.js 20+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
471
508
  - A Tailscale API key or OAuth client credentials
472
509
 
473
510
  ## Contributing
package/dist/index.js CHANGED
@@ -31264,19 +31264,15 @@ function parseValidationError(rawBody) {
31264
31264
  if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
31265
31265
  return void 0;
31266
31266
  }
31267
- async function deployAcl(filePath) {
31268
- let policy;
31267
+ function readPolicyFile(filePath) {
31269
31268
  try {
31270
- policy = readFileSync(filePath, "utf-8");
31269
+ return readFileSync(filePath, "utf-8");
31271
31270
  } catch (err) {
31272
31271
  console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
31273
31272
  process.exit(1);
31274
31273
  }
31275
- const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31276
- if (!getRes.ok || !getRes.etag) {
31277
- console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
31278
- process.exit(1);
31279
- }
31274
+ }
31275
+ async function validatePolicy(policy) {
31280
31276
  const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
31281
31277
  rawBody: policy,
31282
31278
  contentType: "application/hujson",
@@ -31292,6 +31288,20 @@ async function deployAcl(filePath) {
31292
31288
  console.error(`ACL validation failed: ${validationError}`);
31293
31289
  process.exit(1);
31294
31290
  }
31291
+ }
31292
+ async function validateAcl(filePath) {
31293
+ const policy = readPolicyFile(filePath);
31294
+ await validatePolicy(policy);
31295
+ console.log("ACL policy is valid");
31296
+ }
31297
+ async function deployAcl(filePath) {
31298
+ const policy = readPolicyFile(filePath);
31299
+ const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31300
+ if (!getRes.ok || !getRes.etag) {
31301
+ console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
31302
+ process.exit(1);
31303
+ }
31304
+ await validatePolicy(policy);
31295
31305
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31296
31306
  rawBody: policy,
31297
31307
  contentType: "application/hujson",
@@ -31300,7 +31310,13 @@ async function deployAcl(filePath) {
31300
31310
  accept: "application/hujson"
31301
31311
  });
31302
31312
  if (!deployRes.ok) {
31303
- console.error(`ACL deploy failed: ${deployRes.error}`);
31313
+ if (deployRes.status === 412) {
31314
+ console.error(
31315
+ `ACL deploy failed: the tailnet ACL changed between the ETag fetch and the deploy (concurrent edit). Re-run to retry against the current version. (${deployRes.error})`
31316
+ );
31317
+ } else {
31318
+ console.error(`ACL deploy failed: ${deployRes.error}`);
31319
+ }
31304
31320
  process.exit(1);
31305
31321
  }
31306
31322
  console.log("ACL deployed successfully");
@@ -31959,7 +31975,7 @@ var deviceTools = [
31959
31975
  },
31960
31976
  inputSchema: external_exports.object({
31961
31977
  deviceId: external_exports.string().describe("The device ID"),
31962
- ipv4: external_exports.string().ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
31978
+ ipv4: external_exports.ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
31963
31979
  }),
31964
31980
  handler: async (input) => {
31965
31981
  return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
@@ -33551,7 +33567,7 @@ function getAllowedWebhookEvents() {
33551
33567
  const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
33552
33568
  return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
33553
33569
  }
33554
- var endpointUrlSchema = external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
33570
+ var endpointUrlSchema = external_exports.url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
33555
33571
  var webhookSubscriptionsSchema = external_exports.array(external_exports.string()).min(1).superRefine((arr, ctx) => {
33556
33572
  const allowed = getAllowedWebhookEvents();
33557
33573
  let knownEventsList = null;
@@ -33706,15 +33722,16 @@ var webhookTools = [
33706
33722
  ];
33707
33723
 
33708
33724
  // src/index.ts
33709
- var version2 = true ? "0.12.8" : resolveVersionFallback();
33725
+ var version2 = true ? "0.13.0" : resolveVersionFallback();
33710
33726
  var subcommand = process.argv[2];
33711
- if (subcommand === "deploy-acl") {
33727
+ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
33712
33728
  const filePath = process.argv[3];
33713
33729
  if (!filePath) {
33714
- console.error("Usage: tailscale-mcp deploy-acl <path-to-acl.json>");
33730
+ console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
33715
33731
  process.exit(1);
33716
33732
  }
33717
- await deployAcl(filePath).catch((err) => {
33733
+ const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
33734
+ await run(filePath).catch((err) => {
33718
33735
  console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
33719
33736
  process.exit(1);
33720
33737
  });
@@ -33722,6 +33739,10 @@ if (subcommand === "deploy-acl") {
33722
33739
  } else if (subcommand === "version" || subcommand === "--version") {
33723
33740
  console.log(version2);
33724
33741
  process.exit(0);
33742
+ } else if (subcommand !== void 0) {
33743
+ console.error(
33744
+ `@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version. Starting the MCP server.`
33745
+ );
33725
33746
  }
33726
33747
  var toolGroups = {
33727
33748
  status: statusTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.12.8",
3
+ "version": "0.13.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",
@@ -32,7 +32,7 @@
32
32
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
33
33
  "dev": "tsc --watch",
34
34
  "start": "node dist/index.js",
35
- "test": "npm run build && node --test dist/**/*.test.js",
35
+ "test": "npm run build && node --test \"dist/**/*.test.js\"",
36
36
  "test:ci": "npm run test",
37
37
  "lint": "biome check src/",
38
38
  "lint:fix": "biome check --write src/",
@@ -40,10 +40,11 @@
40
40
  },
41
41
  "dependencies": {},
42
42
  "overrides": {
43
- "hono": "^4.12.18",
43
+ "hono": "^4.12.21",
44
44
  "@hono/node-server": "^1.19.13",
45
45
  "fast-uri": "^3.1.2",
46
- "ip-address": "^10.1.1"
46
+ "ip-address": "^10.1.1",
47
+ "qs": "^6.15.2"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@biomejs/biome": "^2.4.12",
@@ -55,5 +56,12 @@
55
56
  },
56
57
  "engines": {
57
58
  "node": ">=20"
59
+ },
60
+ "devEngines": {
61
+ "runtime": {
62
+ "name": "node",
63
+ "version": ">=22",
64
+ "onFail": "error"
65
+ }
58
66
  }
59
67
  }