@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.
- package/README.md +14 -10
- package/dist/index.js +37 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/tailscale-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/YawLabs/tailscale-mcp/stargazers)
|
|
6
|
-
[](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml)
|
|
6
|
+
[](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [](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
|
|
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
|
|
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.**
|
|
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
|
-
- **
|
|
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) —
|
|
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?**
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
32064
|
+
const applied = {};
|
|
32065
|
+
const failed = {};
|
|
32065
32066
|
for (const contactType of ["account", "support", "security"]) {
|
|
32066
32067
|
const value = input[contactType];
|
|
32067
|
-
if (value
|
|
32068
|
-
|
|
32069
|
-
|
|
32070
|
-
|
|
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:
|
|
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.
|
|
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 :
|
|
32588
|
-
settings: settingsRes.ok ? settingsRes.data :
|
|
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 ?? "" :
|
|
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 :
|
|
32629
|
-
searchPaths: searchPaths.ok ? searchPaths.data :
|
|
32630
|
-
splitDns: splitDns.ok ? splitDns.data :
|
|
32631
|
-
preferences: preferences.ok ? preferences.data :
|
|
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
|
);
|