@yawlabs/tailscale-mcp 0.12.6 → 0.12.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 +3 -6
- package/dist/index.js +51 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
8
|
**Ask your agent questions about your tailnet and have it act on the answers.** 89 admin-API tools + 4 optional local-CLI diagnostics + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ unit tests and an opt-in live-tailnet integration suite.
|
|
9
9
|
|
|
@@ -44,10 +44,7 @@ If you already have a skill that covers your 10% of Tailscale workflows, great
|
|
|
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
|
- **700+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
|
|
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.
|
|
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
|
|
|
@@ -224,7 +221,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add fo
|
|
|
224
221
|
|
|
225
222
|
Requirements: the `tailscale` binary must be in `PATH`. If it's installed somewhere unusual, set `TAILSCALE_BINARY` to its absolute path. The MCP server doesn't need root to run these — they're all diagnostic, not state-mutating. Operations that would need elevation (`tailscale up`, `set --advertise-routes`, `lock sign`) are deliberately not exposed.
|
|
226
223
|
|
|
227
|
-
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.10.9 ready (
|
|
224
|
+
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.10.9 ready (89 tools, local-cli=on)`.
|
|
228
225
|
|
|
229
226
|
## Resources (4)
|
|
230
227
|
|
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.8" : 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) {
|