@yawlabs/tailscale-mcp 0.9.0 → 0.10.1

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 +23 -7
  2. package/dist/index.js +274 -73
  3. package/package.json +1 -1
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.0 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,21 @@ 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
+ **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
+
185
200
  ## Resources (4)
186
201
 
187
202
  MCP Resources expose read-only data clients can browse without a tool call.
@@ -205,7 +220,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
205
220
  </details>
206
221
 
207
222
  <details>
208
- <summary><strong>Devices</strong> (16 tools)</summary>
223
+ <summary><strong>Devices</strong> (17 tools)</summary>
209
224
 
210
225
  | Tool | Description |
211
226
  |------|-------------|
@@ -213,6 +228,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
213
228
  | `tailscale_get_device` | Get detailed info for a specific device |
214
229
  | `tailscale_authorize_device` | Authorize a pending device |
215
230
  | `tailscale_deauthorize_device` | Deauthorize a device |
231
+ | `tailscale_set_devices_authorized` | Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
216
232
  | `tailscale_delete_device` | Remove a device from the tailnet |
217
233
  | `tailscale_rename_device` | Rename a device |
218
234
  | `tailscale_expire_device` | Expire a device's key, forcing re-authentication |
package/dist/index.js CHANGED
@@ -30116,6 +30116,9 @@ 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;
30119
30122
  var oauthToken = null;
30120
30123
  var oauthRefreshPromise = null;
30121
30124
  function getAuthConfig() {
@@ -30157,7 +30160,8 @@ async function getOAuthAccessToken(clientId, clientSecret) {
30157
30160
  });
30158
30161
  if (!res.ok) {
30159
30162
  const body = await res.text();
30160
- throw new Error(`OAuth token exchange failed (${res.status}): ${body}`);
30163
+ 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)." : "";
30164
+ throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
30161
30165
  }
30162
30166
  const data = await res.json();
30163
30167
  oauthToken = {
@@ -30218,6 +30222,71 @@ function formatAuthError(apiBody) {
30218
30222
  }
30219
30223
  return lines.join("\n");
30220
30224
  }
