@yawlabs/tailscale-mcp 0.10.1 → 0.10.3
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 +2 -0
- package/dist/index.js +55 -17
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -195,6 +195,8 @@ The server checks for an API key first, then falls back to OAuth. If neither is
|
|
|
195
195
|
|
|
196
196
|
**`TAILSCALE_MAX_CONCURRENT=N`** — cap in-flight API requests at `N`. Default is unlimited (no behavior change for users who don't opt in). Useful when an agent fans out aggressively against a tailnet that has stricter limits than the per-call retry can absorb.
|
|
197
197
|
|
|
198
|
+
**`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including 429 retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the 429 immediately instead of holding the line. Tune lower if your MCP client has a tighter outer timeout. 429s on non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
|
|
199
|
+
|
|
198
200
|
**Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
|
|
199
201
|
|
|
200
202
|
## Resources (4)
|
package/dist/index.js
CHANGED
|
@@ -30119,20 +30119,28 @@ var REQUEST_TIMEOUT_MS = 3e4;
|
|
|
30119
30119
|
var MAX_429_RETRIES = 3;
|
|
30120
30120
|
var DEFAULT_429_DELAY_MS = 1e3;
|
|
30121
30121
|
var MAX_429_DELAY_MS = 3e4;
|
|
30122
|
+
var MAX_REQUEST_BUDGET_MS = 9e4;
|
|
30123
|
+
var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
|
|
30122
30124
|
var oauthToken = null;
|
|
30123
30125
|
var oauthRefreshPromise = null;
|
|
30124
30126
|
function getAuthConfig() {
|
|
30125
30127
|
const apiKey = process.env.TAILSCALE_API_KEY;
|
|
30126
30128
|
const oauthClientId = process.env.TAILSCALE_OAUTH_CLIENT_ID;
|
|
30127
30129
|
const oauthClientSecret = process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
30128
|
-
if (apiKey) {
|
|
30129
|
-
|
|
30130
|
+
if (apiKey !== void 0) {
|
|
30131
|
+
const trimmedKey = apiKey.trim();
|
|
30132
|
+
if (trimmedKey === "") {
|
|
30130
30133
|
throw new Error("TAILSCALE_API_KEY is set but empty. Provide a valid API key.");
|
|
30131
30134
|
}
|
|
30132
|
-
return { kind: "apiKey", apiKey };
|
|
30135
|
+
return { kind: "apiKey", apiKey: trimmedKey };
|
|
30133
30136
|
}
|
|
30134
|
-
if (oauthClientId
|
|
30135
|
-
|
|
30137
|
+
if (oauthClientId !== void 0 || oauthClientSecret !== void 0) {
|
|
30138
|
+
const trimmedId = (oauthClientId ?? "").trim();
|
|
30139
|
+
const trimmedSecret = (oauthClientSecret ?? "").trim();
|
|
30140
|
+
if (trimmedId === "" || trimmedSecret === "") {
|
|
30141
|
+
throw new Error("TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET must both be set and non-empty.");
|
|
30142
|
+
}
|
|
30143
|
+
return { kind: "oauth", clientId: trimmedId, clientSecret: trimmedSecret };
|
|
30136
30144
|
}
|
|
30137
30145
|
const hint = process.platform === "win32" ? ' On Windows, env vars set in bash/WSL profiles are not visible to MCP servers launched via cmd. Either add "env": {"TAILSCALE_API_KEY": "tskey-api-..."} to your .mcp.json, or set it as a Windows user environment variable.' : "";
|
|
30138
30146
|
throw new Error(
|
|
@@ -30245,6 +30253,12 @@ function getConcurrencyLimit() {
|
|
|
30245
30253
|
const n = Number.parseInt(raw, 10);
|
|
30246
30254
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
30247
30255
|
}
|
|
30256
|
+
function getRequestBudgetMs() {
|
|
30257
|
+
const raw = process.env.TAILSCALE_REQUEST_BUDGET_MS;
|
|
30258
|
+
if (!raw) return MAX_REQUEST_BUDGET_MS;
|
|
30259
|
+
const n = Number.parseInt(raw, 10);
|
|
30260
|
+
return Number.isFinite(n) && n > 0 ? n : MAX_REQUEST_BUDGET_MS;
|
|
30261
|
+
}
|
|
30248
30262
|
async function withConcurrencyLimit(fn) {
|
|
30249
30263
|
const limit = getConcurrencyLimit();
|
|
30250
30264
|
if (limit === 0) return fn();
|
|
@@ -30309,12 +30323,19 @@ async function apiRequest(method, path, body, options) {
|
|
|
30309
30323
|
const url2 = path.startsWith("http") ? path : `${BASE_URL}${path}`;
|
|
30310
30324
|
const startedAt = Date.now();
|
|
30311
30325
|
debugLog(`${method} ${url2}`);
|
|
30326
|
+
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
30327
|
+
const requestBudgetMs = getRequestBudgetMs();
|
|
30312
30328
|
return withConcurrencyLimit(async () => {
|
|
30313
30329
|
let res;
|
|
30314
30330
|
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
30315
30331
|
res = await executeFetch(method, url2, headers, fetchBody);
|
|
30316
|
-
if (res.status !== 429 || attempt === MAX_429_RETRIES) break;
|
|
30332
|
+
if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
|
|
30317
30333
|
const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
|
|
30334
|
+
const elapsed2 = Date.now() - startedAt;
|
|
30335
|
+
if (elapsed2 + delay + REQUEST_TIMEOUT_MS > requestBudgetMs) {
|
|
30336
|
+
debugLog(` -> 429 (attempt ${attempt + 1}), giving up: budget exhausted (${elapsed2}ms + ${delay}ms)`);
|
|
30337
|
+
break;
|
|
30338
|
+
}
|
|
30318
30339
|
debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
30319
30340
|
await res.text().catch(() => void 0);
|
|
30320
30341
|
await new Promise((r) => setTimeout(r, delay));
|
|
@@ -30384,6 +30405,10 @@ async function deployAcl(filePath) {
|
|
|
30384
30405
|
console.error(`ACL validation failed: ${validateRes.error}`);
|
|
30385
30406
|
process.exit(1);
|
|
30386
30407
|
}
|
|
30408
|
+
if (validateRes.rawBody?.trim()) {
|
|
30409
|
+
console.error(`ACL validation failed: ${extractErrorMessage(validateRes.rawBody.trim())}`);
|
|
30410
|
+
process.exit(1);
|
|
30411
|
+
}
|
|
30387
30412
|
const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
30388
30413
|
rawBody: policy,
|
|
30389
30414
|
contentType: "application/hujson",
|
|
@@ -30411,7 +30436,7 @@ function filterTools(groups, options) {
|
|
|
30411
30436
|
let unknownProfile2;
|
|
30412
30437
|
if (options.profile) {
|
|
30413
30438
|
const profileKey = options.profile.trim().toLowerCase();
|
|
30414
|
-
if (profileKey
|
|
30439
|
+
if (Object.hasOwn(PROFILES, profileKey)) {
|
|
30415
30440
|
const preset = PROFILES[profileKey];
|
|
30416
30441
|
profileGroups = preset.length > 0 ? [...preset] : void 0;
|
|
30417
30442
|
} else {
|
|
@@ -30551,8 +30576,15 @@ var aclTools = [
|
|
|
30551
30576
|
// src/tools/audit.ts
|
|
30552
30577
|
function assertRFC3339(value, label) {
|
|
30553
30578
|
const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
30579
|
+
const err = () => new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
|
|
30554
30580
|
if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
|
|
30555
|
-
throw
|
|
30581
|
+
throw err();
|
|
30582
|
+
}
|
|
30583
|
+
const dateOnly = value.slice(0, 10);
|
|
30584
|
+
const [y, m, d] = dateOnly.split("-").map(Number);
|
|
30585
|
+
const utc = /* @__PURE__ */ new Date(`${dateOnly}T00:00:00Z`);
|
|
30586
|
+
if (Number.isNaN(utc.getTime()) || utc.getUTCFullYear() !== y || utc.getUTCMonth() + 1 !== m || utc.getUTCDate() !== d) {
|
|
30587
|
+
throw err();
|
|
30556
30588
|
}
|
|
30557
30589
|
}
|
|
30558
30590
|
var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -31200,6 +31232,9 @@ var dnsTools = [
|
|
|
31200
31232
|
for (const [key, value] of Object.entries(input)) {
|
|
31201
31233
|
if (value !== void 0) body[key] = value;
|
|
31202
31234
|
}
|
|
31235
|
+
if (Object.keys(body).length === 0) {
|
|
31236
|
+
throw new Error("No fields to update. Provide at least one of: dns, searchPaths, splitDns, magicDNS.");
|
|
31237
|
+
}
|
|
31203
31238
|
return apiPost(`/tailnet/${getTailnet()}/dns/configuration`, body);
|
|
31204
31239
|
}
|
|
31205
31240
|
}
|
|
@@ -31239,7 +31274,7 @@ var inviteTools = [
|
|
|
31239
31274
|
deviceId: external_exports3.string().describe("The device ID to create an invite for"),
|
|
31240
31275
|
multiUse: external_exports3.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
31241
31276
|
allowExitNode: external_exports3.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
|
|
31242
|
-
email: external_exports3.
|
|
31277
|
+
email: external_exports3.email().optional().describe("Email address to send the invite to")
|
|
31243
31278
|
}),
|
|
31244
31279
|
handler: async (input) => {
|
|
31245
31280
|
const body = {};
|
|
@@ -31327,7 +31362,7 @@ var inviteTools = [
|
|
|
31327
31362
|
openWorldHint: true
|
|
31328
31363
|
},
|
|
31329
31364
|
inputSchema: external_exports3.object({
|
|
31330
|
-
email: external_exports3.
|
|
31365
|
+
email: external_exports3.email().optional().describe("Email address to send the invite to"),
|
|
31331
31366
|
role: external_exports3.enum(["member", "admin", "it-admin", "network-admin", "billing-admin", "auditor"]).optional().describe("Role to assign to the invited user (default: member)")
|
|
31332
31367
|
}),
|
|
31333
31368
|
handler: async (input) => {
|
|
@@ -32134,7 +32169,7 @@ var tailnetTools = [
|
|
|
32134
32169
|
},
|
|
32135
32170
|
{
|
|
32136
32171
|
name: "tailscale_set_contacts",
|
|
32137
|
-
description: "Update tailnet contact information.",
|
|
32172
|
+
description: "Update tailnet contact information. Each provided contact type (account/support/security) is PATCHed in parallel; per-type errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the response is data: { applied, failed } -- inspect data.failed for per-type error details.",
|
|
32138
32173
|
annotations: {
|
|
32139
32174
|
title: "Set contacts",
|
|
32140
32175
|
readOnlyHint: false,
|
|
@@ -32143,12 +32178,15 @@ var tailnetTools = [
|
|
|
32143
32178
|
openWorldHint: true
|
|
32144
32179
|
},
|
|
32145
32180
|
inputSchema: external_exports3.object({
|
|
32146
|
-
account: external_exports3.object({ email: external_exports3.
|
|
32147
|
-
support: external_exports3.object({ email: external_exports3.
|
|
32148
|
-
security: external_exports3.object({ email: external_exports3.
|
|
32181
|
+
account: external_exports3.object({ email: external_exports3.email() }).optional().describe("Account contact email"),
|
|
32182
|
+
support: external_exports3.object({ email: external_exports3.email() }).optional().describe("Support contact email"),
|
|
32183
|
+
security: external_exports3.object({ email: external_exports3.email() }).optional().describe("Security contact email")
|
|
32149
32184
|
}),
|
|
32150
32185
|
handler: async (input) => {
|
|
32151
32186
|
const types = ["account", "support", "security"].filter((t) => input[t] !== void 0);
|
|
32187
|
+
if (types.length === 0) {
|
|
32188
|
+
throw new Error("No fields to update. Provide at least one of: account, support, security.");
|
|
32189
|
+
}
|
|
32152
32190
|
const results = await Promise.all(
|
|
32153
32191
|
types.map(async (contactType) => {
|
|
32154
32192
|
const res = await apiPatch(
|
|
@@ -32391,7 +32429,7 @@ var webhookTools = [
|
|
|
32391
32429
|
},
|
|
32392
32430
|
inputSchema: external_exports3.object({
|
|
32393
32431
|
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
|
|
32394
|
-
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
|
|
32432
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).describe("Event types to subscribe to (at least one)")
|
|
32395
32433
|
}),
|
|
32396
32434
|
handler: async (input) => {
|
|
32397
32435
|
return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
|
|
@@ -32413,7 +32451,7 @@ var webhookTools = [
|
|
|
32413
32451
|
inputSchema: external_exports3.object({
|
|
32414
32452
|
webhookId: external_exports3.string().describe("The webhook ID to update"),
|
|
32415
32453
|
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
|
|
32416
|
-
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
|
|
32454
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).optional().describe("Updated list of event types to subscribe to (at least one)")
|
|
32417
32455
|
}),
|
|
32418
32456
|
handler: async (input) => {
|
|
32419
32457
|
const body = {};
|
|
@@ -32481,7 +32519,7 @@ var webhookTools = [
|
|
|
32481
32519
|
];
|
|
32482
32520
|
|
|
32483
32521
|
// src/index.ts
|
|
32484
|
-
var version2 = true ? "0.10.
|
|
32522
|
+
var version2 = true ? "0.10.3" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
32485
32523
|
var subcommand = process.argv[2];
|
|
32486
32524
|
if (subcommand === "deploy-acl") {
|
|
32487
32525
|
const filePath = process.argv[3];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Tailscale MCP server for managing your tailnet from AI assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "YawLabs <contact@yaw.sh>",
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc && node build.mjs",
|
|
31
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
31
32
|
"dev": "tsc --watch",
|
|
32
33
|
"start": "node dist/index.js",
|
|
33
34
|
"test": "npm run build && node --test dist/**/*.test.js",
|
|
34
35
|
"test:ci": "npm run test",
|
|
35
36
|
"lint": "biome check src/",
|
|
36
37
|
"lint:fix": "biome check --write src/",
|
|
37
|
-
"prepublishOnly": "npm run build"
|
|
38
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {},
|
|
40
41
|
"overrides": {
|