@yawlabs/tailscale-mcp 0.9.1 → 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 +251 -63
  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.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,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({
@@ -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: {
@@ -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;
@@ -31498,7 +31651,7 @@ var logStreamingTools = [
31498
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
31653
  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."),
31654
+ uploadPeriodMinutes: external_exports3.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
31502
31655
  compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
31503
31656
  s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
31504
31657
  s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
@@ -31513,6 +31666,30 @@ var logStreamingTools = [
31513
31666
  )
31514
31667
  }),
31515
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
+ }
31516
31693
  const { logType, ...body } = input;
31517
31694
  const cleanBody = {};
31518
31695
  for (const [key, value] of Object.entries(body)) {
@@ -31711,7 +31888,7 @@ var postureTools = [
31711
31888
  var serviceTools = [
31712
31889
  {
31713
31890
  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.",
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.",
31715
31892
  annotations: {
31716
31893
  title: "List services",
31717
31894
  readOnlyHint: true,
@@ -31966,17 +32143,24 @@ var tailnetTools = [
31966
32143
  openWorldHint: true
31967
32144
  },
31968
32145
  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")
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")
31972
32149
  }),
31973
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
+ );
31974
32161
  const applied = {};
31975
32162
  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);
32163
+ for (const { contactType, res } of results) {
31980
32164
  if (res.ok) applied[contactType] = res.data;
31981
32165
  else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
31982
32166
  }
@@ -31999,7 +32183,8 @@ var tailnetTools = [
31999
32183
  title: "Resend contact verification",
32000
32184
  readOnlyHint: false,
32001
32185
  destructiveHint: false,
32002
- idempotentHint: true,
32186
+ // Each call sends a separate verification email.
32187
+ idempotentHint: false,
32003
32188
  openWorldHint: true
32004
32189
  },
32005
32190
  inputSchema: external_exports3.object({
@@ -32205,7 +32390,7 @@ var webhookTools = [
32205
32390
  openWorldHint: true
32206
32391
  },
32207
32392
  inputSchema: external_exports3.object({
32208
- 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"),
32209
32394
  subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
32210
32395
  }),
32211
32396
  handler: async (input) => {
@@ -32227,7 +32412,7 @@ var webhookTools = [
32227
32412
  },
32228
32413
  inputSchema: external_exports3.object({
32229
32414
  webhookId: external_exports3.string().describe("The webhook ID to update"),
32230
- 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"),
32231
32416
  subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
32232
32417
  }),
32233
32418
  handler: async (input) => {
@@ -32281,7 +32466,9 @@ var webhookTools = [
32281
32466
  title: "Test webhook",
32282
32467
  readOnlyHint: false,
32283
32468
  destructiveHint: false,
32284
- idempotentHint: true,
32469
+ // Each invocation delivers a separate test event to the endpoint —
32470
+ // not idempotent in the strict sense.
32471
+ idempotentHint: false,
32285
32472
  openWorldHint: true
32286
32473
  },
32287
32474
  inputSchema: external_exports3.object({
@@ -32294,7 +32481,7 @@ var webhookTools = [
32294
32481
  ];
32295
32482
 
32296
32483
  // src/index.ts
32297
- var version2 = true ? "0.9.1" : (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;
32298
32485
  var subcommand = process.argv[2];
32299
32486
  if (subcommand === "deploy-acl") {
32300
32487
  const filePath = process.argv[3];
@@ -32468,9 +32655,10 @@ var filterSuffix = [
32468
32655
  console.error(
32469
32656
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
32470
32657
  );
32471
- 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) {
32472
32660
  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."
32661
+ "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (47 tools) or =minimal (20) to load a smaller tool surface. See README."
32474
32662
  );
32475
32663
  }
32476
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.1",
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>",