@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.
- package/README.md +8 -11
- package/dist/index.js +51 -19
- 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
|
-
[](./release.sh)
|
|
7
7
|
|
|
8
|
-
**Ask your agent questions about your tailnet and have it act on the answers.**
|
|
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.**
|
|
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
|
-
- **
|
|
47
|
-
- **
|
|
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
|
-
|
|
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`** (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31265
|
-
|
|
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
|
|
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 && !
|
|
31324
|
-
if (
|
|
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
|
-
|
|
33062
|
-
|
|
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.
|
|
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) {
|