@yawlabs/tailscale-mcp 0.9.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 +25 -7
  2. package/dist/index.js +292 -73
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![CI](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 88 tools + 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.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 89 tools + 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
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -107,7 +107,7 @@ That's it. Now ask your agent:
107
107
 
108
108
  ## Too many tools? Subset them.
109
109
 
110
- 88 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
110
+ 89 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
111
111
 
112
112
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
113
113
 
@@ -120,9 +120,9 @@ That's it. Now ask your agent:
120
120
  }
121
121
  ```
122
122
 
123
- - **`minimal`** (19 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
124
- - **`core`** (46 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
125
- - **`full`** (88 tools, default) — everything. Same as omitting the env var.
123
+ - **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
124
+ - **`core`** (47 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
125
+ - **`full`** (89 tools, default) — everything. Same as omitting the env var.
126
126
 
127
127
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
128
128
 
@@ -158,7 +158,7 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
158
158
  The server logs the active filter to stderr on startup:
159
159
 
160
160
  ```
161
- @yawlabs/tailscale-mcp v0.9.1 ready (19 tools, profile=core, readonly)
161
+ @yawlabs/tailscale-mcp v0.9.1 ready (20 tools, profile=minimal, readonly)
162
162
  ```
163
163
 
164
164
  If you don't set any filter, startup prints a tip pointing you at the profiles.
@@ -182,6 +182,23 @@ The server checks for an API key first, then falls back to OAuth. If neither is
182
182
 
183
183
  **Tailnet:** Uses your default tailnet automatically. Set `TAILSCALE_TAILNET` to specify one explicitly.
184
184
 
185
+ ## Reliability and debugging
186
+
187
+ **429 retry (built-in).** API responses with HTTP 429 are retried up to 3 times, honoring the `Retry-After` header (both seconds-integer and HTTP-date forms). Falls back to exponential backoff with jitter, capped at 30s per wait. No env var needed — this is on by default. Workflows like "rotate every key older than 90 days" no longer fail mid-loop on Tailscale's per-tenant rate limits.
188
+
189
+ **`TAILSCALE_DEBUG=1`** — log every HTTP method, URL, status, and elapsed time to stderr. Authorization headers are never logged. Use this when a tool returns an unexpected error and you want to see the actual request that went out. Example:
190
+
191
+ ```
192
+ [tailscale-mcp] GET https://api.tailscale.com/api/v2/tailnet/-/devices
193
+ [tailscale-mcp] <- 200 (148ms)
194
+ ```
195
+
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
+
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
+
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).
201
+
185
202
  ## Resources (4)
186
203
 
187
204
  MCP Resources expose read-only data clients can browse without a tool call.
@@ -205,7 +222,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
205
222
  </details>
206
223
 
207
224
  <details>
208
- <summary><strong>Devices</strong> (16 tools)</summary>
225
+ <summary><strong>Devices</strong> (17 tools)</summary>
209
226
 
210
227
  | Tool | Description |
211
228
  |------|-------------|
@@ -213,6 +230,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
213
230
  | `tailscale_get_device` | Get detailed info for a specific device |
214
231
  | `tailscale_authorize_device` | Authorize a pending device |
215
232
  | `tailscale_deauthorize_device` | Deauthorize a device |
233
+ | `tailscale_set_devices_authorized` | Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
216
234
  | `tailscale_delete_device` | Remove a device from the tailnet |
217
235
  | `tailscale_rename_device` | Rename a device |
218
236
  | `tailscale_expire_device` | Expire a device's key, forcing re-authentication |
package/dist/index.js CHANGED
@@ -30116,20 +30116,31 @@ var StdioServerTransport = class {
30116
30116
  // src/api.ts
30117
30117
  var BASE_URL = "https://api.tailscale.com/api/v2";
30118
30118
  var REQUEST_TIMEOUT_MS = 3e4;
30119
+ var MAX_429_RETRIES = 3;
30120
+ var DEFAULT_429_DELAY_MS = 1e3;
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"]);
30119
30124
  var oauthToken = null;
30120
30125
  var oauthRefreshPromise = null;
30121
30126
  function getAuthConfig() {
30122
30127
  const apiKey = process.env.TAILSCALE_API_KEY;
30123
30128
  const oauthClientId = process.env.TAILSCALE_OAUTH_CLIENT_ID;
30124
30129
  const oauthClientSecret = process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
30125
- if (apiKey) {
30126
- if (apiKey.trim() === "") {
30130
+ if (apiKey !== void 0) {
30131
+ const trimmedKey = apiKey.trim();
30132
+ if (trimmedKey === "") {
30127
30133
  throw new Error("TAILSCALE_API_KEY is set but empty. Provide a valid API key.");
30128
30134
  }
30129
- return { kind: "apiKey", apiKey };
30135
+ return { kind: "apiKey", apiKey: trimmedKey };
30130
30136
  }
30131
- if (oauthClientId && oauthClientSecret) {
30132
- 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 };
30133
30144
  }
30134
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.' : "";
30135
30146
  throw new Error(
@@ -30157,7 +30168,8 @@ async function getOAuthAccessToken(clientId, clientSecret) {
30157
30168
  });
30158
30169
  if (!res.ok) {
30159
30170
  const body = await res.text();
30160
- throw new Error(`OAuth token exchange failed (${res.status}): ${body}`);
30171
+ const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://login.tailscale.com/admin/settings/oauth)." : "";
30172
+ throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
30161
30173
  }
30162
30174
  const data = await res.json();
30163
30175
  oauthToken = {
@@ -30218,6 +30230,77 @@ function formatAuthError(apiBody) {
30218
30230
  }
30219
30231
  return lines.join("\n");
30220
30232
  }
30233
+ function extractErrorMessage(body) {
30234
+ if (!body) return body;
30235
+ const trimmed = body.trim();
30236
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return body;
30237
+ try {
30238
+ const parsed = JSON.parse(trimmed);
30239
+ if (parsed && typeof parsed === "object") {
30240
+ const obj = parsed;
30241
+ if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
30242
+ if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
30243
+ }
30244
+ } catch {
30245
+ }
30246
+ return body;
30247
+ }
30248
+ var inFlight = 0;
30249
+ var concurrencyQueue = [];
30250
+ function getConcurrencyLimit() {
30251
+ const raw = process.env.TAILSCALE_MAX_CONCURRENT;
30252
+ if (!raw) return 0;
30253
+ const n = Number.parseInt(raw, 10);
30254
+ return Number.isFinite(n) && n > 0 ? n : 0;
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
+ }
30262
+ async function withConcurrencyLimit(fn) {
30263
+ const limit = getConcurrencyLimit();
30264
+ if (limit === 0) return fn();
30265
+ if (inFlight >= limit) {
30266
+ await new Promise((resolve) => concurrencyQueue.push(resolve));
30267
+ }
30268
+ inFlight++;
30269
+ try {
30270
+ return await fn();
30271
+ } finally {
30272
+ inFlight--;
30273
+ const next = concurrencyQueue.shift();
30274
+ if (next) next();
30275
+ }
30276
+ }
30277
+ function debugLog(...parts) {
30278
+ if (process.env.TAILSCALE_DEBUG === "1" || process.env.TAILSCALE_DEBUG === "true") {
30279
+ console.error("[tailscale-mcp]", ...parts);
30280
+ }
30281
+ }
30282
+ function compute429DelayMs(retryAfter, attempt) {
30283
+ if (retryAfter) {
30284
+ const asInt = Number.parseInt(retryAfter, 10);
30285
+ if (Number.isFinite(asInt) && asInt >= 0) {
30286
+ return Math.min(asInt * 1e3, MAX_429_DELAY_MS);
30287
+ }
30288
+ const asDate = Date.parse(retryAfter);
30289
+ if (Number.isFinite(asDate)) {
30290
+ return Math.max(0, Math.min(asDate - Date.now(), MAX_429_DELAY_MS));
30291
+ }
30292
+ }
30293
+ const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
30294
+ return base + Math.floor(Math.random() * 250);
30295
+ }
30296
+ async function executeFetch(method, url2, headers, body) {
30297
+ return fetch(url2, {
30298
+ method,
30299
+ headers,
30300
+ body,
30301
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
30302
+ });
30303
+ }
30221
30304
  async function apiRequest(method, path, body, options) {
30222
30305
  const auth = await getAuthHeader();
30223
30306
  const headers = {
@@ -30238,31 +30321,48 @@ async function apiRequest(method, path, body, options) {
30238
30321
  fetchBody = JSON.stringify(body);
30239
30322
  }
30240
30323
  const url2 = path.startsWith("http") ? path : `${BASE_URL}${path}`;
30241
- const res = await fetch(url2, {
30242
- method,
30243
- headers,
30244
- body: fetchBody,
30245
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
30246
- });
30247
- const etag = res.headers.get("etag") || void 0;
30248
- if (options?.acceptRaw) {
30249
- const rawBody = await res.text();
30250
- if (!res.ok) {
30251
- const error48 = res.status === 401 ? formatAuthError(rawBody) : rawBody;
30252
- return { ok: false, status: res.status, error: error48, rawBody, etag };
30324
+ const startedAt = Date.now();
30325
+ debugLog(`${method} ${url2}`);
30326
+ const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
30327
+ const requestBudgetMs = getRequestBudgetMs();
30328
+ return withConcurrencyLimit(async () => {
30329
+ let res;
30330
+ for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
30331
+ res = await executeFetch(method, url2, headers, fetchBody);
30332
+ if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
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
+ }
30339
+ debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
30340
+ await res.text().catch(() => void 0);
30341
+ await new Promise((r) => setTimeout(r, delay));
30253
30342
  }
30254
- return { ok: true, status: res.status, rawBody, etag };
30255
- }
30256
- if (!res.ok) {
30257
- const errorBody = await res.text();
30258
- const error48 = res.status === 401 ? formatAuthError(errorBody) : errorBody;
30259
- return { ok: false, status: res.status, error: error48, etag };
30260
- }
30261
- if (res.status === 204 || res.headers.get("content-length") === "0") {
30262
- return { ok: true, status: res.status, etag };
30263
- }
30264
- const data = await res.json();
30265
- return { ok: true, status: res.status, data, etag };
30343
+ const response = res;
30344
+ const etag = response.headers.get("etag") || void 0;
30345
+ const elapsed = Date.now() - startedAt;
30346
+ debugLog(` <- ${response.status} (${elapsed}ms)`);
30347
+ if (options?.acceptRaw) {
30348
+ const rawBody = await response.text();
30349
+ if (!response.ok) {
30350
+ const error48 = response.status === 401 ? formatAuthError(rawBody) : extractErrorMessage(rawBody);
30351
+ return { ok: false, status: response.status, error: error48, rawBody, etag };
30352
+ }
30353
+ return { ok: true, status: response.status, rawBody, etag };
30354
+ }
30355
+ if (!response.ok) {
30356
+ const errorBody = await response.text();
30357
+ const error48 = response.status === 401 ? formatAuthError(errorBody) : extractErrorMessage(errorBody);
30358
+ return { ok: false, status: response.status, error: error48, etag };
30359
+ }
30360
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
30361
+ return { ok: true, status: response.status, etag };
30362
+ }
30363
+ const data = await response.json();
30364
+ return { ok: true, status: response.status, data, etag };
30365
+ });
30266
30366
  }
30267
30367
  async function apiGet(path, options) {
30268
30368
  return apiRequest("GET", path, void 0, options);
@@ -30270,14 +30370,14 @@ async function apiGet(path, options) {
30270
30370
  async function apiPost(path, body, options) {
30271
30371
  return apiRequest("POST", path, body, options);
30272
30372
  }
30273
- async function apiPut(path, body) {
30274
- return apiRequest("PUT", path, body);
30373
+ async function apiPut(path, body, options) {
30374
+ return apiRequest("PUT", path, body, options);
30275
30375
  }
30276
- async function apiPatch(path, body) {
30277
- return apiRequest("PATCH", path, body);
30376
+ async function apiPatch(path, body, options) {
30377
+ return apiRequest("PATCH", path, body, options);
30278
30378
  }
30279
- async function apiDelete(path) {
30280
- return apiRequest("DELETE", path);
30379
+ async function apiDelete(path, options) {
30380
+ return apiRequest("DELETE", path, void 0, options);
30281
30381
  }
30282
30382
 
30283
30383
  // src/cli.ts
@@ -30332,7 +30432,7 @@ function filterTools(groups, options) {
30332
30432
  let unknownProfile2;
30333
30433
  if (options.profile) {
30334
30434
  const profileKey = options.profile.trim().toLowerCase();
30335
- if (profileKey in PROFILES) {
30435
+ if (Object.hasOwn(PROFILES, profileKey)) {
30336
30436
  const preset = PROFILES[profileKey];
30337
30437
  profileGroups = preset.length > 0 ? [...preset] : void 0;
30338
30438
  } else {
@@ -30378,14 +30478,14 @@ var aclTools = [
30378
30478
  accept: "application/hujson"
30379
30479
  });
30380
30480
  if (res.ok && res.etag) {
30381
- return {
30382
- ...res,
30383
- rawBody: `${res.rawBody}
30384
-
30385
- ---
30386
- ETag: ${res.etag}
30387
- Pass this ETag to tailscale_update_acl when updating the policy.`
30388
- };
30481
+ const footer = [
30482
+ "",
30483
+ `// ETag: ${res.etag}`,
30484
+ "// Pass this ETag to tailscale_update_acl when updating the policy.",
30485
+ "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
30486
+ ""
30487
+ ].join("\n");
30488
+ return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
30389
30489
  }
