@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +43 -12
  3. 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
- if (apiKey.trim() === "") {
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 && oauthClientSecret) {
30135
- return { kind: "oauth", clientId: oauthClientId, clientSecret: oauthClientSecret };
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 in PROFILES) {
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 new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
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.1" : (await null).createRequire(import.meta.url)("../package.json").version;
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.1",
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": {