@yawlabs/tailscale-mcp 0.10.1 → 0.10.2
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 +43 -12
- 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));
|
|
@@ -30411,7 +30432,7 @@ function filterTools(groups, options) {
|
|
|
30411
30432
|
let unknownProfile2;
|
|
30412
30433
|
if (options.profile) {
|
|
30413
30434
|
const profileKey = options.profile.trim().toLowerCase();
|
|
30414
|
-
if (profileKey
|
|
30435
|
+
if (Object.hasOwn(PROFILES, profileKey)) {
|
|
30415
30436
|
const preset = PROFILES[profileKey];
|
|
30416
30437
|
profileGroups = preset.length > 0 ? [...preset] : void 0;
|
|
30417
30438
|
} else {
|
|
@@ -30551,8 +30572,15 @@ var aclTools = [
|
|
|
30551
30572
|
// src/tools/audit.ts
|
|
30552
30573
|
function assertRFC3339(value, label) {
|
|
30553
30574
|
const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
30575
|
+
const err = () => new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
|
|
30554
30576
|
if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
|
|
30555
|
-
throw
|
|
30577
|
+
throw err();
|
|
30578
|
+
}
|
|
30579
|
+
const dateOnly = value.slice(0, 10);
|
|
30580
|
+
const [y, m, d] = dateOnly.split("-").map(Number);
|
|
30581
|
+
const utc = /* @__PURE__ */ new Date(`${dateOnly}T00:00:00Z`);
|
|
30582
|
+
if (Number.isNaN(utc.getTime()) || utc.getUTCFullYear() !== y || utc.getUTCMonth() + 1 !== m || utc.getUTCDate() !== d) {
|
|
30583
|
+
throw err();
|
|
30556
30584
|
}
|
|
30557
30585
|
}
|
|
30558
30586
|
var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -31200,6 +31228,9 @@ var dnsTools = [
|
|
|
31200
31228
|
for (const [key, value] of Object.entries(input)) {
|
|
31201
31229
|
if (value !== void 0) body[key] = value;
|
|
31202
31230
|
}
|
|
31231
|
+
if (Object.keys(body).length === 0) {
|
|
31232
|
+
throw new Error("No fields to update. Provide at least one of: dns, searchPaths, splitDns, magicDNS.");
|
|
31233
|
+
}
|
|
31203
31234
|
return apiPost(`/tailnet/${getTailnet()}/dns/configuration`, body);
|
|
31204
31235
|
}
|
|
31205
31236
|
}
|
|
@@ -32134,7 +32165,7 @@ var tailnetTools = [
|
|
|
32134
32165
|
},
|
|
32135
32166
|
{
|
|
32136
32167
|
name: "tailscale_set_contacts",
|
|
32137
|
-
description: "Update tailnet contact information.",
|
|
32168
|
+
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
32169
|
annotations: {
|
|
32139
32170
|
title: "Set contacts",
|
|
32140
32171
|
readOnlyHint: false,
|
|
@@ -32391,7 +32422,7 @@ var webhookTools = [
|
|
|
32391
32422
|
},
|
|
32392
32423
|
inputSchema: external_exports3.object({
|
|
32393
32424
|
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")
|
|
32425
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).describe("Event types to subscribe to (at least one)")
|
|
32395
32426
|
}),
|
|
32396
32427
|
handler: async (input) => {
|
|
32397
32428
|
return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
|
|
@@ -32413,7 +32444,7 @@ var webhookTools = [
|
|
|
32413
32444
|
inputSchema: external_exports3.object({
|
|
32414
32445
|
webhookId: external_exports3.string().describe("The webhook ID to update"),
|
|
32415
32446
|
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")
|
|
32447
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).optional().describe("Updated list of event types to subscribe to (at least one)")
|
|
32417
32448
|
}),
|
|
32418
32449
|
handler: async (input) => {
|
|
32419
32450
|
const body = {};
|
|
@@ -32481,7 +32512,7 @@ var webhookTools = [
|
|
|
32481
32512
|
];
|
|
32482
32513
|
|
|
32483
32514
|
// src/index.ts
|
|
32484
|
-
var version2 = true ? "0.10.
|
|
32515
|
+
var version2 = true ? "0.10.2" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
32485
32516
|
var subcommand = process.argv[2];
|
|
32486
32517
|
if (subcommand === "deploy-acl") {
|
|
32487
32518
|
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.2",
|
|
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": {
|