@yawlabs/tailscale-mcp 0.21.2 → 0.23.0

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.
package/dist/index.js CHANGED
@@ -31350,9 +31350,6 @@ var StdioServerTransport = class {
31350
31350
  }
31351
31351
  };
31352
31352
 
31353
- // src/cli.ts
31354
- import { readFileSync } from "node:fs";
31355
-
31356
31353
  // src/api.ts
31357
31354
  var BASE_URL = "https://api.tailscale.com/api/v2";
31358
31355
  var REQUEST_TIMEOUT_MS = 3e4;
@@ -31390,17 +31387,27 @@ function getAuthConfig() {
31390
31387
  `No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
31391
31388
  );
31392
31389
  }
31390
+ function hasUsableCredentials() {
31391
+ try {
31392
+ getAuthConfig();
31393
+ return true;
31394
+ } catch {
31395
+ return false;
31396
+ }
31397
+ }
31393
31398
  function getOAuthTailnet() {
31394
31399
  const raw = process.env.TAILSCALE_OAUTH_TAILNET?.trim();
31395
31400
  return raw ? raw : void 0;
31396
31401
  }
31397
- async function getOAuthAccessToken(clientId, clientSecret) {
31402
+ var OAUTH_FALLBACK_EXPIRES_IN_SEC = 300;
31403
+ async function getOAuthAccessToken(clientId, clientSecret, timeoutMs = REQUEST_TIMEOUT_MS) {
31398
31404
  if (oauthToken && Date.now() < oauthToken.expires_at - 6e4) {
31399
31405
  return oauthToken.access_token;
31400
31406
  }
31401
31407
  if (oauthRefreshPromise) {
31402
31408
  return oauthRefreshPromise;
31403
31409
  }
31410
+ const exchangeTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, Math.max(1, timeoutMs));
31404
31411
  oauthRefreshPromise = (async () => {
31405
31412
  try {
31406
31413
  const oauthTailnet = getOAuthTailnet();
@@ -31413,7 +31420,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31413
31420
  client_secret: clientSecret,
31414
31421
  grant_type: "client_credentials"
31415
31422
  }),
31416
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
31423
+ signal: AbortSignal.timeout(exchangeTimeoutMs)
31417
31424
  });
31418
31425
  if (!res.ok) {
31419
31426
  const body = await res.text();
@@ -31421,9 +31428,13 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31421
31428
  throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
31422
31429
  }
31423
31430
  const data = await res.json();
31431
+ if (typeof data.access_token !== "string" || data.access_token === "") {
31432
+ throw new Error("OAuth token exchange succeeded but the response carried no access_token.");
31433
+ }
31434
+ const expiresInSec = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : OAUTH_FALLBACK_EXPIRES_IN_SEC;
31424
31435
  oauthToken = {
31425
31436
  access_token: data.access_token,
31426
- expires_at: Date.now() + data.expires_in * 1e3
31437
+ expires_at: Date.now() + expiresInSec * 1e3
31427
31438
  };
31428
31439
  return oauthToken.access_token;
31429
31440
  } finally {
@@ -31437,12 +31448,12 @@ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
31437
31448
  if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
31438
31449
  oauthToken = null;
31439
31450
  }
31440
- async function getAuthHeader() {
31451
+ async function getAuthHeader(timeoutMs = REQUEST_TIMEOUT_MS) {
31441
31452
  const config2 = getAuthConfig();
31442
31453
  if (config2.kind === "apiKey") {
31443
31454
  return `Basic ${Buffer.from(`${config2.apiKey}:`).toString("base64")}`;
31444
31455
  }
31445
- const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret);
31456
+ const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret, timeoutMs);
31446
31457
  return `Bearer ${token}`;
31447
31458
  }
31448
31459
  function getTailnet() {
@@ -31662,11 +31673,22 @@ async function executeFetch(method, url2, headers, body, timeoutMs) {
31662
31673
  signal: AbortSignal.timeout(timeoutMs)
31663
31674
  });
31664
31675
  }
31676
+ var UNDICI_TIMEOUT_PHASES = {
31677
+ UND_ERR_CONNECT_TIMEOUT: "while connecting",
31678
+ UND_ERR_HEADERS_TIMEOUT: "waiting for the response headers",
31679
+ UND_ERR_BODY_TIMEOUT: "reading the response body"
31680
+ };
31681
+ function undiciTimeoutPhase(err) {
31682
+ const code = err?.cause?.code;
31683
+ return typeof code === "string" ? UNDICI_TIMEOUT_PHASES[code] ?? null : null;
31684
+ }
31665
31685
  function describeTransportError(err, method, attemptTimeoutMs) {
31666
31686
  if (err instanceof Error) {
31667
31687
  if (err.name === "TimeoutError" || err.name === "AbortError") {
31668
31688
  return `${method} request timed out after ${attemptTimeoutMs}ms`;
31669
31689
  }
31690
+ const phase = undiciTimeoutPhase(err);
31691
+ if (phase) return `${method} request timed out ${phase}`;
31670
31692
  const cause = err.cause;
31671
31693
  if (cause instanceof Error && cause.message) {
31672
31694
  return `${method} request failed: ${err.message} (${cause.message})`;
@@ -31685,7 +31707,8 @@ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
31685
31707
  return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
31686
31708
  }
31687
31709
  function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, priorTransportError) {
31688
- if (status !== 404 || method.toUpperCase() !== "DELETE") return error51;
31710
+ if (status !== 404 && status !== 0) return error51;
31711
+ if (method.toUpperCase() !== "DELETE") return error51;
31689
31712
  const causes = [];
31690
31713
  if (priorGatewayStatus !== void 0) causes.push(`returned HTTP ${priorGatewayStatus}`);
31691
31714
  if (priorTransportError !== void 0) causes.push(`never returned a response (${priorTransportError})`);
@@ -31693,6 +31716,11 @@ function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, pr
31693
31716
  const subject = causes.length > 1 ? "earlier attempts" : "an earlier attempt";
31694
31717
  return `${error51 || `HTTP ${status}`} (${subject} ${causes.join(" and ")}; the delete may already have succeeded)`;
31695
31718
  }
31719
+ function annotateTransportFailure(error51, method, priorGatewayStatus, priorTransportError) {
31720
+ if (method.toUpperCase() !== "DELETE") return error51;
31721
+ if (priorGatewayStatus === void 0 && priorTransportError === void 0) return error51;
31722
+ return annotateAmbiguousDelete(error51, 0, method, priorGatewayStatus, priorTransportError);
31723
+ }
31696
31724
  async function apiRequest(method, path, body, options) {
31697
31725
  const headers = {};
31698
31726
  if (options?.accept) {
@@ -31723,11 +31751,14 @@ async function apiRequest(method, path, body, options) {
31723
31751
  const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
31724
31752
  const requestBudgetMs = getRequestBudgetMs();
31725
31753
  return withConcurrencyLimit(async (queuedForMs) => {
31726
- headers.Authorization = await getAuthHeader();
31754
+ const authTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, requestBudgetMs - (Date.now() - startedAt));
31755
+ headers.Authorization = await getAuthHeader(authTimeoutMs);
31727
31756
  let res;
31728
31757
  let lastTransportError;
31729
31758
  let priorGatewayStatus;
31730
31759
  let priorTransportError;
31760
+ let priorRateLimited = false;
31761
+ const withRateLimitNote = (error51) => priorRateLimited ? `${error51} (an earlier attempt was rate-limited: HTTP 429)` : error51;
31731
31762
  let budgetMs = requestBudgetMs;
31732
31763
  for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
31733
31764
  const remaining = budgetMs - (Date.now() - startedAt);
@@ -31735,7 +31766,14 @@ async function apiRequest(method, path, body, options) {
31735
31766
  return {
31736
31767
  ok: false,
31737
31768
  status: 0,
31738
- error: describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError)
31769
+ error: withRateLimitNote(
31770
+ annotateTransportFailure(
31771
+ describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError),
31772
+ method,
31773
+ priorGatewayStatus,
31774
+ priorTransportError
31775
+ )
31776
+ )
31739
31777
  };
31740
31778
  }
31741
31779
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
@@ -31747,12 +31785,27 @@ async function apiRequest(method, path, body, options) {
31747
31785
  const desc = describeTransportError(err, method, attemptTimeoutMs);
31748
31786
  lastTransportError = desc;
31749
31787
  if (!isRetryable || attempt === MAX_429_RETRIES) {
31750
- return { ok: false, status: 0, error: desc };
31788
+ return {
31789
+ ok: false,
31790
+ status: 0,
31791
+ error: withRateLimitNote(annotateTransportFailure(desc, method, priorGatewayStatus, priorTransportError))
31792
+ };
31751
31793
  }
31752
31794
  const delay2 = compute429DelayMs(null, attempt);
31753
31795
  const elapsed3 = Date.now() - startedAt;
31754
31796
  if (budgetMs - elapsed3 - delay2 <= 0) {
31755
- return { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
31797
+ return {
31798
+ ok: false,
31799
+ status: 0,
31800
+ error: withRateLimitNote(
31801
+ annotateTransportFailure(
31802
+ `${desc}; request budget exhausted before retry.`,
31803
+ method,
31804
+ priorGatewayStatus,
31805
+ priorTransportError
31806
+ )
31807
+ )
31808
+ };
31756
31809
  }
31757
31810
  debugLog(
31758
31811
  ` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
@@ -31776,7 +31829,9 @@ async function apiRequest(method, path, body, options) {
31776
31829
  break;
31777
31830
  }
31778
31831
  debugLog(` -> ${res.status} (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
31779
- if (res.status !== 429) {
31832
+ if (res.status === 429) {
31833
+ priorRateLimited = true;
31834
+ } else {
31780
31835
  priorGatewayStatus = res.status;
31781
31836
  budgetMs = retryCeilingMs;
31782
31837
  }
@@ -31818,13 +31873,17 @@ async function apiRequest(method, path, body, options) {
31818
31873
  if (response.status === 204 || response.headers.get("content-length") === "0") {
31819
31874
  return { ok: true, status: response.status, etag };
31820
31875
  }
31821
- const data = await response.json();
31876
+ const text = await response.text();
31877
+ if (text.trim() === "") {
31878
+ return { ok: true, status: response.status, etag };
31879
+ }
31880
+ const data = JSON.parse(text);
31822
31881
  return { ok: true, status: response.status, data, etag };
31823
31882
  } catch (err) {
31824
31883
  return {
31825
31884
  ok: false,
31826
31885
  status: response.status,
31827
- error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${err instanceof Error ? err.message : String(err)}`,
31886
+ error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${undiciTimeoutPhase(err) ? `timed out ${undiciTimeoutPhase(err)}` : err instanceof Error ? err.message : String(err)}`,
31828
31887
  etag
31829
31888
  };
31830
31889
  }
@@ -31847,6 +31906,7 @@ async function apiDelete(path, options) {
31847
31906
  }
31848
31907
 
31849
31908
  // src/cli.ts
31909
+ import { readFileSync } from "node:fs";
31850
31910
  function isWarningsOnly(message, data) {
31851
31911
  if (!/^warning/i.test(message)) return false;
31852
31912
  if (!Array.isArray(data) || data.length === 0) return false;
@@ -31880,13 +31940,22 @@ function parseValidationError(rawBody) {
31880
31940
  ...details ? { details } : {}
31881
31941
  };
31882
31942
  }
31943
+ var CliFailure = class extends Error {
31944
+ constructor() {
31945
+ super("CLI failure reported on stderr");
31946
+ this.name = "CliFailure";
31947
+ }
31948
+ };
31883
31949
  function readPolicyFile(filePath) {
31884
31950
  try {
31885
31951
  const text = readFileSync(filePath, "utf-8");
31886
31952
  return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
31887
31953
  } catch (err) {
31888
- console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
31889
- process.exit(1);
31954
+ const denied = err?.code === "ERR_ACCESS_DENIED";
31955
+ const hint = denied ? " -- file access is denied by the tailscale-mcp permission sandbox; run this command with the sandbox off (README: Sandboxing)" : "";
31956
+ console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}${hint}`);
31957
+ process.exitCode = 1;
31958
+ throw new CliFailure();
31890
31959
  }