30225
+ function extractErrorMessage(body) {
30226
+ if (!body) return body;
30227
+ const trimmed = body.trim();
30228
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return body;
30229
+ try {
30230
+ const parsed = JSON.parse(trimmed);
30231
+ if (parsed && typeof parsed === "object") {
30232
+ const obj = parsed;
30233
+ if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
30234
+ if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
30235
+ }
30236
+ } catch {
30237
+ }
30238
+ return body;
30239
+ }
30240
+ var inFlight = 0;
30241
+ var concurrencyQueue = [];
30242
+ function getConcurrencyLimit() {
30243
+ const raw = process.env.TAILSCALE_MAX_CONCURRENT;
30244
+ if (!raw) return 0;
30245
+ const n = Number.parseInt(raw, 10);
30246
+ return Number.isFinite(n) && n > 0 ? n : 0;
30247
+ }
30248
+ async function withConcurrencyLimit(fn) {
30249
+ const limit = getConcurrencyLimit();
30250
+ if (limit === 0) return fn();
30251
+ if (inFlight >= limit) {
30252
+ await new Promise((resolve) => concurrencyQueue.push(resolve));
30253
+ }
30254
+ inFlight++;
30255
+ try {
30256
+ return await fn();
30257
+ } finally {
30258
+ inFlight--;
30259
+ const next = concurrencyQueue.shift();
30260
+ if (next) next();
30261
+ }
30262
+ }
30263
+ function debugLog(...parts) {
30264
+ if (process.env.TAILSCALE_DEBUG === "1" || process.env.TAILSCALE_DEBUG === "true") {
30265
+ console.error("[tailscale-mcp]", ...parts);
30266
+ }
30267
+ }
30268
+ function compute429DelayMs(retryAfter, attempt) {
30269
+ if (retryAfter) {
30270
+ const asInt = Number.parseInt(retryAfter, 10);
30271
+ if (Number.isFinite(asInt) && asInt >= 0) {
30272
+ return Math.min(asInt * 1e3, MAX_429_DELAY_MS);
30273
+ }
30274
+ const asDate = Date.parse(retryAfter);
30275
+ if (Number.isFinite(asDate)) {
30276
+ return Math.max(0, Math.min(asDate - Date.now(), MAX_429_DELAY_MS));
30277
+ }
30278
+ }
30279
+ const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
30280
+ return base + Math.floor(Math.random() * 250);
30281
+ }
30282
+ async function executeFetch(method, url2, headers, body) {
30283
+ return fetch(url2, {
30284
+ method,
30285
+ headers,
30286
+ body,
30287
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
30288
+ });
30289
+ }
30221
30290
  async function apiRequest(method, path, body, options) {
30222
30291
  const auth = await getAuthHeader();
30223
30292
  const headers = {
@@ -30238,31 +30307,41 @@ async function apiRequest(method, path, body, options) {
30238
30307
  fetchBody = JSON.stringify(body);
30239
30308
  }
30240
30309
  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)
30310
+ const startedAt = Date.now();
30311
+ debugLog(`${method} ${url2}`);
30312
+ return withConcurrencyLimit(async () => {
30313
+ let res;
30314
+ for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
30315
+ res = await executeFetch(method, url2, headers, fetchBody);
30316
+ if (res.status !== 429 || attempt === MAX_429_RETRIES) break;
30317
+ const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
30318
+ debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
30319
+ await res.text().catch(() => void 0);
30320
+ await new Promise((r) => setTimeout(r, delay));
30321
+ }
30322
+ const response = res;
30323
+ const etag = response.headers.get("etag") || void 0;
30324
+ const elapsed = Date.now() - startedAt;
30325
+ debugLog(` <- ${response.status} (${elapsed}ms)`);
30326
+ if (options?.acceptRaw) {
30327
+ const rawBody = await response.text();
30328
+ if (!response.ok) {
30329
+ const error48 = response.status === 401 ? formatAuthError(rawBody) : extractErrorMessage(rawBody);
30330
+ return { ok: false, status: response.status, error: error48, rawBody, etag };
30331
+ }
30332
+ return { ok: true, status: response.status, rawBody, etag };
30333
+ }
30334
+ if (!response.ok) {
30335
+ const errorBody = await response.text();
30336
+ const error48 = response.status === 401 ? formatAuthError(errorBody) : extractErrorMessage(errorBody);
30337
+ return { ok: false, status: response.status, error: error48, etag };
30338
+ }
30339
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
30340
+ return { ok: true, status: response.status, etag };
30341
+ }
30342
+ const data = await response.json();
30343
+ return { ok: true, status: response.status, data, etag };
30246
30344
  });
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 };
30253
- }
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 };
30266
30345
  }
30267
30346
  async function apiGet(path, options) {
30268
30347
  return apiRequest("GET", path, void 0, options);
@@ -30270,14 +30349,14 @@ async function apiGet(path, options) {
30270
30349
  async function apiPost(path, body, options) {
30271
30350
  return apiRequest("POST", path, body, options);
30272
30351
  }
30273
- async function apiPut(path, body) {
30274
- return apiRequest("PUT", path, body);
30352
+ async function apiPut(path, body, options) {
30353
+ return apiRequest("PUT", path, body, options);
30275
30354
  }
30276
- async function apiPatch(path, body) {
30277
- return apiRequest("PATCH", path, body);
30355
+ async function apiPatch(path, body, options) {
30356
+ return apiRequest("PATCH", path, body, options);
30278
30357
  }
30279
- async function apiDelete(path) {
30280
- return apiRequest("DELETE", path);
30358
+ async function apiDelete(path, options) {
30359
+ return apiRequest("DELETE", path, void 0, options);
30281
30360
  }
30282
30361
 
30283
30362
  // src/cli.ts
@@ -30378,14 +30457,14 @@ var aclTools = [
30378
30457
  accept: "application/hujson"
30379
30458
  });
30380
30459
  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
- };
30460
+ const footer = [
30461
+ "",
30462
+ `// ETag: ${res.etag}`,
30463
+ "// Pass this ETag to tailscale_update_acl when updating the policy.",
30464
+ "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
30465
+ ""
30466
+ ].join("\n");
30467
+ return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
30389
30468
  }
30390
30469
  return res;
30391
30470
  }
@@ -30476,6 +30555,19 @@ function assertRFC3339(value, label) {
30476
30555
  throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
30477
30556
  }
30478
30557
  }
