@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +55 -17
  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));
@@ -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 in PROFILES) {
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 new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
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.string().email().optional().describe("Email address to send the invite to")
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.string().email().optional().describe("Email address to send the invite to"),
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.string().email() }).optional().describe("Account contact email"),
32147
- support: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Support contact email"),
32148
- security: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Security contact email")
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.1" : (await null).createRequire(import.meta.url)("../package.json").version;
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.1",
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": {