31891
31960
  }
31892
31961
  async function validatePolicy(policy) {
@@ -31898,13 +31967,15 @@ async function validatePolicy(policy) {
31898
31967
  });
31899
31968
  if (!validateRes.ok) {
31900
31969
  console.error(`ACL validation failed: ${validateRes.error}`);
31901
- process.exit(1);
31970
+ process.exitCode = 1;
31971
+ throw new CliFailure();
31902
31972
  }
31903
31973
  const validation = parseValidationError(validateRes.rawBody);
31904
31974
  if (validation.kind !== "valid") {
31905
31975
  console.error(`ACL validation failed: ${validation.message}${validation.details ? `
31906
31976
  ${validation.details}` : ""}`);
31907
- process.exit(1);
31977
+ process.exitCode = 1;
31978
+ throw new CliFailure();
31908
31979
  }
31909
31980
  }
31910
31981
  async function validateAcl(filePath) {
@@ -31914,12 +31985,13 @@ async function validateAcl(filePath) {
31914
31985
  }
31915
31986
  async function deployAcl(filePath) {
31916
31987
  const policy = readPolicyFile(filePath);
31988
+ await validatePolicy(policy);
31917
31989
  const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31918
31990
  if (!getRes.ok || !getRes.etag) {
31919
31991
  console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
31920
- process.exit(1);
31992
+ process.exitCode = 1;
31993
+ throw new CliFailure();
31921
31994
  }
31922
- await validatePolicy(policy);
31923
31995
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31924
31996
  rawBody: policy,
31925
31997
  contentType: "application/hujson",
@@ -31935,7 +32007,8 @@ async function deployAcl(filePath) {
31935
32007
  } else {
31936
32008
  console.error(`ACL deploy failed: ${deployRes.error}`);
31937
32009
  }
31938
- process.exit(1);
32010
+ process.exitCode = 1;
32011
+ throw new CliFailure();
31939
32012
  }
31940
32013
  console.log("ACL deployed successfully");
31941
32014
  }
@@ -32055,13 +32128,13 @@ async function previewAccess(policy, principal) {
32055
32128
  }
32056
32129
  return {
32057
32130
  ok: true,
32058
- access: accessSet(parsed.matches, parsed.postures, false),
32059
32131
  hasPostureDefs: !!parsed.postures,
32060
32132
  matches: parsed.matches,
32061
32133
  postureDefs: parsed.postures
32062
32134
  };
32063
32135
  }
32064
32136
  var ETAG_FOOTER_MARKER = "// ETag: ";
32137
+ var NO_ETAG_MARKER = "// WARNING: the API returned no ETag";
32065
32138
  function stripEtagFooter(body) {
32066
32139
  const lines = body.split("\n");
32067
32140
  let cut = lines.length;
@@ -32069,7 +32142,7 @@ function stripEtagFooter(body) {
32069
32142
  const line = lines[i].trim();
32070
32143
  if (line === "") continue;
32071
32144
  if (!line.startsWith("//")) break;
32072
- if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
32145
+ if (line.startsWith(ETAG_FOOTER_MARKER) || line.startsWith(NO_ETAG_MARKER)) cut = i;
32073
32146
  }
32074
32147
  return lines.slice(0, cut).join("\n");
32075
32148
  }