30390
30490
  return res;
30391
30491
  }
@@ -30472,8 +30572,28 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
30472
30572
  // src/tools/audit.ts
30473
30573
  function assertRFC3339(value, label) {
30474
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}'`);
30475
30576
  if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
30476
- 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();
30584
+ }
30585
+ }
30586
+ var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
30587
+ function assertLogRange(start, end, label) {
30588
+ const startMs = Date.parse(start);
30589
+ const endMs = end ? Date.parse(end) : Date.now();
30590
+ if (endMs < startMs) {
30591
+ throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
30592
+ }
30593
+ if (endMs - startMs > MAX_LOG_RANGE_MS) {
30594
+ throw new Error(
30595
+ `${label}: range exceeds the 30-day Tailscale API limit. start=${start} end=${end ?? "<now>"}. Split the query into <=30-day windows.`
30596
+ );
30477
30597
  }
30478
30598
  }
30479
30599
  var auditTools = [
@@ -30494,6 +30614,7 @@ var auditTools = [
30494
30614
  handler: async (input) => {
30495
30615
  assertRFC3339(input.start, "start");
30496
30616
  if (input.end) assertRFC3339(input.end, "end");
30617
+ assertLogRange(input.start, input.end, "tailscale_get_audit_log");
30497
30618
  const params = new URLSearchParams({ start: input.start });
30498
30619
  if (input.end) params.set("end", input.end);
30499
30620
  return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
@@ -30516,6 +30637,7 @@ var auditTools = [
30516
30637
  handler: async (input) => {
30517
30638
  assertRFC3339(input.start, "start");
30518
30639
  if (input.end) assertRFC3339(input.end, "end");
30640
+ assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs");
30519
30641
  const params = new URLSearchParams({ start: input.start });
30520
30642
  if (input.end) params.set("end", input.end);
30521
30643
  return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
@@ -30687,7 +30809,16 @@ var deviceTools = [
30687
30809
  },
30688
30810
  inputSchema: external_exports3.object({
30689
30811
  deviceId: external_exports3.string().describe("The device ID"),
30690
- routes: external_exports3.array(external_exports3.string()).describe(
30812
+ routes: external_exports3.array(
30813
+ external_exports3.string().refine(
30814
+ // Accept v4 (10.0.0.0/24) or v6 (fd7a:115c::/48) CIDRs. Routes can be either.
30815
+ // Loose check: must contain a '/' followed by 1-3 digits, and the address part
30816
+ // must look like an IPv4 quad-dotted or an IPv6 colon-form. The Tailscale API
30817
+ // is the authoritative validator; this just rejects obvious typos client-side.
30818
+ (s) => /^([\d.]+|[\da-fA-F:]+)\/\d{1,3}$/.test(s),
30819
+ { message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" }
30820
+ )
30821
+ ).describe(
30691
30822
  "Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
30692
30823
  )
30693
30824
  }),
@@ -30791,7 +30922,7 @@ var deviceTools = [
30791
30922
  },
30792
30923
  inputSchema: external_exports3.object({
30793
30924
  deviceId: external_exports3.string().describe("The device ID"),
30794
- ipv4: external_exports3.string().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
30925
+ ipv4: external_exports3.string().ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
30795
30926
  }),
30796
30927
  handler: async (input) => {
30797
30928
  return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
@@ -30817,6 +30948,48 @@ var deviceTools = [
30817
30948
  });
30818
30949
  }
30819
30950
  },
30951
+ {
30952
+ name: "tailscale_set_devices_authorized",
30953
+ 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.",
30954
+ annotations: {
30955
+ title: "Set devices authorized (bulk)",
30956
+ readOnlyHint: false,
30957
+ // Deauthorize is destructive; authorize is not. Mark destructive so MCP
30958
+ // clients gate the bulk call the safer way.
30959
+ destructiveHint: true,
30960
+ idempotentHint: true,
30961
+ openWorldHint: true
30962
+ },
30963
+ inputSchema: external_exports3.object({
30964
+ deviceIds: external_exports3.array(external_exports3.string().min(1)).min(1).describe("Device IDs to update"),
30965
+ authorized: external_exports3.boolean().describe("true to authorize, false to deauthorize")
30966
+ }),
30967
+ handler: async (input) => {
30968
+ const unique = [...new Set(input.deviceIds)];
30969
+ const results = await Promise.all(
30970
+ unique.map(async (deviceId) => {
30971
+ const res = await apiPost(`/device/${encPath(deviceId)}/authorized`, { authorized: input.authorized });
30972
+ return { deviceId, res };
30973
+ })
30974
+ );
30975
+ const succeeded = [];
30976
+ const failed = {};
30977
+ for (const { deviceId, res } of results) {
30978
+ if (res.ok) succeeded.push(deviceId);
30979
+ else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
30980
+ }
30981
+ const failedCount = Object.keys(failed).length;
30982
+ if (failedCount === unique.length) {
30983
+ const first = Object.values(failed)[0];
30984
+ return {
30985
+ ok: false,
30986
+ status: first.status,
30987
+ error: `All ${failedCount} device updates failed: ${JSON.stringify(failed)}`
30988
+ };
30989
+ }
30990
+ return { ok: true, status: 200, data: { authorized: input.authorized, succeeded, failed } };
30991
+ }
30992
+ },
30820
30993
  {
30821
30994
  name: "tailscale_batch_update_posture_attributes",
30822
30995
  description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics \u2014 pass null as the attribute config to delete.",
@@ -31055,6 +31228,9 @@ var dnsTools = [
31055
31228
  for (const [key, value] of Object.entries(input)) {
31056
31229
  if (value !== void 0) body[key] = value;
31057
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
+ }
31058
31234
  return apiPost(`/tailnet/${getTailnet()}/dns/configuration`, body);
31059
31235
  }
31060
31236
  }
@@ -31094,7 +31270,7 @@ var inviteTools = [
31094
31270
  deviceId: external_exports3.string().describe("The device ID to create an invite for"),
31095
31271
  multiUse: external_exports3.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
31096
31272
  allowExitNode: external_exports3.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
31097
- email: external_exports3.string().optional().describe("Email address to send the invite to")
31273
+ email: external_exports3.string().email().optional().describe("Email address to send the invite to")
31098
31274
  }),
31099
31275
  handler: async (input) => {
31100
31276
  const body = {};
@@ -31182,7 +31358,7 @@ var inviteTools = [
31182
31358
  openWorldHint: true
31183
31359
  },
31184
31360
  inputSchema: external_exports3.object({
31185
- email: external_exports3.string().optional().describe("Email address to send the invite to"),
31361
+ email: external_exports3.string().email().optional().describe("Email address to send the invite to"),
31186
31362
  role: external_exports3.enum(["member", "admin", "it-admin", "network-admin", "billing-admin", "auditor"]).optional().describe("Role to assign to the invited user (default: member)")
31187
31363
  }),
31188
31364
  handler: async (input) => {
@@ -31233,7 +31409,8 @@ var inviteTools = [
31233
31409
  title: "Resend device invite",
31234
31410
  readOnlyHint: false,
31235
31411
  destructiveHint: false,
31236
- idempotentHint: true,
31412
+ // Each call delivers a separate email to the recipient.
31413
+ idempotentHint: false,
31237
31414
  openWorldHint: true
31238
31415
  },
31239
31416
  inputSchema: external_exports3.object({
@@ -31250,7 +31427,8 @@ var inviteTools = [
31250
31427
  title: "Resend user invite",
31251
31428
  readOnlyHint: false,
31252
31429
  destructiveHint: false,
31253
- idempotentHint: true,
31430
+ // Each call delivers a separate email to the recipient.
31431
+ idempotentHint: false,
31254
31432
  openWorldHint: true
31255
31433
  },
31256
31434
  inputSchema: external_exports3.object({
@@ -31343,7 +31521,10 @@ var keyTools = [
31343
31521
  }
31344
31522
  const body = {};
31345
31523
  if (keyType !== "auth") body.keyType = keyType;
31346
- if (input.description !== void 0) body.description = sanitizeDescription(input.description);
31524
+ if (input.description !== void 0) {
31525
+ const sanitized = sanitizeDescription(input.description);
31526
+ if (sanitized.length > 0) body.description = sanitized;
31527
+ }
31347
31528
  if (keyType === "auth") {
31348
31529
  body.capabilities = {
31349
31530
  devices: {
@@ -31414,7 +31595,10 @@ var keyTools = [
31414
31595
  handler: async (input) => {
31415
31596
  validateTags(input.tags);
31416
31597
  const body = {};
31417
- if (input.description !== void 0) body.description = sanitizeDescription(input.description);
31598
+ if (input.description !== void 0) {
31599
+ const sanitized = sanitizeDescription(input.description);
31600
+ if (sanitized.length > 0) body.description = sanitized;
31601
+ }
31418
31602
  if (input.scopes !== void 0) body.scopes = input.scopes;
31419
31603
  if (input.tags !== void 0) body.tags = input.tags;
31420
31604
  if (input.issuer !== void 0) body.issuer = input.issuer;
@@ -31498,7 +31682,7 @@ var logStreamingTools = [
31498
31682
  url: external_exports3.string().optional().describe("Destination URL (required for non-s3 destinations)"),
31499
31683
  token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
31500
31684
  user: external_exports3.string().optional().describe("Username for the destination (if required)"),
31501
- uploadPeriodMinutes: external_exports3.number().optional().describe("Minutes to wait between uploads (max 1440). Optional."),
31685
+ uploadPeriodMinutes: external_exports3.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
31502
31686
  compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
31503
31687
  s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
31504
31688
  s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
@@ -31513,6 +31697,30 @@ var logStreamingTools = [
31513
31697
  )
31514
31698
  }),
31515
31699
  handler: async (input) => {
31700
+ if (input.destinationType === "s3") {
31701
+ const missing = [];
31702
+ if (!input.s3Bucket) missing.push("s3Bucket");
31703
+ if (!input.s3Region) missing.push("s3Region");
31704
+ if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
31705
+ if (input.s3AuthenticationType === "accesskey") {
31706
+ if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
31707
+ if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
31708
+ } else if (input.s3AuthenticationType === "rolearn") {
31709
+ if (!input.s3RoleArn) missing.push("s3RoleArn");
31710
+ }
31711
+ if (missing.length > 0) {
31712
+ throw new Error(
31713
+ `destinationType 's3' requires: ${missing.join(", ")}. For 'rolearn' auth, call tailscale_create_aws_external_id first to get the external ID for your IAM role trust policy.`
31714
+ );
31715
+ }
31716
+ } else {
31717
+ const missing = [];
31718
+ if (!input.url) missing.push("url");
31719
+ if (!input.token) missing.push("token");
31720
+ if (missing.length > 0) {
31721
+ throw new Error(`destinationType '${input.destinationType}' requires: ${missing.join(", ")}.`);
31722
+ }
31723
+ }
31516
31724
  const { logType, ...body } = input;
31517
31725
  const cleanBody = {};
31518
31726
  for (const [key, value] of Object.entries(body)) {
@@ -31711,7 +31919,7 @@ var postureTools = [
31711
31919
  var serviceTools = [
31712
31920
  {
31713
31921
  name: "tailscale_list_services",
31714
- description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices.",
31922
+ description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note: services are created implicitly when a node first advertises one (`tailscale up --advertise-services=svc:name`); there is no API endpoint to create a service from this MCP. Use the update/delete/approval tools here once the service exists.",
31715
31923
  annotations: {
31716
31924
  title: "List services",
31717
31925
  readOnlyHint: true,
@@ -31957,7 +32165,7 @@ var tailnetTools = [
31957
32165
  },
31958
32166
  {
31959
32167
  name: "tailscale_set_contacts",
31960
- 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.",
31961
32169
  annotations: {
31962
32170
  title: "Set contacts",
31963
32171
  readOnlyHint: false,
@@ -31966,17 +32174,24 @@ var tailnetTools = [
31966
32174
  openWorldHint: true
31967
32175
  },
31968
32176
  inputSchema: external_exports3.object({
31969
- account: external_exports3.object({ email: external_exports3.string() }).optional().describe("Account contact email"),
31970
- support: external_exports3.object({ email: external_exports3.string() }).optional().describe("Support contact email"),
31971
- security: external_exports3.object({ email: external_exports3.string() }).optional().describe("Security contact email")
32177
+ account: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Account contact email"),
32178
+ support: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Support contact email"),
32179
+ security: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Security contact email")
31972
32180
  }),
31973
32181
  handler: async (input) => {
32182
+ const types = ["account", "support", "security"].filter((t) => input[t] !== void 0);
32183
+ const results = await Promise.all(
32184
+ types.map(async (contactType) => {
32185
+ const res = await apiPatch(
32186
+ `/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`,
32187
+ input[contactType]
32188
+ );
32189
+ return { contactType, res };
32190
+ })
32191
+ );
31974
32192
  const applied = {};
31975
32193
  const failed = {};
31976
- for (const contactType of ["account", "support", "security"]) {
31977
- const value = input[contactType];
31978
- if (value === void 0) continue;
31979
- const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32194
+ for (const { contactType, res } of results) {
31980
32195
  if (res.ok) applied[contactType] = res.data;
31981
32196
  else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
31982
32197
  }
@@ -31999,7 +32214,8 @@ var tailnetTools = [
31999
32214
  title: "Resend contact verification",
32000
32215
  readOnlyHint: false,
32001
32216
  destructiveHint: false,
32002
- idempotentHint: true,
32217
+ // Each call sends a separate verification email.
32218
+ idempotentHint: false,
32003
32219
  openWorldHint: true
32004
32220
  },
32005
32221
  inputSchema: external_exports3.object({
@@ -32205,8 +32421,8 @@ var webhookTools = [
32205
32421
  openWorldHint: true
32206
32422
  },
32207
32423
  inputSchema: external_exports3.object({
32208
- endpointUrl: external_exports3.string().describe("The URL to send webhook events to"),
32209
- subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
32424
+ endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
32425
+ subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).describe("Event types to subscribe to (at least one)")
32210
32426
  }),
32211
32427
  handler: async (input) => {
32212
32428
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -32227,8 +32443,8 @@ var webhookTools = [
32227
32443
  },
32228
32444
  inputSchema: external_exports3.object({
32229
32445
  webhookId: external_exports3.string().describe("The webhook ID to update"),
32230
- endpointUrl: external_exports3.string().optional().describe("New URL to send webhook events to"),
32231
- subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
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"),
32447
+ subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).optional().describe("Updated list of event types to subscribe to (at least one)")
32232
32448
  }),
32233
32449
  handler: async (input) => {
32234
32450
  const body = {};
@@ -32281,7 +32497,9 @@ var webhookTools = [
32281
32497
  title: "Test webhook",
32282
32498
  readOnlyHint: false,
32283
32499
  destructiveHint: false,
32284
- idempotentHint: true,
32500
+ // Each invocation delivers a separate test event to the endpoint —
32501
+ // not idempotent in the strict sense.
32502
+ idempotentHint: false,
32285
32503
  openWorldHint: true
32286
32504
  },
32287
32505
  inputSchema: external_exports3.object({
@@ -32294,7 +32512,7 @@ var webhookTools = [
32294
32512
  ];
32295
32513
 
32296
32514
  // src/index.ts
32297
- var version2 = true ? "0.9.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;
32298
32516
  var subcommand = process.argv[2];
32299
32517
  if (subcommand === "deploy-acl") {
32300
32518
  const filePath = process.argv[3];
@@ -32468,9 +32686,10 @@ var filterSuffix = [
32468
32686
  console.error(
32469
32687
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
32470
32688
  );
32471
- if (!filterSuffix) {
32689
+ var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
32690
+ if (!filterSuffix && hasCreds) {
32472
32691
  console.error(
32473
- "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (46 tools) or =minimal (19) to load a smaller tool surface. See README."
32692
+ "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (47 tools) or =minimal (20) to load a smaller tool surface. See README."
32474
32693
  );
32475
32694
  }
32476
32695
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.9.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": {