30558
+ var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
30559
+ function assertLogRange(start, end, label) {
30560
+ const startMs = Date.parse(start);
30561
+ const endMs = end ? Date.parse(end) : Date.now();
30562
+ if (endMs < startMs) {
30563
+ throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
30564
+ }
30565
+ if (endMs - startMs > MAX_LOG_RANGE_MS) {
30566
+ throw new Error(
30567
+ `${label}: range exceeds the 30-day Tailscale API limit. start=${start} end=${end ?? "<now>"}. Split the query into <=30-day windows.`
30568
+ );
30569
+ }
30570
+ }
30479
30571
  var auditTools = [
30480
30572
  {
30481
30573
  name: "tailscale_get_audit_log",
@@ -30494,6 +30586,7 @@ var auditTools = [
30494
30586
  handler: async (input) => {
30495
30587
  assertRFC3339(input.start, "start");
30496
30588
  if (input.end) assertRFC3339(input.end, "end");
30589
+ assertLogRange(input.start, input.end, "tailscale_get_audit_log");
30497
30590
  const params = new URLSearchParams({ start: input.start });
30498
30591
  if (input.end) params.set("end", input.end);
30499
30592
  return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
@@ -30516,6 +30609,7 @@ var auditTools = [
30516
30609
  handler: async (input) => {
30517
30610
  assertRFC3339(input.start, "start");
30518
30611
  if (input.end) assertRFC3339(input.end, "end");
30612
+ assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs");
30519
30613
  const params = new URLSearchParams({ start: input.start });
30520
30614
  if (input.end) params.set("end", input.end);
30521
30615
  return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
@@ -30687,7 +30781,16 @@ var deviceTools = [
30687
30781
  },
30688
30782
  inputSchema: external_exports3.object({
30689
30783
  deviceId: external_exports3.string().describe("The device ID"),
30690
- routes: external_exports3.array(external_exports3.string()).describe(
30784
+ routes: external_exports3.array(
30785
+ external_exports3.string().refine(
30786
+ // Accept v4 (10.0.0.0/24) or v6 (fd7a:115c::/48) CIDRs. Routes can be either.
30787
+ // Loose check: must contain a '/' followed by 1-3 digits, and the address part
30788
+ // must look like an IPv4 quad-dotted or an IPv6 colon-form. The Tailscale API
30789
+ // is the authoritative validator; this just rejects obvious typos client-side.
30790
+ (s) => /^([\d.]+|[\da-fA-F:]+)\/\d{1,3}$/.test(s),
30791
+ { message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" }
30792
+ )
30793
+ ).describe(
30691
30794
  "Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
30692
30795
  )
30693
30796
  }),
@@ -30791,7 +30894,7 @@ var deviceTools = [
30791
30894
  },
30792
30895
  inputSchema: external_exports3.object({
30793
30896
  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')")
30897
+ ipv4: external_exports3.string().ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
30795
30898
  }),
30796
30899
  handler: async (input) => {
30797
30900
  return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
@@ -30817,6 +30920,48 @@ var deviceTools = [
30817
30920
  });
30818
30921
  }
30819
30922
  },
30923
+ {
30924
+ name: "tailscale_set_devices_authorized",
30925
+ 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.",
30926
+ annotations: {
30927
+ title: "Set devices authorized (bulk)",
30928
+ readOnlyHint: false,
30929
+ // Deauthorize is destructive; authorize is not. Mark destructive so MCP
30930
+ // clients gate the bulk call the safer way.
30931
+ destructiveHint: true,
30932
+ idempotentHint: true,
30933
+ openWorldHint: true
30934
+ },
30935
+ inputSchema: external_exports3.object({
30936
+ deviceIds: external_exports3.array(external_exports3.string().min(1)).min(1).describe("Device IDs to update"),
30937
+ authorized: external_exports3.boolean().describe("true to authorize, false to deauthorize")
30938
+ }),
30939
+ handler: async (input) => {
30940
+ const unique = [...new Set(input.deviceIds)];
30941
+ const results = await Promise.all(
30942
+ unique.map(async (deviceId) => {
30943
+ const res = await apiPost(`/device/${encPath(deviceId)}/authorized`, { authorized: input.authorized });
30944
+ return { deviceId, res };
30945
+ })
30946
+ );
30947
+ const succeeded = [];
30948
+ const failed = {};
30949
+ for (const { deviceId, res } of results) {
30950
+ if (res.ok) succeeded.push(deviceId);
30951
+ else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
30952
+ }
30953
+ const failedCount = Object.keys(failed).length;
30954
+ if (failedCount === unique.length) {
30955
+ const first = Object.values(failed)[0];
30956
+ return {
30957
+ ok: false,
30958
+ status: first.status,
30959
+ error: `All ${failedCount} device updates failed: ${JSON.stringify(failed)}`
30960
+ };
30961
+ }
30962
+ return { ok: true, status: 200, data: { authorized: input.authorized, succeeded, failed } };
30963
+ }
30964
+ },
30820
30965
  {
30821
30966
  name: "tailscale_batch_update_posture_attributes",
30822
30967
  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.",
@@ -31094,7 +31239,7 @@ var inviteTools = [
31094
31239
  deviceId: external_exports3.string().describe("The device ID to create an invite for"),
31095
31240
  multiUse: external_exports3.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
31096
31241
  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")
31242
+ email: external_exports3.string().email().optional().describe("Email address to send the invite to")
31098
31243
  }),
31099
31244
  handler: async (input) => {
31100
31245
  const body = {};
@@ -31182,7 +31327,7 @@ var inviteTools = [
31182
31327
  openWorldHint: true
31183
31328
  },
31184
31329
  inputSchema: external_exports3.object({
31185
- email: external_exports3.string().optional().describe("Email address to send the invite to"),
31330
+ email: external_exports3.string().email().optional().describe("Email address to send the invite to"),
31186
31331
  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
31332
  }),
31188
31333
  handler: async (input) => {
@@ -31233,7 +31378,8 @@ var inviteTools = [
31233
31378
  title: "Resend device invite",
31234
31379
  readOnlyHint: false,
31235
31380
  destructiveHint: false,
31236
- idempotentHint: true,
31381
+ // Each call delivers a separate email to the recipient.
31382
+ idempotentHint: false,
31237
31383
  openWorldHint: true
31238
31384
  },
31239
31385
  inputSchema: external_exports3.object({
@@ -31250,7 +31396,8 @@ var inviteTools = [
31250
31396
  title: "Resend user invite",
31251
31397
  readOnlyHint: false,
31252
31398
  destructiveHint: false,
31253
- idempotentHint: true,
31399
+ // Each call delivers a separate email to the recipient.
31400
+ idempotentHint: false,
31254
31401
  openWorldHint: true
31255
31402
  },
31256
31403
  inputSchema: external_exports3.object({
@@ -31284,16 +31431,16 @@ var keyTools = [
31284
31431
  },
31285
31432
  {
31286
31433
  name: "tailscale_get_key",
31287
- description: "Get details for a specific auth key.",
31434
+ description: "Get details for a specific key (auth key, OAuth client, or federated identity).",
31288
31435
  annotations: {
31289
- title: "Get auth key",
31436
+ title: "Get key",
31290
31437
  readOnlyHint: true,
31291
31438
  destructiveHint: false,
31292
31439
  idempotentHint: true,
31293
31440
  openWorldHint: true
31294
31441
  },
31295
31442
  inputSchema: external_exports3.object({
31296
- keyId: external_exports3.string().describe("The auth key ID")
31443
+ keyId: external_exports3.string().describe("The key ID (auth key, OAuth client, or federated identity)")
31297
31444
  }),
31298
31445
  handler: async (input) => {
31299
31446
  return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -31343,7 +31490,10 @@ var keyTools = [
31343
31490
  }
31344
31491
  const body = {};
31345
31492
  if (keyType !== "auth") body.keyType = keyType;
31346
- if (input.description !== void 0) body.description = sanitizeDescription(input.description);
31493
+ if (input.description !== void 0) {
31494
+ const sanitized = sanitizeDescription(input.description);
31495
+ if (sanitized.length > 0) body.description = sanitized;
31496
+ }
31347
31497
  if (keyType === "auth") {
31348
31498
  body.capabilities = {
31349
31499
  devices: {
@@ -31376,16 +31526,16 @@ var keyTools = [
31376
31526
  },
31377
31527
  {
31378
31528
  name: "tailscale_delete_key",
31379
- description: "Delete an auth key. This is irreversible \u2014 devices already authenticated with this key are unaffected, but no new devices can use it.",
31529
+ description: "Delete a key (auth key, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately.",
31380
31530
  annotations: {
31381
- title: "Delete auth key",
31531
+ title: "Delete key",
31382
31532
  readOnlyHint: false,
31383
31533
  destructiveHint: true,
31384
31534
  idempotentHint: true,
31385
31535
  openWorldHint: true
31386
31536
  },
31387
31537
  inputSchema: external_exports3.object({
31388
- keyId: external_exports3.string().describe("The auth key ID to delete")
31538
+ keyId: external_exports3.string().describe("The key ID to delete (auth key, OAuth client, or federated identity)")
31389
31539
  }),
31390
31540
  handler: async (input) => {
31391
31541
  return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -31414,7 +31564,10 @@ var keyTools = [
31414
31564
  handler: async (input) => {
31415
31565
  validateTags(input.tags);
31416
31566
  const body = {};
31417
- if (input.description !== void 0) body.description = sanitizeDescription(input.description);
31567
+ if (input.description !== void 0) {
31568
+ const sanitized = sanitizeDescription(input.description);
31569
+ if (sanitized.length > 0) body.description = sanitized;
31570
+ }
31418
31571
  if (input.scopes !== void 0) body.scopes = input.scopes;
31419
31572
  if (input.tags !== void 0) body.tags = input.tags;
31420
31573
  if (input.issuer !== void 0) body.issuer = input.issuer;
@@ -31433,7 +31586,7 @@ var keyTools = [
31433
31586
  var logStreamingTools = [
31434
31587
  {
31435
31588
  name: "tailscale_list_log_stream_configs",
31436
- description: "List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, S3, or GCS.",
31589
+ description: "List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, or S3.",
31437
31590
  annotations: {
31438
31591
  title: "List log stream configs",
31439
31592
  readOnlyHint: true,
@@ -31484,7 +31637,7 @@ var logStreamingTools = [
31484
31637
  },
31485
31638
  {
31486
31639
  name: "tailscale_set_log_stream_config",
31487
- description: "Set the log streaming configuration for a specific log type. Configures where logs are sent (e.g. Axiom, Datadog, Splunk, Elasticsearch, S3, GCS).",
31640
+ description: "Set the log streaming configuration for a specific log type. Configures where logs are sent (e.g. Axiom, Datadog, Splunk, Elasticsearch, S3).\n\nPer-destination required fields:\n- splunk / elastic / panther / cribl / datadog / axiom: url + token (user optional)\n- s3: s3Bucket + s3Region + s3AuthenticationType, plus either (s3AccessKeyId + s3SecretAccessKey) for 'accesskey' auth or s3RoleArn for 'rolearn' auth. Call tailscale_create_aws_external_id first when using 'rolearn'.",
31488
31641
  annotations: {
31489
31642
  title: "Set log stream config",
31490
31643
  readOnlyHint: false,
@@ -31494,12 +31647,49 @@ var logStreamingTools = [
31494
31647
  },
31495
31648
  inputSchema: external_exports3.object({
31496
31649
  logType: external_exports3.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
31497
- destinationType: external_exports3.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3", "gcs"]).describe("The log streaming destination type"),
31498
- url: external_exports3.string().optional().describe("Destination URL (required for most destination types)"),
31650
+ destinationType: external_exports3.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
31651
+ url: external_exports3.string().optional().describe("Destination URL (required for non-s3 destinations)"),
31499
31652
  token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
31500
- user: external_exports3.string().optional().describe("Username for the destination (if required)")
31653
+ user: external_exports3.string().optional().describe("Username for the destination (if required)"),
31654
+ uploadPeriodMinutes: external_exports3.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
31655
+ compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
31656
+ s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
31657
+ s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
31658
+ s3KeyPrefix: external_exports3.string().optional().describe("(s3 only) Optional prefix prepended to the auto-generated S3 object key."),
31659
+ s3AuthenticationType: external_exports3.enum(["accesskey", "rolearn"]).optional().describe(
31660
+ "(s3 only) Authentication mode. Required when destinationType is 's3'. Tailscale recommends 'rolearn'."
31661
+ ),
31662
+ s3AccessKeyId: external_exports3.string().optional().describe("(s3 only) AWS access key id. Required when s3AuthenticationType is 'accesskey'."),
31663
+ s3SecretAccessKey: external_exports3.string().optional().describe("(s3 only) AWS secret access key. Required when s3AuthenticationType is 'accesskey'."),
31664
+ s3RoleArn: external_exports3.string().optional().describe(
31665
+ "(s3 only) IAM role ARN that Tailscale will assume. Required when s3AuthenticationType is 'rolearn'."
31666
+ )
31501
31667
  }),
31502
31668
  handler: async (input) => {
31669
+ if (input.destinationType === "s3") {
31670
+ const missing = [];
31671
+ if (!input.s3Bucket) missing.push("s3Bucket");
31672
+ if (!input.s3Region) missing.push("s3Region");
31673
+ if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
31674
+ if (input.s3AuthenticationType === "accesskey") {
31675
+ if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
31676
+ if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
31677
+ } else if (input.s3AuthenticationType === "rolearn") {
31678
+ if (!input.s3RoleArn) missing.push("s3RoleArn");
31679
+ }
31680
+ if (missing.length > 0) {
31681
+ throw new Error(
31682
+ `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.`
31683
+ );
31684
+ }
31685
+ } else {
31686
+ const missing = [];
31687
+ if (!input.url) missing.push("url");
31688
+ if (!input.token) missing.push("token");
31689
+ if (missing.length > 0) {
31690
+ throw new Error(`destinationType '${input.destinationType}' requires: ${missing.join(", ")}.`);
31691
+ }
31692
+ }
31503
31693
  const { logType, ...body } = input;
31504
31694
  const cleanBody = {};
31505
31695
  for (const [key, value] of Object.entries(body)) {
@@ -31698,7 +31888,7 @@ var postureTools = [
31698
31888
  var serviceTools = [
31699
31889
  {
31700
31890
  name: "tailscale_list_services",
31701
- description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices.",
31891
+ 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.",
31702
31892
  annotations: {
31703
31893
  title: "List services",
31704
31894
  readOnlyHint: true,
@@ -31953,17 +32143,24 @@ var tailnetTools = [
31953
32143
  openWorldHint: true
31954
32144
  },
31955
32145
  inputSchema: external_exports3.object({
31956
- account: external_exports3.object({ email: external_exports3.string() }).optional().describe("Account contact email"),
31957
- support: external_exports3.object({ email: external_exports3.string() }).optional().describe("Support contact email"),
31958
- security: external_exports3.object({ email: external_exports3.string() }).optional().describe("Security contact email")
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")
31959
32149
  }),
31960
32150
  handler: async (input) => {
32151
+ const types = ["account", "support", "security"].filter((t) => input[t] !== void 0);
32152
+ const results = await Promise.all(
32153
+ types.map(async (contactType) => {
32154
+ const res = await apiPatch(
32155
+ `/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`,
32156
+ input[contactType]
32157
+ );
32158
+ return { contactType, res };
32159
+ })
32160
+ );
31961
32161
  const applied = {};
31962
32162
  const failed = {};
31963
- for (const contactType of ["account", "support", "security"]) {
31964
- const value = input[contactType];
31965
- if (value === void 0) continue;
31966
- const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32163
+ for (const { contactType, res } of results) {
31967
32164
  if (res.ok) applied[contactType] = res.data;
31968
32165
  else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
31969
32166
  }
@@ -31986,7 +32183,8 @@ var tailnetTools = [
31986
32183
  title: "Resend contact verification",
31987
32184
  readOnlyHint: false,
31988
32185
  destructiveHint: false,
31989
- idempotentHint: true,
32186
+ // Each call sends a separate verification email.
32187
+ idempotentHint: false,
31990
32188
  openWorldHint: true
31991
32189
  },
31992
32190
  inputSchema: external_exports3.object({
@@ -32192,7 +32390,7 @@ var webhookTools = [
32192
32390
  openWorldHint: true
32193
32391
  },
32194
32392
  inputSchema: external_exports3.object({
32195
- endpointUrl: external_exports3.string().describe("The URL to send webhook events to"),
32393
+ endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
32196
32394
  subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
32197
32395
  }),
32198
32396
  handler: async (input) => {
@@ -32214,7 +32412,7 @@ var webhookTools = [
32214
32412
  },
32215
32413
  inputSchema: external_exports3.object({
32216
32414
  webhookId: external_exports3.string().describe("The webhook ID to update"),
32217
- endpointUrl: external_exports3.string().optional().describe("New URL to send webhook events to"),
32415
+ endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
32218
32416
  subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
32219
32417
  }),
32220
32418
  handler: async (input) => {
@@ -32268,7 +32466,9 @@ var webhookTools = [
32268
32466
  title: "Test webhook",
32269
32467
  readOnlyHint: false,
32270
32468
  destructiveHint: false,
32271
- idempotentHint: true,
32469
+ // Each invocation delivers a separate test event to the endpoint —
32470
+ // not idempotent in the strict sense.
32471
+ idempotentHint: false,
32272
32472
  openWorldHint: true
32273
32473
  },
32274
32474
  inputSchema: external_exports3.object({
@@ -32281,7 +32481,7 @@ var webhookTools = [
32281
32481
  ];
32282
32482
 
32283
32483
  // src/index.ts
32284
- var version2 = true ? "0.9.0" : (await null).createRequire(import.meta.url)("../package.json").version;
32484
+ var version2 = true ? "0.10.1" : (await null).createRequire(import.meta.url)("../package.json").version;
32285
32485
  var subcommand = process.argv[2];
32286
32486
  if (subcommand === "deploy-acl") {
32287
32487
  const filePath = process.argv[3];
@@ -32455,9 +32655,10 @@ var filterSuffix = [
32455
32655
  console.error(
32456
32656
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
32457
32657
  );
32458
- if (!filterSuffix) {
32658
+ var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
32659
+ if (!filterSuffix && hasCreds) {
32459
32660
  console.error(
32460
- "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (46 tools) or =minimal (19) to load a smaller tool surface. See README."
32661
+ "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (47 tools) or =minimal (20) to load a smaller tool surface. See README."
32461
32662
  );
32462
32663
  }
32463
32664
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",