@@ -32087,7 +32160,7 @@ function normalizeIfMatch(etag) {
32087
32160
  var aclTools = [
32088
32161
  {
32089
32162
  name: "tailscale_get_acl",
32090
- description: "Get the current ACL policy for your tailnet. Returns the raw policy text with original formatting preserved, including comments and trailing commas (HuJSON). Also returns an ETag \u2014 you must pass it to tailscale_update_acl to safely update the policy.",
32163
+ description: "Get the current ACL policy for your tailnet. Returns the raw policy text with original formatting preserved, including comments and trailing commas (HuJSON). Also returns an ETag -- you must pass it to tailscale_update_acl to safely update the policy.",
32091
32164
  annotations: {
32092
32165
  title: "Get ACL policy",
32093
32166
  readOnlyHint: true,
@@ -32106,11 +32179,20 @@ var aclTools = [
32106
32179
  "",
32107
32180
  `${ETAG_FOOTER_MARKER}${res.etag}`,
32108
32181
  "// Pass this ETag to tailscale_update_acl when updating the policy.",
32109
- "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
32182
+ "// (HuJSON treats // as a comment -- safe to leave in or strip before re-submitting.)",
32110
32183
  ""
32111
32184
  ].join("\n");
32112
32185
  return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
32113
32186
  }
32187
+ if (res.ok) {
32188
+ const warning = [
32189
+ "",
32190
+ `${NO_ETAG_MARKER} for this policy, so there is none to pass to`,
32191
+ "// tailscale_update_acl. Call tailscale_get_acl again before updating.",
32192
+ ""
32193
+ ].join("\n");
32194
+ return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${warning}` };
32195
+ }
32114
32196
  return res;
32115
32197
  }
32116
32198
  },
@@ -32147,9 +32229,12 @@ var aclTools = [
32147
32229
  // this is the code that builds the header, and because the handlers are what
32148
32230
  // the tests call directly -- a transform on the schema would be invisible to
32149
32231
  // every assertion made at the header.
32232
+ // The get_acl footer is stripped before the POST: it is inert HuJSON, but left
32233
+ // in it would store a comment naming an ETag that stops matching on this very
32234
+ // write, and diff_acl_access would compare against that decorated baseline.
32150
32235
  handler: async (input) => {
32151
32236
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
32152
- rawBody: input.policy,
32237
+ rawBody: stripEtagFooter(input.policy),
32153
32238
  contentType: "application/hujson",
32154
32239
  ifMatch: normalizeIfMatch(input.etag),
32155
32240
  acceptRaw: true,
@@ -32213,7 +32298,7 @@ var aclTools = [
32213
32298
  },
32214
32299
  {
32215
32300
  name: "tailscale_diff_acl_access",
32216
- description: "Answer 'who loses access?' before applying an ACL change. Compares the CURRENT policy against a proposed one and reports, per user, which destinations they gain and lose. Run this before tailscale_update_acl -- validate_acl only checks syntax and the policy's own tests block, so a policy with no tests validates clean while revoking everyone. LIMITS, all reported in the response rather than left to be discovered. It compares USER principals only, so a revocation that runs through a tag or group can show a clean diff, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening `posture:corp` shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops after 60 seconds regardless; either way it sets `truncated`, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in `failed` and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.",
32301
+ description: "Answer 'who loses access?' before applying an ACL change. Compares the CURRENT policy against a proposed one and reports, per user, which destinations they gain and lose. Run this before tailscale_update_acl -- validate_acl only checks syntax and the policy's own tests block, so a policy with no tests validates clean while revoking everyone. LIMITS, all reported in the response rather than left to be discovered. It compares USER principals only, so a revocation that runs through a tag or group can show a clean diff, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening `posture:corp` shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops starting new users after 60 seconds (a user whose previews are already in flight still finishes); either way it sets `truncated`, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in `failed` and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.",
32217
32302
  annotations: {
32218
32303
  title: "Diff ACL access",
32219
32304
  readOnlyHint: true,
@@ -32370,7 +32455,9 @@ var aclTools = [
32370
32455
  // src/tools/audit.ts
32371
32456
  function assertRFC3339(value, label) {
32372
32457
  const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
32373
- const err = () => new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
32458
+ const err = () => new Error(
32459
+ `${label} must be a valid RFC3339 date-time with an uppercase 'T' and 'Z' (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`
32460
+ );
32374
32461
  if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
32375
32462
  throw err();
32376
32463
  }
@@ -32454,7 +32541,7 @@ var auditTools = [
32454
32541
  },
32455
32542
  {
32456
32543
  name: "tailscale_get_network_flow_logs",
32457
- description: "Get network traffic flow logs showing connections between devices. Shows source/destination nodes, timestamps, and traffic metadata \u2014 useful for security monitoring and debugging connectivity.",
32544
+ description: "Get network traffic flow logs showing connections between devices. Shows source/destination nodes, timestamps, and traffic metadata -- useful for security monitoring and debugging connectivity.",
32458
32545
  annotations: {
32459
32546
  title: "Get network flow logs",
32460
32547
  readOnlyHint: true,
@@ -32491,7 +32578,7 @@ function isCidr(s) {
32491
32578
  if (!/^\d+$/.test(prefix)) return false;
32492
32579
  const prefixN = Number(prefix);
32493
32580
  if (net.isIPv4(addr)) return prefixN <= 32;
32494
- if (net.isIPv6(addr)) return prefixN <= 128;
32581
+ if (net.isIPv6(addr)) return !addr.includes("%") && prefixN <= 128;
32495
32582
  return false;
32496
32583
  }
32497
32584
  var DEVICE_FIELDS_DESC = "Which device fields to return. Tailscale documents exactly two values. 'default' (also what you get when this is omitted) is the limited set: addresses, id, nodeId, user, name, hostname, clientVersion, updateAvailable, os, created, connectedToControl, lastSeen, keyExpiryDisabled, expires, authorized, isExternal, machineKey, nodeKey, blocksIncomingConnections, tailnetLockKey, tailnetLockError, tags, isEphemeral. 'all' adds advertisedRoutes, enabledRoutes, clientConnectivity (endpoints, DERP latency), sshEnabled, distro, multipleConnections and postureIdentity (serial numbers and, where a posture integration collects them, hardware/MAC addresses). Omitting it does NOT return everything.";
@@ -32587,7 +32674,7 @@ var deviceTools = [
32587
32674
  },
32588
32675
  {
32589
32676
  name: "tailscale_delete_device",
32590
- description: "Permanently remove a device from the tailnet. This is irreversible \u2014 the device must re-authenticate to rejoin.",
32677
+ description: "Permanently remove a device from the tailnet. This is irreversible -- the device must re-authenticate to rejoin.",
32591
32678
  annotations: {
32592
32679
  title: "Delete device",
32593
32680
  readOnlyHint: false,
@@ -32596,7 +32683,10 @@ var deviceTools = [
32596
32683
  openWorldHint: true
32597
32684
  },
32598
32685
  inputSchema: external_exports.object({
32599
- deviceId: external_exports.string().describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
32686
+ // `.trim().min(1)`, not a bare string: a whitespace-only device id encodes
32687
+ // to "%20" and this irreversible delete returns a 404 that reads like the
32688
+ // device is already gone (rationale: tailnets.ts delete_tailnet).
32689
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
32600
32690
  }),
32601
32691
  handler: async (input) => {
32602
32692
  return apiDelete(`/device/${encPath(input.deviceId)}`);
@@ -32658,7 +32748,7 @@ var deviceTools = [
32658
32748
  },
32659
32749
  {
32660
32750
  name: "tailscale_set_device_routes",
32661
- description: "Set the enabled subnet routes for a device. Replaces all currently enabled routes \u2014 pass the full list of routes you want enabled.",
32751
+ description: "Set the enabled subnet routes for a device. Replaces all currently enabled routes -- pass the full list of routes you want enabled.",
32662
32752
  annotations: {
32663
32753
  title: "Set device routes",
32664
32754
  readOnlyHint: false,
@@ -32756,7 +32846,7 @@ var deviceTools = [
32756
32846
  },
32757
32847
  {
32758
32848
  name: "tailscale_set_device_tags",
32759
- description: "Set ACL tags on a device. Replaces all existing tags \u2014 pass the full list of tags you want applied.",
32849
+ description: "Set ACL tags on a device. Replaces all existing tags -- pass the full list of tags you want applied.",
32760
32850
  annotations: {
32761
32851
  title: "Set device tags",
32762
32852
  readOnlyHint: false,
@@ -32815,7 +32905,7 @@ var deviceTools = [
32815
32905
  },
32816
32906
  {
32817
32907
  name: "tailscale_set_devices_authorized",
32818
- 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. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
32908
+ 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. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. POSTs are never retried, so on a large batch a rate-limited device lands in data.failed with status 429: re-call with just those ids, or set TAILSCALE_MAX_CONCURRENT to cap requests in flight. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
32819
32909
  annotations: {
32820
32910
  title: "Set devices authorized (bulk)",
32821
32911
  readOnlyHint: false,
@@ -32857,7 +32947,7 @@ var deviceTools = [
32857
32947
  },
32858
32948
  {
32859
32949
  name: "tailscale_batch_update_posture_attributes",
32860
- 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.",
32950
+ description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics -- pass null as the attribute config to delete.",
32861
32951
  annotations: {
32862
32952
  title: "Batch update posture attributes",
32863
32953
  readOnlyHint: false,
@@ -33085,7 +33175,7 @@ var dnsTools = [
33085
33175
  },
33086
33176
  {
33087
33177
  name: "tailscale_set_dns_configuration",
33088
- description: "Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference).",
33178
+ description: "Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference). This is a replace-all endpoint: pass every top-level field you want to keep, because a field you omit may be cleared rather than left alone.",
33089
33179
  annotations: {
33090
33180
  title: "Set DNS configuration (unified)",
33091
33181
  readOnlyHint: false,
@@ -33098,9 +33188,16 @@ var dnsTools = [
33098
33188
  inputSchema: external_exports.object({
33099
33189
  dns: external_exports.array(external_exports.string()).optional().describe("List of DNS server IP addresses"),
33100
33190
  searchPaths: external_exports.array(external_exports.string()).optional().describe("List of DNS search domains"),
33101
- splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).optional().describe("Map of domain to nameserver list for split DNS"),
33191
+ // `.nullable()` to match the PUT/PATCH split-dns siblings above: the
33192
+ // spec's SplitDns schema types each value as an array OR null.
33193
+ splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).optional().describe("Map of domain to nameserver list for split DNS, or to null to clear that domain"),
33102
33194
  magicDNS: external_exports.boolean().optional().describe("Whether to enable MagicDNS")
33103
33195
  }),
33196
+ // UNVERIFIED: whether an omitted top-level field is preserved or cleared by
33197
+ // the API on a partial body has never been probed live here. The description
33198
+ // warns the caller it MAY be cleared rather than promising a merge -- the
33199
+ // safe reading of a replace-all endpoint -- because claiming preservation we
33200
+ // have not observed is how a silent wipe gets shipped.
33104
33201
  handler: async (input) => {
33105
33202
  const body = {};
33106
33203
  for (const [key, value] of Object.entries(input)) {
@@ -33114,6 +33211,158 @@ var dnsTools = [
33114
33211
  }
33115
33212
  ];
33116
33213
 
33214
+ // src/tools/users.ts
33215
+ var USER_ROLES = [
33216
+ "owner",
33217
+ "admin",
33218
+ "it-admin",
33219
+ "network-admin",
33220
+ "billing-admin",
33221
+ "auditor",
33222
+ "member"
33223
+ ];
33224
+ var INVITABLE_ROLES = [
33225
+ "member",
33226
+ "admin",
33227
+ "it-admin",
33228
+ "network-admin",
33229
+ "billing-admin",
33230
+ "auditor"
33231
+ ];
33232
+ var userTools = [
33233
+ {
33234
+ name: "tailscale_list_users",
33235
+ description: "List all users in your tailnet.",
33236
+ annotations: {
33237
+ title: "List users",
33238
+ readOnlyHint: true,
33239
+ destructiveHint: false,
33240
+ idempotentHint: true,
33241
+ openWorldHint: true
33242
+ },
33243
+ inputSchema: external_exports.object({
33244
+ type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
33245
+ role: external_exports.enum(USER_ROLES).optional().describe("Filter by user role")
33246
+ }),
33247
+ handler: async (input) => {
33248
+ const params = new URLSearchParams();
33249
+ if (input.type) params.set("type", input.type);
33250
+ if (input.role) params.set("role", input.role);
33251
+ const qs = params.toString();
33252
+ return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
33253
+ }
33254
+ },
33255
+ {
33256
+ name: "tailscale_get_user",
33257
+ description: "Get details for a specific user.",
33258
+ annotations: {
33259
+ title: "Get user",
33260
+ readOnlyHint: true,
33261
+ destructiveHint: false,
33262
+ idempotentHint: true,
33263
+ openWorldHint: true
33264
+ },
33265
+ inputSchema: external_exports.object({
33266
+ userId: external_exports.string().trim().min(1).describe("The user ID")
33267
+ }),
33268
+ handler: async (input) => {
33269
+ return apiGet(`/users/${encPath(input.userId)}`);
33270
+ }
33271
+ },
33272
+ {
33273
+ name: "tailscale_approve_user",
33274
+ description: "Approve a pending user, granting them access to the tailnet.",
33275
+ annotations: {
33276
+ title: "Approve user",
33277
+ readOnlyHint: false,
33278
+ destructiveHint: false,
33279
+ idempotentHint: true,
33280
+ openWorldHint: true
33281
+ },
33282
+ inputSchema: external_exports.object({
33283
+ userId: external_exports.string().trim().min(1).describe("The user ID to approve")
33284
+ }),
33285
+ handler: async (input) => {
33286
+ return apiPost(`/users/${encPath(input.userId)}/approve`);
33287
+ }
33288
+ },
33289
+ {
33290
+ name: "tailscale_suspend_user",
33291
+ description: "Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.",
33292
+ annotations: {
33293
+ title: "Suspend user",
33294
+ readOnlyHint: false,
33295
+ destructiveHint: true,
33296
+ idempotentHint: true,
33297
+ openWorldHint: true
33298
+ },
33299
+ inputSchema: external_exports.object({
33300
+ // `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
33301
+ // to "%20" and suspend returns a 404 that reads like the user is already
33302
+ // gone when it is not (rationale: tailnets.ts delete_tailnet).
33303
+ userId: external_exports.string().trim().min(1).describe("The user ID to suspend")
33304
+ }),
33305
+ handler: async (input) => {
33306
+ return apiPost(`/users/${encPath(input.userId)}/suspend`);
33307
+ }
33308
+ },
33309
+ {
33310
+ name: "tailscale_restore_user",
33311
+ description: "Restore a previously suspended user, re-granting them access to the tailnet.",
33312
+ annotations: {
33313
+ title: "Restore user",
33314
+ readOnlyHint: false,
33315
+ destructiveHint: false,
33316
+ idempotentHint: true,
33317
+ openWorldHint: true
33318
+ },
33319
+ inputSchema: external_exports.object({
33320
+ userId: external_exports.string().trim().min(1).describe("The user ID to restore")
33321
+ }),
33322
+ handler: async (input) => {
33323
+ return apiPost(`/users/${encPath(input.userId)}/restore`);
33324
+ }
33325
+ },
33326
+ {
33327
+ name: "tailscale_update_user_role",
33328
+ description: "Update a user's role in the tailnet.",
33329
+ annotations: {
33330
+ title: "Update user role",
33331
+ readOnlyHint: false,
33332
+ destructiveHint: false,
33333
+ idempotentHint: true,
33334
+ openWorldHint: true
33335
+ },
33336
+ inputSchema: external_exports.object({
33337
+ userId: external_exports.string().trim().min(1).describe("The user ID"),
33338
+ role: external_exports.enum(USER_ROLES).describe("The new role to assign")
33339
+ }),
33340
+ handler: async (input) => {
33341
+ return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
33342
+ }
33343
+ },
33344
+ {
33345
+ name: "tailscale_delete_user",
33346
+ description: "Delete a user from the tailnet. This is irreversible -- the user and all their devices will be removed.",
33347
+ annotations: {
33348
+ title: "Delete user",
33349
+ readOnlyHint: false,
33350
+ destructiveHint: true,
33351
+ idempotentHint: true,
33352
+ openWorldHint: true
33353
+ },
33354
+ inputSchema: external_exports.object({
33355
+ // `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
33356
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33357
+ // user is already gone (rationale: tailnets.ts delete_tailnet).
33358
+ userId: external_exports.string().trim().min(1).describe("The user ID to delete")
33359
+ }),
33360
+ handler: async (input) => {
33361
+ return apiPost(`/users/${encPath(input.userId)}/delete`);
33362
+ }
33363
+ }
33364
+ ];
33365
+
33117
33366
  // src/tools/invites.ts
33118
33367
  var inviteTools = [
33119
33368
  // --- Device Invites ---
@@ -33128,7 +33377,7 @@ var inviteTools = [
33128
33377
  openWorldHint: true
33129
33378
  },
33130
33379
  inputSchema: external_exports.object({
33131
- deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33380
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
33132
33381
  }),
33133
33382
  handler: async (input) => {
33134
33383
  return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
@@ -33145,7 +33394,7 @@ var inviteTools = [
33145
33394
  openWorldHint: true
33146
33395
  },
33147
33396
  inputSchema: external_exports.object({
33148
- deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33397
+ deviceId: external_exports.string().trim().min(1).describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
33149
33398
  multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
33150
33399
  allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
33151
33400
  email: external_exports.email().optional().describe("Email address to send the invite to")
@@ -33155,6 +33404,9 @@ var inviteTools = [
33155
33404
  if (input.multiUse !== void 0) body.multiUse = input.multiUse;
33156
33405
  if (input.allowExitNode !== void 0) body.allowExitNode = input.allowExitNode;
33157
33406
  if (input.email !== void 0) body.email = input.email;
33407
+ if (Object.keys(body).length === 0) {
33408
+ throw new Error("No fields to update. Provide at least one of: multiUse, allowExitNode, email.");
33409
+ }
33158
33410
  return apiPost(`/device/${encPath(input.deviceId)}/device-invites`, body);
33159
33411
  }
33160
33412
  },
@@ -33169,7 +33421,7 @@ var inviteTools = [
33169
33421
  openWorldHint: true
33170
33422
  },
33171
33423
  inputSchema: external_exports.object({
33172
- inviteId: external_exports.string().describe("The device invite ID")
33424
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID")
33173
33425
  }),
33174
33426
  handler: async (input) => {
33175
33427
  return apiGet(`/device-invites/${encPath(input.inviteId)}`);
@@ -33177,7 +33429,7 @@ var inviteTools = [
33177
33429
  },
33178
33430
  {
33179
33431
  name: "tailscale_delete_device_invite",
33180
- description: "Delete a device invite. This is irreversible \u2014 the invite link will stop working.",
33432
+ description: "Delete a device invite. This is irreversible -- the invite link will stop working.",
33181
33433
  annotations: {
33182
33434
  title: "Delete device invite",
33183
33435
  readOnlyHint: false,
@@ -33186,7 +33438,10 @@ var inviteTools = [
33186
33438
  openWorldHint: true
33187
33439
  },
33188
33440
  inputSchema: external_exports.object({
33189
- inviteId: external_exports.string().describe("The device invite ID to delete")
33441
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to delete")
33442
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33443
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33444
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33190
33445
  }),
33191
33446
  handler: async (input) => {
33192
33447
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
@@ -33237,12 +33492,15 @@ var inviteTools = [
33237
33492
  },
33238
33493
  inputSchema: external_exports.object({
33239
33494
  email: external_exports.email().optional().describe("Email address to send the invite to"),
33240
- role: external_exports.enum(["member", "admin", "it-admin", "network-admin", "billing-admin", "auditor"]).optional().describe("Role to assign to the invited user (default: member)")
33495
+ role: external_exports.enum(INVITABLE_ROLES).optional().describe("Role to assign to the invited user (default: member)")
33241
33496
  }),
33242
33497
  handler: async (input) => {
33243
33498
  const body = {};
33244
33499
  if (input.email !== void 0) body.email = input.email;
33245
33500
  if (input.role !== void 0) body.role = input.role;
33501
+ if (Object.keys(body).length === 0) {
33502
+ throw new Error("No fields to update. Provide at least one of: email, role.");
33503
+ }
33246
33504
  return apiPost(`/tailnet/${getTailnet()}/user-invites`, body);
33247
33505
  }
33248
33506
  },
@@ -33257,7 +33515,7 @@ var inviteTools = [
33257
33515
  openWorldHint: true
33258
33516
  },
33259
33517
  inputSchema: external_exports.object({
33260
- inviteId: external_exports.string().describe("The user invite ID")
33518
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID")
33261
33519
  }),
33262
33520
  handler: async (input) => {
33263
33521
  return apiGet(`/user-invites/${encPath(input.inviteId)}`);
@@ -33265,7 +33523,7 @@ var inviteTools = [
33265
33523
  },
33266
33524
  {
33267
33525
  name: "tailscale_delete_user_invite",
33268
- description: "Delete a user invite. This is irreversible \u2014 the invite link will stop working.",
33526
+ description: "Delete a user invite. This is irreversible -- the invite link will stop working.",
33269
33527
  annotations: {
33270
33528
  title: "Delete user invite",
33271
33529
  readOnlyHint: false,
@@ -33274,7 +33532,10 @@ var inviteTools = [
33274
33532
  openWorldHint: true
33275
33533
  },
33276
33534
  inputSchema: external_exports.object({
33277
- inviteId: external_exports.string().describe("The user invite ID to delete")
33535
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to delete")
33536
+ // `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
33537
+ // to "%20" and this irreversible delete returns a 404 that reads like the
33538
+ // invite is already gone (rationale: tailnets.ts delete_tailnet).
33278
33539
  }),
33279
33540
  handler: async (input) => {
33280
33541
  return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
@@ -33292,7 +33553,7 @@ var inviteTools = [
33292
33553
  openWorldHint: true
33293
33554
  },
33294
33555
  inputSchema: external_exports.object({
33295
- inviteId: external_exports.string().describe("The device invite ID to resend")
33556
+ inviteId: external_exports.string().trim().min(1).describe("The device invite ID to resend")
33296
33557
  }),
33297
33558
  handler: async (input) => {
33298
33559
  return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
@@ -33310,7 +33571,7 @@ var inviteTools = [
33310
33571
  openWorldHint: true
33311
33572
  },
33312
33573
  inputSchema: external_exports.object({
33313
- inviteId: external_exports.string().describe("The user invite ID to resend")
33574
+ inviteId: external_exports.string().trim().min(1).describe("The user invite ID to resend")
33314
33575
  }),
33315
33576
  handler: async (input) => {
33316
33577
  return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
@@ -33349,7 +33610,7 @@ var keyTools = [
33349
33610
  openWorldHint: true
33350
33611
  },
33351
33612
  inputSchema: external_exports.object({
33352
- keyId: external_exports.string().describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
33613
+ keyId: external_exports.string().trim().min(1).describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
33353
33614
  }),
33354
33615
  handler: async (input) => {
33355
33616
  return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -33374,7 +33635,8 @@ var keyTools = [
33374
33635
  reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
33375
33636
  ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
33376
33637
  preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
33377
- expirySeconds: external_exports.number().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
33638
+ // Seconds, so a fractional or non-positive value is nonsense to send.
33639
+ expirySeconds: external_exports.number().int().positive().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
33378
33640
  // Shared fields
33379
33641
  tags: external_exports.array(external_exports.string()).optional().describe(
33380
33642
  "ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
@@ -33454,7 +33716,14 @@ var keyTools = [
33454
33716
  openWorldHint: true
33455
33717
  },
33456
33718
  inputSchema: external_exports.object({
33457
- keyId: external_exports.string().describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
33719
+ // `.trim().min(1)`, as on tailscale_delete_oauth_app's appId below and for
33720
+ // the reason tailnets.ts spells out on tailscale_delete_tailnet: a bare
33721
+ // z.string() lets "" through (encPath then sends a DELETE on the keys
33722
+ // collection path itself) and " " through as the literal segment "%20".
33723
+ // On a read that is a wasted round-trip; on this irreversible delete it
33724
+ // returns a 404 that reads like the key is already gone, with no retry to
33725
+ // notice it with.
33726
+ keyId: external_exports.string().trim().min(1).describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
33458
33727
  }),
33459
33728
  handler: async (input) => {
33460
33729
  return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -33462,7 +33731,7 @@ var keyTools = [
33462
33731
  },
33463
33732
  {
33464
33733
  name: "tailscale_update_key",
33465
- description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description' \u2014 the Tailscale API will reject other fields.",
33734
+ description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description' -- the Tailscale API will reject other fields.",
33466
33735
  annotations: {
33467
33736
  title: "Update key",
33468
33737
  readOnlyHint: false,
@@ -33471,7 +33740,7 @@ var keyTools = [
33471
33740
  openWorldHint: true
33472
33741
  },
33473
33742
  inputSchema: external_exports.object({
33474
- keyId: external_exports.string().describe("The key ID to update"),
33743
+ keyId: external_exports.string().trim().min(1).describe("The key ID to update"),
33475
33744
  description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
33476
33745
  scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
33477
33746
  tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
@@ -33603,7 +33872,7 @@ var keyTools = [
33603
33872
  // src/tools/local-cli.ts
33604
33873
  import * as net2 from "node:net";
33605
33874
 
33606
- // src/local-cli.ts
33875
+ // src/local-cli-runner.ts
33607
33876
  import { execFile as execFileCb } from "node:child_process";
33608
33877
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
33609
33878
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -33615,6 +33884,10 @@ var DARWIN_CANDIDATES = [
33615
33884
  ];
33616
33885
  var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
33617
33886
  var execFileImpl = execFileCb;
33887
+ function describeUnexecutableBinary(binary, fromEnv) {
33888
+ const where = fromEnv ? `'${binary}', which is where TAILSCALE_BINARY points` : `'${binary}', found in PATH`;
33889
+ return `Found the 'tailscale' binary at ${where}, but it could not be executed (EACCES). It must be an executable file -- not a directory -- with execute permission for the user running this server. Set TAILSCALE_BINARY to the absolute path of the tailscale executable, or reinstall Tailscale (https://tailscale.com/download).`;
33890
+ }
33618
33891
  function binaryCandidates(platform) {
33619
33892
  if (platform === "darwin") return DARWIN_CANDIDATES;
33620
33893
  if (platform === "linux") return LINUX_CANDIDATES;
@@ -33642,7 +33915,7 @@ function readProcVersion() {
33642
33915
  }
33643
33916
  function describeMissingBinary(binary, fromEnv, platform, wsl) {
33644
33917
  if (fromEnv) {
33645
- const pathNote = platform === "win32" ? `On Windows the value must be a Windows path (C:/Program Files/Tailscale/tailscale.exe), not an MSYS one (/c/...) -- Git Bash rewrites /c/... when you type it at the prompt, but a value read from an MCP client's JSON config or a .env file arrives untranslated.` : `It must be the absolute path of an executable file -- not a directory, and not a shell alias or function.`;
33918
+ const pathNote = platform === "win32" ? `On Windows the value must be a Windows path (C:/Program Files/Tailscale/tailscale.exe), not an MSYS one (/c/...) -- Git Bash rewrites /c/... when you type it at the prompt, but a value read from an MCP client's JSON config or a .env file arrives untranslated. It must also name tailscale.exe itself, not the folder it is in.` : `It must be the absolute path of an executable file, not a shell alias or function.`;
33646
33919
  return `Could not find the 'tailscale' binary at '${binary}', which is where TAILSCALE_BINARY points. PATH was never consulted, so nothing here is a PATH problem. ${pathNote}`;
33647
33920
  }
33648
33921
  if (platform === "darwin") {
@@ -33653,12 +33926,30 @@ function describeMissingBinary(binary, fromEnv, platform, wsl) {
33653
33926
  }
33654
33927
  return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
33655
33928
  }
33929
+ var MAX_CLI_CONCURRENCY = 4;
33930
+ var cliInFlight = 0;
33931
+ var cliQueue = [];
33932
+ async function acquireCliSlot() {
33933
+ if (cliInFlight < MAX_CLI_CONCURRENCY) {
33934
+ cliInFlight++;
33935
+ return;
33936
+ }
33937
+ await new Promise((resolve) => cliQueue.push(resolve));
33938
+ }
33939
+ function releaseCliSlot() {
33940
+ const next = cliQueue.shift();
33941
+ if (next)
33942
+ next();
33943
+ else cliInFlight--;
33944
+ }
33656
33945
  async function runTailscaleCli(args, options = {}) {
33657
33946
  const binary = resolveBinary();
33658
33947
  const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
33659
33948
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33949
+ await acquireCliSlot();
33660
33950
  return new Promise((resolve) => {
33661
33951
  execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
33952
+ releaseCliSlot();
33662
33953
  const stdoutStr = stdout == null ? "" : String(stdout);
33663
33954
  const stderrStr = stderr == null ? "" : String(stderr);
33664
33955
  if (err) {
@@ -33670,9 +33961,14 @@ async function runTailscaleCli(args, options = {}) {
33670
33961
  });
33671
33962
  return;
33672
33963
  }
33964
+ if (errno.code === "EACCES") {
33965
+ resolve({ ok: false, error: describeUnexecutableBinary(binary, fromEnv) });
33966
+ return;
33967
+ }
33673
33968
  if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
33674
33969
  resolve({
33675
33970
  ok: false,
33971
+ kind: "output-limit",
33676
33972
  error: `'${binary} ${args.join(" ")}' exceeded the ${MAX_BUFFER_BYTES / 1024 / 1024} MB output limit and was aborted -- no output was captured. This usually means a very large tailnet; narrow the query if the command supports it.`
33677
33973
  });
33678
33974
  return;
@@ -33680,6 +33976,7 @@ async function runTailscaleCli(args, options = {}) {
33680
33976
  if (errno.killed) {
33681
33977
  resolve({
33682
33978
  ok: false,
33979
+ kind: "timeout",
33683
33980
  error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
33684
33981
  });
33685
33982
  return;
@@ -33743,10 +34040,10 @@ var localCliTools = [
33743
34040
  if (input.peers === false) args.push("--peers=false");
33744
34041
  if (input.activeOnly) args.push("--active");
33745
34042
  const result = await runTailscaleCli(args, { parseJson: true });
33746
- if (!result.ok && result.error?.includes("output limit")) {
34043
+ if (!result.ok && result.kind === "output-limit") {
33747
34044
  return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
33748
34045
  }
33749
- if (!result.ok && result.error?.includes("timed out after")) {
34046
+ if (!result.ok && result.kind === "timeout") {
33750
34047
  return {
33751
34048
  ...result,
33752
34049
  error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
@@ -33864,10 +34161,12 @@ var logStreamingTools = [
33864
34161
  apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
33865
34162
  apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
33866
34163
  ]);
34164
+ const describeFailure = (r) => r.status === 404 ? "HTTP 404: not configured (Tailscale also answers 404 when this log type is unsupported or the caller cannot view it)" : r.error || `HTTP ${r.status}`;
33867
34165
  const errors = {};
33868
- if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
33869
- if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
33870
- if (!configuration.ok && !network.ok) {
34166
+ if (!configuration.ok) errors.configuration = describeFailure(configuration);
34167
+ if (!network.ok) errors.network = describeFailure(network);
34168
+ const hardFail = (r) => !r.ok && r.status !== 404;
34169
+ if (hardFail(configuration) && hardFail(network)) {
33871
34170
  return {
33872
34171
  ok: false,
33873
34172
  status: configuration.status || network.status || 500,
@@ -33912,7 +34211,7 @@ var logStreamingTools = [
33912
34211
  inputSchema: external_exports.object({
33913
34212
  logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
33914
34213
  destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
33915
- url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations)"),
34214
+ url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations; for s3, an optional S3-compatible endpoint)"),
33916
34215
  token: external_exports.string().optional().describe(
33917
34216
  "Authentication token or API key for the destination. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
33918
34217
  ),
@@ -33939,11 +34238,19 @@ var logStreamingTools = [
33939
34238
  if (!input.s3Bucket) missing.push("s3Bucket");
33940
34239
  if (!input.s3Region) missing.push("s3Region");
33941
34240
  if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
34241
+ let wrongAuthFields = [];
33942
34242
  if (input.s3AuthenticationType === "accesskey") {
33943
34243
  if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
33944
34244
  if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
34245
+ if (input.s3RoleArn !== void 0) wrongAuthFields = ["s3RoleArn"];
33945
34246
  } else if (input.s3AuthenticationType === "rolearn") {
33946
34247
  if (!input.s3RoleArn) missing.push("s3RoleArn");
34248
+ wrongAuthFields = ["s3AccessKeyId", "s3SecretAccessKey"].filter((f) => input[f] !== void 0);
34249
+ }
34250
+ if (wrongAuthFields.length > 0) {
34251
+ throw new Error(
34252
+ `${wrongAuthFields.join(", ")} cannot be used with s3AuthenticationType '${input.s3AuthenticationType}'.`
34253
+ );
33947
34254
  }
33948
34255
  if (missing.length > 0) {
33949
34256
  throw new Error(
@@ -34036,9 +34343,11 @@ var logStreamingTools = [
34036
34343
  // The flag was previously never sent at all, so the server applied whatever
34037
34344
  // default it applies to an absent body -- undocumented either way. It now
34038
34345
  // goes on the wire explicitly, as the Go client always does. The default
34039
- // lives here rather than in a Zod `.default()` because handlers are called
34040
- // with the client's raw input, and `??` rather than `||` because false is a
34041
- // meaningful value.
34346
+ // lives here rather than in a Zod `.default()` because the tests call the
34347
+ // handlers directly, bypassing the schema, so a schema default would be
34348
+ // invisible to every body assertion (in production the SDK does parse the
34349
+ // input, so a `.default()` would apply there). `??` rather than `||`
34350
+ // because false is a meaningful value.
34042
34351
  handler: async (input) => {
34043
34352
  return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
34044
34353
  }
@@ -34118,7 +34427,7 @@ var postureTools = [
34118
34427
  openWorldHint: true
34119
34428
  },
34120
34429
  inputSchema: external_exports.object({
34121
- integrationId: external_exports.string().describe("The posture integration ID")
34430
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID")
34122
34431
  }),
34123
34432
  handler: async (input) => {
34124
34433
  return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34173,7 +34482,7 @@ var postureTools = [
34173
34482
  openWorldHint: true
34174
34483
  },
34175
34484
  inputSchema: external_exports.object({
34176
- integrationId: external_exports.string().describe("The posture integration ID to update"),
34485
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to update"),
34177
34486
  clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
34178
34487
  clientSecret: external_exports.string().optional().describe(
34179
34488
  "Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
@@ -34204,7 +34513,10 @@ var postureTools = [
34204
34513
  openWorldHint: true
34205
34514
  },
34206
34515
  inputSchema: external_exports.object({
34207
- integrationId: external_exports.string().describe("The posture integration ID to delete")
34516
+ integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to delete")
34517
+ // `.trim().min(1)`, not a bare string: a whitespace-only integrationId
34518
+ // encodes to "%20" and this irreversible delete returns a 404 that reads
34519
+ // like the integration is already gone (rationale: tailnets.ts delete_tailnet).
34208
34520
  }),
34209
34521
  handler: async (input) => {
34210
34522
  return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
@@ -34216,7 +34528,7 @@ var postureTools = [
34216
34528
  var serviceTools = [
34217
34529
  {
34218
34530
  name: "tailscale_list_services",
34219
- 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.",
34531
+ description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note: this MCP has no create-service tool. The API documents its Service PUT as create-or-update, but tailscale_update_service sends only the fields you pass, so do not use it to create one. Use the update/delete/approval tools here once the service exists.",
34220
34532
  annotations: {
34221
34533
  title: "List services",
34222
34534
  readOnlyHint: true,
@@ -34282,7 +34594,7 @@ var serviceTools = [
34282
34594
  },
34283
34595
  {
34284
34596
  name: "tailscale_delete_service",
34285
- description: "Delete a Tailscale Service. This is irreversible \u2014 the service's MagicDNS name and virtual IP will be released.",
34597
+ description: "Delete a Tailscale Service. This is irreversible -- the service's MagicDNS name and virtual IP will be released.",
34286
34598
  annotations: {
34287
34599
  title: "Delete service",
34288
34600
  readOnlyHint: false,
@@ -34291,7 +34603,10 @@ var serviceTools = [
34291
34603
  openWorldHint: true
34292
34604
  },
34293
34605
  inputSchema: external_exports.object({
34294
- serviceName: external_exports.string().describe("The service name to delete")
34606
+ // `.trim().min(1)`, not a bare string: a whitespace-only service name
34607
+ // encodes to "%20" and this irreversible delete returns a 404 that reads
34608
+ // like the service is already gone (rationale: tailnets.ts delete_tailnet).
34609
+ serviceName: external_exports.string().trim().min(1).describe("The service name to delete")
34295
34610
  }),
34296
34611
  handler: async (input) => {
34297
34612
  return apiDelete(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}`);
@@ -34372,6 +34687,12 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
34372
34687
  if (Object.keys(errors).length > 0) data.errors = errors;
34373
34688
  return data;
34374
34689
  }
34690
+ function fetchTailnetStatusInputs() {
34691
+ return Promise.all([
34692
+ apiGet(`/tailnet/${getTailnet()}/devices?fields=default`),
34693
+ apiGet(`/tailnet/${getTailnet()}/settings`)
34694
+ ]);
34695
+ }
34375
34696
  var statusTools = [
34376
34697
  {
34377
34698
  name: "tailscale_status",
@@ -34385,12 +34706,15 @@ var statusTools = [
34385
34706
  },
34386
34707
  inputSchema: external_exports.object({}),
34387
34708
  handler: async () => {
34388
- const [devicesRes, settingsRes] = await Promise.all([
34389
- apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
34390
- apiGet(`/tailnet/${getTailnet()}/settings`)
34391
- ]);
34709
+ const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
34392
34710
  if (!devicesRes.ok && !settingsRes.ok) {
34393
- return devicesRes;
34711
+ if (settingsRes.error === devicesRes.error && settingsRes.status === devicesRes.status) {
34712
+ return devicesRes;
34713
+ }
34714
+ return {
34715
+ ...devicesRes,
34716
+ error: `${devicesRes.error || `HTTP ${devicesRes.status}`} (the settings fetch also failed: ${settingsRes.error || `HTTP ${settingsRes.status}`})`
34717
+ };
34394
34718
  }
34395
34719
  const data = composeTailnetStatusData(devicesRes, settingsRes, {
34396
34720
  connected: true,
@@ -34431,7 +34755,7 @@ var tailnetTools = [
34431
34755
  inputSchema: external_exports.object({
34432
34756
  devicesApprovalOn: external_exports.boolean().optional().describe("Whether device approval is required"),
34433
34757
  devicesAutoUpdatesOn: external_exports.boolean().optional().describe("Whether auto-updates are enabled"),
34434
- devicesKeyDurationDays: external_exports.number().optional().describe("Key expiry duration in days"),
34758
+ devicesKeyDurationDays: external_exports.number().int().min(1).max(180).optional().describe("Key expiry duration in days (whole days, 1-180)"),
34435
34759
  usersApprovalOn: external_exports.boolean().optional().describe("Whether user approval is required"),
34436
34760
  usersRoleAllowedToJoinExternalTailnets: external_exports.enum(["none", "admin", "member"]).optional().describe("Which user roles can join external tailnets"),
34437
34761
  networkFlowLoggingOn: external_exports.boolean().optional().describe("Whether network flow logging is enabled"),
@@ -34499,7 +34823,7 @@ var tailnetTools = [
34499
34823
  const applied = {};
34500
34824
  const failed = {};
34501
34825
  for (const { contactType, res } of results) {
34502
- if (res.ok) applied[contactType] = res.data;
34826
+ if (res.ok) applied[contactType] = res.data ?? { status: res.status };
34503
34827
  else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
34504
34828
  }
34505
34829
  const hasFailed = Object.keys(failed).length > 0;
@@ -34635,135 +34959,6 @@ var tailnetsTools = [
34635
34959
  }
34636
34960
  ];
34637
34961
 
34638
- // src/tools/users.ts
34639
- var userTools = [
34640
- {
34641
- name: "tailscale_list_users",
34642
- description: "List all users in your tailnet.",
34643
- annotations: {
34644
- title: "List users",
34645
- readOnlyHint: true,
34646
- destructiveHint: false,
34647
- idempotentHint: true,
34648
- openWorldHint: true
34649
- },
34650
- inputSchema: external_exports.object({
34651
- type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
34652
- role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).optional().describe("Filter by user role")
34653
- }),
34654
- handler: async (input) => {
34655
- const params = new URLSearchParams();
34656
- if (input.type) params.set("type", input.type);
34657
- if (input.role) params.set("role", input.role);
34658
- const qs = params.toString();
34659
- return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
34660
- }
34661
- },
34662
- {
34663
- name: "tailscale_get_user",
34664
- description: "Get details for a specific user.",
34665
- annotations: {
34666
- title: "Get user",
34667
- readOnlyHint: true,
34668
- destructiveHint: false,
34669
- idempotentHint: true,
34670
- openWorldHint: true
34671
- },
34672
- inputSchema: external_exports.object({
34673
- userId: external_exports.string().describe("The user ID")
34674
- }),
34675
- handler: async (input) => {
34676
- return apiGet(`/users/${encPath(input.userId)}`);
34677
- }
34678
- },
34679
- {
34680
- name: "tailscale_approve_user",
34681
- description: "Approve a pending user, granting them access to the tailnet.",
34682
- annotations: {
34683
- title: "Approve user",
34684
- readOnlyHint: false,
34685
- destructiveHint: false,
34686
- idempotentHint: true,
34687
- openWorldHint: true
34688
- },
34689
- inputSchema: external_exports.object({
34690
- userId: external_exports.string().describe("The user ID to approve")
34691
- }),
34692
- handler: async (input) => {
34693
- return apiPost(`/users/${encPath(input.userId)}/approve`);
34694
- }
34695
- },
34696
- {
34697
- name: "tailscale_suspend_user",
34698
- description: "Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.",
34699
- annotations: {
34700
- title: "Suspend user",
34701
- readOnlyHint: false,
34702
- destructiveHint: true,
34703
- idempotentHint: true,
34704
- openWorldHint: true
34705
- },
34706
- inputSchema: external_exports.object({
34707
- userId: external_exports.string().describe("The user ID to suspend")
34708
- }),
34709
- handler: async (input) => {
34710
- return apiPost(`/users/${encPath(input.userId)}/suspend`);
34711
- }
34712
- },
34713
- {
34714
- name: "tailscale_restore_user",
34715
- description: "Restore a previously suspended user, re-granting them access to the tailnet.",
34716
- annotations: {
34717
- title: "Restore user",
34718
- readOnlyHint: false,
34719
- destructiveHint: false,
34720
- idempotentHint: true,
34721
- openWorldHint: true
34722
- },
34723
- inputSchema: external_exports.object({
34724
- userId: external_exports.string().describe("The user ID to restore")
34725
- }),
34726
- handler: async (input) => {
34727
- return apiPost(`/users/${encPath(input.userId)}/restore`);
34728
- }
34729
- },
34730
- {
34731
- name: "tailscale_update_user_role",
34732
- description: "Update a user's role in the tailnet.",
34733
- annotations: {
34734
- title: "Update user role",
34735
- readOnlyHint: false,
34736
- destructiveHint: false,
34737
- idempotentHint: true,
34738
- openWorldHint: true
34739
- },
34740
- inputSchema: external_exports.object({
34741
- userId: external_exports.string().describe("The user ID"),
34742
- role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).describe("The new role to assign")
34743
- }),
34744
- handler: async (input) => {
34745
- return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
34746
- }
34747
- },
34748
- {
34749
- name: "tailscale_delete_user",
34750
- description: "Delete a user from the tailnet. This is irreversible \u2014 the user and all their devices will be removed.",
34751
- annotations: {
34752
- title: "Delete user",
34753
- readOnlyHint: false,
34754
- destructiveHint: true,
34755
- idempotentHint: true,
34756
- openWorldHint: true
34757
- },
34758
- inputSchema: external_exports.object({
34759
- userId: external_exports.string().describe("The user ID to delete")
34760
- }),
34761
- handler: async (input) => {
34762
- return apiPost(`/users/${encPath(input.userId)}/delete`);
34763
- }
34764
- }
34765
- ];
34766
-
34767
34962
  // src/tools/webhooks.ts
34768
34963
  var STATIC_WEBHOOK_EVENT_TYPES = [
34769
34964
  "nodeCreated",
@@ -34792,7 +34987,13 @@ function getAllowedWebhookEvents() {
34792
34987
  const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
34793
34988
  return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
34794
34989
  }
34795
- var endpointUrlSchema = external_exports.url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
34990
+ var endpointUrlSchema = external_exports.url().refine((u) => {
34991
+ try {
34992
+ return new URL(u).protocol === "https:";
34993
+ } catch {
34994
+ return false;
34995
+ }
34996
+ }, "endpointUrl must use https://");
34796
34997
  var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
34797
34998
  const allowed = getAllowedWebhookEvents();
34798
34999
  let knownEventsList = null;
@@ -34839,7 +35040,7 @@ var webhookTools = [
34839
35040
  openWorldHint: true
34840
35041
  },
34841
35042
  inputSchema: external_exports.object({
34842
- webhookId: external_exports.string().describe("The webhook ID")
35043
+ webhookId: external_exports.string().trim().min(1).describe("The webhook ID")
34843
35044
  }),
34844
35045
  handler: async (input) => {
34845
35046
  return apiGet(`/webhooks/${encPath(input.webhookId)}`);
@@ -34884,7 +35085,7 @@ var webhookTools = [
34884
35085
  openWorldHint: true
34885
35086
  },
34886
35087
  inputSchema: external_exports.object({
34887
- webhookId: external_exports.string().describe("The webhook ID to update"),
35088
+ webhookId: external_exports.string().trim().min(1).describe("The webhook ID to update"),
34888
35089
  endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
34889
35090
  subscriptions: webhookSubscriptionsSchema.optional().describe(
34890
35091
  "Updated list of event types to subscribe to (at least one). 'categoryTailnetManagement' and 'categoryDeviceMisconfigurations' subscribe to a whole category, including events Tailscale adds to it later."
@@ -34902,7 +35103,7 @@ var webhookTools = [
34902
35103
  },
34903
35104
  {
34904
35105
  name: "tailscale_delete_webhook",
34905
- description: "Delete a webhook. This is irreversible \u2014 the webhook secret cannot be recovered.",
35106
+ description: "Delete a webhook. This is irreversible -- the webhook secret cannot be recovered.",
34906
35107
  annotations: {
34907
35108
  title: "Delete webhook",
34908
35109
  readOnlyHint: false,
@@ -34911,7 +35112,10 @@ var webhookTools = [
34911
35112
  openWorldHint: true
34912
35113
  },
34913
35114
  inputSchema: external_exports.object({
34914
- webhookId: external_exports.string().describe("The webhook ID to delete")
35115
+ // `.trim().min(1)`, not a bare string: a whitespace-only webhook id
35116
+ // encodes to "%20" and this irreversible delete returns a 404 that reads
35117
+ // like the webhook is already gone (rationale: tailnets.ts delete_tailnet).
35118
+ webhookId: external_exports.string().trim().min(1).describe("The webhook ID to delete")
34915
35119
  }),
34916
35120
  handler: async (input) => {
34917
35121
  return apiDelete(`/webhooks/${encPath(input.webhookId)}`);
@@ -34919,7 +35123,7 @@ var webhookTools = [
34919
35123
  },
34920
35124
  {
34921
35125
  name: "tailscale_rotate_webhook_secret",
34922
- description: "Rotate a webhook's secret. Returns the new secret \u2014 save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
35126
+ description: "Rotate a webhook's secret. Returns the new secret -- save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
34923
35127
  annotations: {
34924
35128
  title: "Rotate webhook secret",
34925
35129
  readOnlyHint: false,
@@ -34928,7 +35132,7 @@ var webhookTools = [
34928
35132
  openWorldHint: true
34929
35133
  },
34930
35134
  inputSchema: external_exports.object({
34931
- webhookId: external_exports.string().describe("The webhook ID whose secret to rotate")
35135
+ webhookId: external_exports.string().trim().min(1).describe("The webhook ID whose secret to rotate")
34932
35136
  }),
34933
35137
  handler: async (input) => {
34934
35138
  return apiPost(`/webhooks/${encPath(input.webhookId)}/rotate`);
@@ -34947,7 +35151,7 @@ var webhookTools = [
34947
35151
  openWorldHint: true
34948
35152
  },
34949
35153
  inputSchema: external_exports.object({
34950
- webhookId: external_exports.string().describe("The webhook ID to test")
35154
+ webhookId: external_exports.string().trim().min(1).describe("The webhook ID to test")
34951
35155
  }),
34952
35156
  handler: async (input) => {
34953
35157
  return apiPost(`/webhooks/${encPath(input.webhookId)}/test`);
@@ -35036,8 +35240,9 @@ function formatTailnetMismatchWarning(env) {
35036
35240
  return `TAILSCALE_OAUTH_TAILNET="${oauthTailnet}" but TAILSCALE_TAILNET="${explicit}". The OAuth token will be scoped to the former while tool requests are addressed to the latter, so every tailnet-scoped tool will fail with HTTP 403. Either unset TAILSCALE_TAILNET (or set it to "-") to follow the token, or set both to the same tailnet.`;
35037
35241
  }
35038
35242
  function formatBannerFilterSuffix(inputs) {
35039
- const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
35040
- const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
35243
+ const profileEnv = inputs.profileEnv?.trim();
35244
+ const profileValid = !!profileEnv && !inputs.unknownProfile;
35245
+ const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${profileEnv}` : null;
35041
35246
  const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
35042
35247
  return [
35043
35248
  profileLabel,
@@ -35055,6 +35260,11 @@ function wrapToolHandler(tool) {
35055
35260
  try {
35056
35261
  const result = await tool.handler(input);
35057
35262
  const response = result;
35263
+ if (typeof response?.ok !== "boolean") {
35264
+ return {
35265
+ content: [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }]
35266
+ };
35267
+ }
35058
35268
  if (!response.ok) {
35059
35269
  return {
35060
35270
  content: [
@@ -35080,10 +35290,7 @@ function wrapToolHandler(tool) {
35080
35290
  };
35081
35291
  }
35082
35292
  async function tailnetStatusResource(uri) {
35083
- const [devicesRes, settingsRes] = await Promise.all([
35084
- apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
35085
- apiGet(`/tailnet/${getTailnet()}/settings`)
35086
- ]);
35293
+ const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
35087
35294
  const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
35088
35295
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
35089
35296
  }
@@ -35132,7 +35339,7 @@ function explainNotLoaded(group, state) {
35132
35339
  toEnable: "set TAILSCALE_LOCAL_CLI=1"
35133
35340
  };
35134
35341
  }
35135
- if (state.toolsEnv) {
35342
+ if (parseGroupList(state.toolsEnv) && !state.toolsIgnored) {
35136
35343
  return {
35137
35344
  reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
35138
35345
  toEnable: `add "${group}" to TAILSCALE_TOOLS`
@@ -35156,10 +35363,16 @@ function explainWritesWithheld(group, state) {
35156
35363
  toEnable: "unset TAILSCALE_READONLY"
35157
35364
  };
35158
35365
  }
35159
- const current = state.writeGroupsEnv?.trim();
35366
+ const granted = parseGroupList(state.writeGroupsEnv);
35367
+ if (!granted) {
35368
+ return { reason: "writes are withheld in this group", toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS` };
35369
+ }
35370
+ const unknown2 = granted.filter((g) => !Object.hasOwn(state.fullRegistry, g));
35371
+ const known = granted.filter((g) => Object.hasOwn(state.fullRegistry, g));
35372
+ const unknownNote = unknown2.length > 0 ? `; ${unknown2.map((g) => `"${g}"`).join(", ")} ${unknown2.length > 1 ? "are not group names and grant" : "is not a group name and grants"} nothing` : "";
35160
35373
  return {
35161
- reason: current ? `TAILSCALE_WRITE_GROUPS is set to "${current}", which does not grant writes here` : "writes are withheld in this group",
35162
- toEnable: current ? `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${current},${group}")` : `add "${group}" to TAILSCALE_WRITE_GROUPS`
35374
+ reason: `TAILSCALE_WRITE_GROUPS is set to "${state.writeGroupsEnv?.trim()}", which does not grant writes here${unknownNote}`,
35375
+ toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${[...known, group].join(",")}")`
35163
35376
  };
35164
35377
  }
35165
35378
  function buildGroupReports(state) {
@@ -35248,8 +35461,8 @@ function buildMetaTools(state) {
35248
35461
  const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
35249
35462
  const activeFilters = [
35250
35463
  state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
35251
- state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
35252
- state.readonlyMode ? "TAILSCALE_READONLY=1" : null,
35464
+ state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}${state.toolsIgnored ? " (ignored: names no known group)" : ""}` : null,
35465
+ state.readonlyMode ? `TAILSCALE_READONLY=${state.readonlyEnv ?? "1"}` : null,
35253
35466
  state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
35254
35467
  state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
35255
35468
  ].filter(Boolean);
@@ -35289,7 +35502,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
35289
35502
  );
35290
35503
  process.exit(1);
35291
35504
  }
35292
- var version2 = true ? "0.21.2" : resolveVersionFallback();
35505
+ var version2 = true ? "0.23.0" : resolveVersionFallback();
35293
35506
  var subcommand = process.argv[2];
35294
35507
  var USAGE = `Usage: tailscale-mcp [command]
35295
35508
 
@@ -35318,6 +35531,9 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
35318
35531
  }
35319
35532
  const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
35320
35533
  run(filePath).then(() => process.exit(0)).catch((err) => {
35534
+ if (err instanceof Error && err.name === "CliFailure") {
35535
+ process.exit(1);
35536
+ }
35321
35537
  console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
35322
35538
  process.exit(1);
35323
35539
  });
@@ -35335,6 +35551,8 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
35335
35551
  if (!cliSubcommandHandled) {
35336
35552
  const localCliEnabled = isLocalCliEnabled(process.env);
35337
35553
  const toolGroups = buildToolGroups(process.env);
35554
+ const fullRegistry = buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" });
35555
+ const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
35338
35556
  const {
35339
35557
  tools: allTools,
35340
35558
  unknownGroups,
@@ -35353,6 +35571,10 @@ if (!cliSubcommandHandled) {
35353
35571
  profile: process.env.TAILSCALE_PROFILE,
35354
35572
  writeGroups: process.env.TAILSCALE_WRITE_GROUPS
35355
35573
  });
35574
+ const tailnetMismatch = formatTailnetMismatchWarning(process.env);
35575
+ if (tailnetMismatch) {
35576
+ console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
35577
+ }
35356
35578
  if (unknownGroups.length > 0) {
35357
35579
  const validNames = Object.keys(toolGroups);
35358
35580
  const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
@@ -35362,7 +35584,7 @@ if (!cliSubcommandHandled) {
35362
35584
  }
35363
35585
  if (unknownWriteGroups && unknownWriteGroups.length > 0) {
35364
35586
  const validNames = Object.keys(toolGroups);
35365
- const everyPossibleGroup = Object.keys(buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }));
35587
+ const everyPossibleGroup = Object.keys(fullRegistry);
35366
35588
  const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
35367
35589
  const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
35368
35590
  if (notEnabled.length > 0) {
@@ -35389,10 +35611,6 @@ if (!cliSubcommandHandled) {
35389
35611
  `@yawlabs/tailscale-mcp: internal inconsistency -- TAILSCALE_PROFILE="${process.env.TAILSCALE_PROFILE}" references group(s) that are not registered: ${unknownProfileGroups.join(", ")}. Those groups contributed no tools. This is a bug in @yawlabs/tailscale-mcp, not your configuration -- please report it at https://github.com/YawLabs/tailscale-mcp/issues.`
35390
35612
  );
35391
35613
  }
35392
- const tailnetMismatch = formatTailnetMismatchWarning(process.env);
35393
- if (tailnetMismatch) {
35394
- console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
35395
- }
35396
35614
  if (unknownProfile) {
35397
35615
  console.error(
35398
35616
  `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
@@ -35408,7 +35626,7 @@ if (!cliSubcommandHandled) {
35408
35626
  // that is currently disabled -- which is exactly the group an agent needs
35409
35627
  // explained. Reporting only what loaded would make local-cli invisible rather
35410
35628
  // than explained.
35411
- fullRegistry: buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }),
35629
+ fullRegistry,
35412
35630
  // Ground truth for availability: what this server actually serves. Deliberately
35413
35631
  // not a re-derivation of the filter logic, so the catalog cannot disagree with
35414
35632
  // the server about what exists.
@@ -35416,8 +35634,10 @@ if (!cliSubcommandHandled) {
35416
35634
  toolsEnv: process.env.TAILSCALE_TOOLS,
35417
35635
  profileEnv: process.env.TAILSCALE_PROFILE,
35418
35636
  writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
35419
- readonlyMode: parseReadonlyFlag(process.env.TAILSCALE_READONLY),
35420
- localCliEnabled
35637
+ readonlyMode,
35638
+ readonlyEnv: process.env.TAILSCALE_READONLY,
35639
+ localCliEnabled,
35640
+ toolsIgnored: toolsAllUnknown
35421
35641
  });
35422
35642
  for (const tool of metaTools) {
35423
35643
  server.registerTool(
@@ -35482,7 +35702,6 @@ if (!cliSubcommandHandled) {
35482
35702
  `);
35483
35703
  process.exit(1);
35484
35704
  });
35485
- const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
35486
35705
  const filterSuffix = formatBannerFilterSuffix({
35487
35706
  unknownProfile,
35488
35707
  explicitTools,
@@ -35496,15 +35715,22 @@ if (!cliSubcommandHandled) {
35496
35715
  console.error(
35497
35716
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
35498
35717
  );
35499
- const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
35500
- const ADMIN_EQUIVALENT = ["keys", "users", "acl"];
35718
+ const hasCreds = hasUsableCredentials();
35719
+ const ADMIN_EQUIVALENT = {
35720
+ keys: "tailscale_create_key mints an OAuth client with any scopes the caller asks for",
35721
+ users: 'tailscale_update_user_role accepts "owner"',
35722
+ acl: "tailscale_update_acl rewrites policy for every principal"
35723
+ };
35501
35724
  const registeredNames = new Set(allTools.map((t) => t.name));
35502
- const adminWritable = ADMIN_EQUIVALENT.filter(
35725
+ const adminWritable = Object.keys(ADMIN_EQUIVALENT).filter(
35503
35726
  (g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
35504
35727
  );
35505
35728
  if (adminWritable.length > 0 && hasCreds) {
35729
+ const examples = adminWritable.map((g) => ADMIN_EQUIVALENT[g]);
35730
+ const last = examples.pop();
35731
+ const exampleText = examples.length === 0 ? last : `${examples.join(", ")}${examples.length > 1 ? "," : ""} and ${last}`;
35506
35732
  console.error(
35507
- `@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent. tailscale_create_key mints an OAuth client with any scopes the caller asks for, tailscale_update_user_role accepts "owner", and tailscale_update_acl rewrites policy for every principal. Scope the Tailscale OAuth client itself to the areas you need -- that bound survives outside this process; this one does not. TAILSCALE_WRITE_GROUPS narrows what this server exposes.`
35733
+ `@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent. ${exampleText}. Scope the Tailscale OAuth client itself to the areas you need -- that bound survives outside this process; this one does not. TAILSCALE_WRITE_GROUPS narrows what this server exposes.`
35508
35734
  );
35509
35735
  }
35510
35736
  if (!filterSuffix && hasCreds) {