@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.
- package/README.md +39 -2
- package/dist/index.js +36 -15
- 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
|
-
|
|
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
|
-
|
|
31268
|
-
let policy;
|
|
31267
|
+
function readPolicyFile(filePath) {
|
|
31269
31268
|
try {
|
|
31270
|
-
|
|
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
|
-
|
|
31276
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
33730
|
+
console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
|
|
33715
33731
|
process.exit(1);
|
|
33716
33732
|
}
|
|
33717
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|