@yawlabs/tailscale-mcp 0.20.2 → 0.21.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
@@ -31297,7 +31297,9 @@ var DEFAULT_429_DELAY_MS = 1e3;
31297
31297
  var MAX_429_DELAY_MS = 3e4;
31298
31298
  var MAX_429_JITTER_MS = 250;
31299
31299
  var MAX_REQUEST_BUDGET_MS = 9e4;
31300
+ var GATEWAY_5XX_BUDGET_FRACTION = 0.5;
31300
31301
  var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
31302
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
31301
31303
  var oauthToken = null;
31302
31304
  var oauthRefreshPromise = null;
31303
31305
  function getAuthConfig() {
@@ -31351,7 +31353,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31351
31353
  });
31352
31354
  if (!res.ok) {
31353
31355
  const body = await res.text();
31354
- 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://console.tailscale.com/admin/settings/oauth)." + (oauthTailnet ? ` Targeting tailnet "${oauthTailnet}" via TAILSCALE_OAUTH_TAILNET -- that requires an OAuth client from the CREATING tailnet with the 'all' scope.` : "") : "";
31356
+ 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://console.tailscale.com/admin/settings/trust-credentials)." + (oauthTailnet ? ` Targeting tailnet "${oauthTailnet}" via TAILSCALE_OAUTH_TAILNET -- that requires an OAuth client from the CREATING tailnet with the 'all' scope.` : "") : "";
31355
31357
  throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
31356
31358
  }
31357
31359
  const data = await res.json();
@@ -31385,6 +31387,7 @@ function getTailnet() {
31385
31387
  function encPath(segment) {
31386
31388
  return encodeURIComponent(segment);
31387
31389
  }
31390
+ var DEVICE_ID_HINT = "nodeId from tailscale_list_devices (e.g. nPM2KNuedB21DEVEL); numeric id ok; not the nodeKey.";
31388
31391
  function validateTags(tags) {
31389
31392
  if (!tags || tags.length === 0) return;
31390
31393
  const invalid = tags.filter((t) => !t.startsWith("tag:"));
@@ -31403,6 +31406,7 @@ function validateAndSanitizeDescription(value) {
31403
31406
  `description ${JSON.stringify(value)} contains no valid characters after sanitization. Allowed characters: alphanumeric, spaces, and hyphens (max 50 chars).`
31404
31407
  );
31405
31408
  }
31409
+ var OAUTH_SCOPE_TABLE_HEADING = "OAuth scopes by tool group";
31406
31410
  function formatAuthError(status, apiBody) {
31407
31411
  let usingOAuth = false;
31408
31412
  try {
@@ -31411,7 +31415,7 @@ function formatAuthError(status, apiBody) {
31411
31415
  usingOAuth = false;
31412
31416
  }
31413
31417
  const headline = status === 401 ? "Authentication failed (HTTP 401)." : "Authorization failed (HTTP 403): the request was authenticated but not permitted for this resource.";
31414
- const cause = status === 401 ? usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked" : usingOAuth ? " - OAuth client is missing a scope required for this endpoint" : " - API key lacks the permission required for this endpoint";
31418
+ const cause = status === 401 ? usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked" : usingOAuth ? ` - OAuth client is missing a scope required for this endpoint (scopes per tool group: README, "${OAUTH_SCOPE_TABLE_HEADING}")` : " - API key lacks the permission required for this endpoint";
31415
31419
  const lines = [headline, "", "Possible causes:", cause];
31416
31420
  if (status === 401 && process.platform === "win32" && !usingOAuth) {
31417
31421
  lines.push(
@@ -31422,13 +31426,89 @@ function formatAuthError(status, apiBody) {
31422
31426
  " 2. Set TAILSCALE_API_KEY as a Windows user environment variable (System Properties > Environment Variables)"
31423
31427
  );
31424
31428
  }
31425
- const link = status === 401 ? "Generate a new key at: https://console.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the OAuth client scopes at: https://console.tailscale.com/admin/settings/oauth" : "Adjust the API key permissions at: https://console.tailscale.com/admin/settings/keys";
31429
+ const link = status === 401 ? "Generate a new key at: https://console.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the credential's scopes at: https://console.tailscale.com/admin/settings/trust-credentials" : "Adjust the API key permissions at: https://console.tailscale.com/admin/settings/keys";
31426
31430
  lines.push("", link);
31427
31431
  if (apiBody) {
31428
31432
  lines.push("", `API response: ${apiBody}`);
31429
31433
  }
31430
31434
  return lines.join("\n");
31431
31435
  }
31436
+ var ERROR_DATA_MAX_LINES = 100;
31437
+ var ERROR_DATA_MAX_CHARS = 8e3;
31438
+ var ERROR_DATA_MAX_JSON_CHARS = 1e3;
31439
+ var SENSITIVE_DATA_KEY = /secret|token|password|credential|key/i;
31440
+ function redactSensitive(value) {
31441
+ if (Array.isArray(value)) return value.map(redactSensitive);
31442
+ if (value === null || typeof value !== "object") return value;
31443
+ const out = {};
31444
+ for (const [key, inner] of Object.entries(value)) {
31445
+ out[key] = SENSITIVE_DATA_KEY.test(key) ? "[redacted]" : redactSensitive(inner);
31446
+ }
31447
+ return out;
31448
+ }
31449
+ function asStringArray(value) {
31450
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : void 0;
31451
+ }
31452
+ function renderJsonEntry(value) {
31453
+ const json2 = JSON.stringify(redactSensitive(value)) ?? String(value);
31454
+ if (json2.length <= ERROR_DATA_MAX_JSON_CHARS) return json2;
31455
+ return `${json2.slice(0, ERROR_DATA_MAX_JSON_CHARS)}... (${json2.length - ERROR_DATA_MAX_JSON_CHARS} more characters)`;
31456
+ }
31457
+ function joinErrorDataBlocks(blocks) {
31458
+ const out = [];
31459
+ let lines = 0;
31460
+ let chars = 0;
31461
+ let shown = 0;
31462
+ for (const block of blocks) {
31463
+ const blockChars = block.reduce((n, line) => n + line.length + 1, 0);
31464
+ if (lines + block.length <= ERROR_DATA_MAX_LINES && chars + blockChars <= ERROR_DATA_MAX_CHARS) {
31465
+ out.push(...block);
31466
+ lines += block.length;
31467
+ chars += blockChars;
31468
+ shown++;
31469
+ continue;
31470
+ }
31471
+ if (shown === 0) {
31472
+ const kept = block.slice(0, ERROR_DATA_MAX_LINES);
31473
+ out.push(...kept);
31474
+ if (kept.length < block.length) out.push(`... and ${block.length - kept.length} more lines in this entry`);
31475
+ shown++;
31476
+ }
31477
+ break;
31478
+ }
31479
+ const dropped = blocks.length - shown;
31480
+ if (dropped > 0) out.push(`... and ${dropped} more ${dropped === 1 ? "entry" : "entries"}`);
31481
+ return out.join("\n");
31482
+ }
31483
+ function formatApiErrorData(data) {
31484
+ if (!Array.isArray(data) || data.length === 0) return "";
31485
+ const blocks = [];
31486
+ for (const entry of data) {
31487
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
31488
+ blocks.push([renderJsonEntry(entry)]);
31489
+ continue;
31490
+ }
31491
+ const obj = entry;
31492
+ const consumed = /* @__PURE__ */ new Set();
31493
+ const rendered = [];
31494
+ if (typeof obj.user === "string") {
31495
+ consumed.add("user");
31496
+ if (obj.user.length > 0) rendered.push(`For user ${obj.user}:`);
31497
+ }
31498
+ for (const [key, heading] of [
31499
+ ["errors", "Errors found:"],
31500
+ ["warnings", "Warnings found:"]
31501
+ ]) {
31502
+ const items = asStringArray(obj[key]);
31503
+ if (!items) continue;
31504
+ consumed.add(key);
31505
+ if (items.length > 0) rendered.push(heading, ...items.map((item) => `- ${item}`));
31506
+ }
31507
+ if (rendered.length > 0 && Object.keys(obj).every((key) => consumed.has(key))) blocks.push(rendered);
31508
+ else blocks.push([renderJsonEntry(obj)]);
31509
+ }
31510
+ return joinErrorDataBlocks(blocks);
31511
+ }
31432
31512
  function extractErrorMessage(body) {
31433
31513
  if (!body) return body;
31434
31514
  const trimmed = body.trim();
@@ -31437,8 +31517,12 @@ function extractErrorMessage(body) {
31437
31517
  const parsed = JSON.parse(trimmed);
31438
31518
  if (parsed && typeof parsed === "object") {
31439
31519
  const obj = parsed;
31440
- if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
31441
- if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
31520
+ const message = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : typeof obj.error === "string" && obj.error.length > 0 ? obj.error : void 0;
31521
+ if (message !== void 0) {
31522
+ const details = formatApiErrorData(obj.data);
31523
+ return details ? `${message}
31524
+ ${details}` : message;
31525
+ }
31442
31526
  }
31443
31527
  } catch {
31444
31528
  }
@@ -31536,6 +31620,15 @@ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
31536
31620
  }
31537
31621
  return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
31538
31622
  }
31623
+ function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, priorTransportError) {
31624
+ if (status !== 404 || method.toUpperCase() !== "DELETE") return error51;
31625
+ const causes = [];
31626
+ if (priorGatewayStatus !== void 0) causes.push(`returned HTTP ${priorGatewayStatus}`);
31627
+ if (priorTransportError !== void 0) causes.push(`never returned a response (${priorTransportError})`);
31628
+ if (causes.length === 0) return error51;
31629
+ const subject = causes.length > 1 ? "earlier attempts" : "an earlier attempt";
31630
+ return `${error51 || `HTTP ${status}`} (${subject} ${causes.join(" and ")}; the delete may already have succeeded)`;
31631
+ }
31539
31632
  async function apiRequest(method, path, body, options) {
31540
31633
  const headers = {};
31541
31634
  if (options?.accept) {
@@ -31569,16 +31662,20 @@ async function apiRequest(method, path, body, options) {
31569
31662
  headers.Authorization = await getAuthHeader();
31570
31663
  let res;
31571
31664
  let lastTransportError;
31665
+ let priorGatewayStatus;
31666
+ let priorTransportError;
31667
+ let budgetMs = requestBudgetMs;
31572
31668
  for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
31573
- const remaining = requestBudgetMs - (Date.now() - startedAt);
31669
+ const remaining = budgetMs - (Date.now() - startedAt);
31574
31670
  if (remaining <= 0) {
31575
31671
  return {
31576
31672
  ok: false,
31577
31673
  status: 0,
31578
- error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
31674
+ error: describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError)
31579
31675
  };
31580
31676
  }
31581
31677
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
31678
+ const attemptStartedAt = Date.now();
31582
31679
  let attemptRes;
31583
31680
  try {
31584
31681
  attemptRes = await executeFetch(method, url2, headers, fetchBody, attemptTimeoutMs);
@@ -31590,25 +31687,35 @@ async function apiRequest(method, path, body, options) {
31590
31687
  }
31591
31688
  const delay2 = compute429DelayMs(null, attempt);
31592
31689
  const elapsed3 = Date.now() - startedAt;
31593
- if (requestBudgetMs - elapsed3 - delay2 <= 0) {
31690
+ if (budgetMs - elapsed3 - delay2 <= 0) {
31594
31691
  return { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
31595
31692
  }
31596
31693
  debugLog(
31597
31694
  ` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
31598
31695
  );
31696
+ priorTransportError = desc;
31599
31697
  await new Promise((r) => setTimeout(r, delay2));
31600
31698
  continue;
31601
31699
  }
31602
31700
  res = attemptRes;
31603
- if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
31701
+ if (!RETRYABLE_STATUSES.has(res.status) || attempt === MAX_429_RETRIES || !isRetryable) break;
31604
31702
  const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
31703
+ const gatewayCeilingMs = Math.min(budgetMs, Math.floor(requestBudgetMs * GATEWAY_5XX_BUDGET_FRACTION));
31704
+ const retryCeilingMs = res.status === 429 ? budgetMs : gatewayCeilingMs;
31605
31705
  const elapsed2 = Date.now() - startedAt;
31606
- const nextAttemptBudgetMs = requestBudgetMs - elapsed2 - delay;
31706
+ const predictedCostMs = res.status === 429 ? delay : delay + (Date.now() - attemptStartedAt);
31707
+ const nextAttemptBudgetMs = retryCeilingMs - elapsed2 - predictedCostMs;
31607
31708
  if (nextAttemptBudgetMs <= 0) {
31608
- debugLog(` -> 429 (attempt ${attempt + 1}), giving up: budget exhausted (${elapsed2}ms + ${delay}ms)`);
31709
+ debugLog(
31710
+ ` -> ${res.status} (attempt ${attempt + 1}), giving up: budget exhausted (${elapsed2}ms + ${predictedCostMs}ms of ${retryCeilingMs}ms)`
31711
+ );
31609
31712
  break;
31610
31713
  }
31611
- debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
31714
+ debugLog(` -> ${res.status} (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
31715
+ if (res.status !== 429) {
31716
+ priorGatewayStatus = res.status;
31717
+ budgetMs = retryCeilingMs;
31718
+ }
31612
31719
  await res.text().catch(() => void 0);
31613
31720
  await new Promise((r) => setTimeout(r, delay));
31614
31721
  }
@@ -31624,14 +31731,25 @@ async function apiRequest(method, path, body, options) {
31624
31731
  const rawBody = await response.text();
31625
31732
  if (!response.ok) {
31626
31733
  const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, rawBody) : extractErrorMessage(rawBody);
31627
- return { ok: false, status: response.status, error: error51, rawBody, etag };
31734
+ return {
31735
+ ok: false,
31736
+ status: response.status,
31737
+ error: annotateAmbiguousDelete(error51, response.status, method, priorGatewayStatus, priorTransportError),
31738
+ rawBody,
31739
+ etag
31740
+ };
31628
31741
  }
31629
31742
  return { ok: true, status: response.status, rawBody, etag };
31630
31743
  }
31631
31744
  if (!response.ok) {
31632
31745
  const errorBody = await response.text();
31633
31746
  const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, errorBody) : extractErrorMessage(errorBody);
31634
- return { ok: false, status: response.status, error: error51, etag };
31747
+ return {
31748
+ ok: false,
31749
+ status: response.status,
31750
+ error: annotateAmbiguousDelete(error51, response.status, method, priorGatewayStatus, priorTransportError),
31751
+ etag
31752
+ };
31635
31753
  }
31636
31754
  if (response.status === 204 || response.headers.get("content-length") === "0") {
31637
31755
  return { ok: true, status: response.status, etag };
@@ -31665,26 +31783,43 @@ async function apiDelete(path, options) {
31665
31783
  }
31666
31784
 
31667
31785
  // src/cli.ts
31786
+ function isWarningsOnly(message, data) {
31787
+ if (!/^warning/i.test(message)) return false;
31788
+ if (!Array.isArray(data) || data.length === 0) return false;
31789
+ return data.every((entry) => {
31790
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
31791
+ const obj = entry;
31792
+ if (!Object.keys(obj).every((key) => key === "user" || key === "warnings")) return false;
31793
+ if (obj.user !== void 0 && typeof obj.user !== "string") return false;
31794
+ return Array.isArray(obj.warnings) && obj.warnings.length > 0 && obj.warnings.every((w) => typeof w === "string");
31795
+ });
31796
+ }
31668
31797
  function parseValidationError(rawBody) {
31669
31798
  const trimmed = rawBody?.trim();
31670
- if (!trimmed) return void 0;
31799
+ if (!trimmed) return { kind: "valid" };
31671
31800
  let parsed;
31672
31801
  try {
31673
31802
  parsed = JSON.parse(trimmed);
31674
31803
  } catch {
31675
- return trimmed;
31804
+ return { kind: "failure", message: trimmed };
31676
31805
  }
31677
31806
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
31678
- return trimmed;
31807
+ return { kind: "failure", message: trimmed };
31679
31808
  }
31680
31809
  const obj = parsed;
31681
- if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
31682
- if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
31683
- return void 0;
31810
+ const message = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : typeof obj.error === "string" && obj.error.length > 0 ? obj.error : void 0;
31811
+ if (message === void 0) return { kind: "valid" };
31812
+ const details = formatApiErrorData(obj.data);
31813
+ return {
31814
+ kind: isWarningsOnly(message, obj.data) ? "warnings" : "failure",
31815
+ message,
31816
+ ...details ? { details } : {}
31817
+ };
31684
31818
  }
31685
31819
  function readPolicyFile(filePath) {
31686
31820
  try {
31687
- return readFileSync(filePath, "utf-8");
31821
+ const text = readFileSync(filePath, "utf-8");
31822
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
31688
31823
  } catch (err) {
31689
31824
  console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
31690
31825
  process.exit(1);
@@ -31701,9 +31836,10 @@ async function validatePolicy(policy) {
31701
31836
  console.error(`ACL validation failed: ${validateRes.error}`);
31702
31837
  process.exit(1);
31703
31838
  }
31704
- const validationError = parseValidationError(validateRes.rawBody);
31705
- if (validationError) {
31706
- console.error(`ACL validation failed: ${validationError}`);
31839
+ const validation = parseValidationError(validateRes.rawBody);
31840
+ if (validation.kind !== "valid") {
31841
+ console.error(`ACL validation failed: ${validation.message}${validation.details ? `
31842
+ ${validation.details}` : ""}`);
31707
31843
  process.exit(1);
31708
31844
  }
31709
31845
  }
@@ -31873,6 +32009,17 @@ function stripEtagFooter(body) {
31873
32009
  }
31874
32010
  return lines.slice(0, cut).join("\n");
31875
32011
  }
32012
+ function normalizeIfMatch(etag) {
32013
+ const trimmed = etag.trim();
32014
+ if (trimmed.startsWith("W/")) return trimmed;
32015
+ const inner = trimmed.replace(/^"+|"+$/g, "").trim();
32016
+ if (!inner) {
32017
+ throw new Error(
32018
+ "etag is empty once its quotes are removed -- a value with nothing inside its quotes cannot match any ETag the tailnet holds, so this overwrite would come back 412."
32019
+ );
32020
+ }
32021
+ return `"${inner}"`;
32022
+ }
31876
32023
  var aclTools = [
31877
32024
  {
31878
32025
  name: "tailscale_get_acl",
@@ -31905,7 +32052,7 @@ var aclTools = [
31905
32052
  },
31906
32053
  {
31907
32054
  name: "tailscale_update_acl",
31908
- description: "Update the ACL policy for your tailnet. Accepts the full policy as a string to preserve formatting, comments, and trailing commas (HuJSON). You MUST pass the ETag from tailscale_get_acl to prevent overwriting concurrent changes. Always get the current ACL first, make targeted edits to the text, and pass the full modified text back.",
32055
+ description: "Update the ACL policy for your tailnet. Accepts the full policy as a string to preserve formatting, comments, and trailing commas (HuJSON). You MUST pass the ETag from tailscale_get_acl to prevent overwriting concurrent changes, or `ts-default` for the first write to a tailnet nobody has edited yet. Always get the current ACL first, make targeted edits to the text, and pass the full modified text back.",
31909
32056
  annotations: {
31910
32057
  title: "Update ACL policy",
31911
32058
  readOnlyHint: false,
@@ -31920,7 +32067,9 @@ var aclTools = [
31920
32067
  policy: external_exports.string().describe(
31921
32068
  "The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
31922
32069
  ),
31923
- etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
32070
+ etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe(
32071
+ "The ETag from tailscale_get_acl (quotes optional -- they are normalized). Required to prevent concurrent edit conflicts. For the FIRST write to a fresh tailnet you may pass `ts-default` instead: the update then succeeds only if the policy file is still Tailscale's untouched default."
32072
+ )
31924
32073
  }),
31925
32074
  // `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
31926
32075
  // `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
@@ -31930,11 +32079,15 @@ var aclTools = [
31930
32079
  // on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
31931
32080
  // header goes out carrying a precondition that cannot match any real ETag --
31932
32081
  // a confusing 412 instead of a local validation error naming the field.
32082
+ // Quote normalization lives in the handler rather than in the schema because
32083
+ // this is the code that builds the header, and because the handlers are what
32084
+ // the tests call directly -- a transform on the schema would be invisible to
32085
+ // every assertion made at the header.
31933
32086
  handler: async (input) => {
31934
32087
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31935
32088
  rawBody: input.policy,
31936
32089
  contentType: "application/hujson",
31937
- ifMatch: input.etag,
32090
+ ifMatch: normalizeIfMatch(input.etag),
31938
32091
  acceptRaw: true,
31939
32092
  accept: "application/hujson"
31940
32093
  });
@@ -32165,9 +32318,9 @@ function assertRFC3339(value, label) {
32165
32318
  }
32166
32319
  }
32167
32320
  var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
32168
- function assertLogRange(start, end, label) {
32321
+ function assertLogRange(start, end, label, nowMs = Date.now()) {
32169
32322
  const startMs = Date.parse(start);
32170
- const endMs = end ? Date.parse(end) : Date.now();
32323
+ const endMs = end ? Date.parse(end) : nowMs;
32171
32324
  if (endMs < startMs) {
32172
32325
  throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
32173
32326
  }
@@ -32177,10 +32330,14 @@ function assertLogRange(start, end, label) {
32177
32330
  );
32178
32331
  }
32179
32332
  }
32333
+ function isoSecond(now) {
32334
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
32335
+ return `${pad(now.getUTCFullYear(), 4)}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())}Z`;
32336
+ }
32180
32337
  var auditTools = [
32181
32338
  {
32182
32339
  name: "tailscale_get_audit_log",
32183
- description: "Get the tailnet audit/configuration log. Shows who changed what and when \u2014 useful for troubleshooting and compliance.",
32340
+ description: "Get the tailnet audit/configuration log. Shows who changed what and when -- useful for troubleshooting and compliance. Optional actor, target and event filters narrow the query server-side, so a targeted question doesn't have to pull the whole window.",
32184
32341
  annotations: {
32185
32342
  title: "Get audit log",
32186
32343
  readOnlyHint: true,
@@ -32188,16 +32345,46 @@ var auditTools = [
32188
32345
  idempotentHint: true,
32189
32346
  openWorldHint: true
32190
32347
  },
32348
+ // The three filters are free strings rather than the spec's 138-value event
32349
+ // enum: that list is still growing (the PAM_* entries are recent), and a
32350
+ // hard enum would make a new event type unqueryable rather than merely
32351
+ // unvalidated. `.trim().min(1)` is the house idiom -- a bare min(1) admits
32352
+ // " ", which goes on the wire as `event=+...` and comes back empty with no
32353
+ // error.
32354
+ //
32355
+ // `.max(1)` for now. The spec declares no style/explode on these array
32356
+ // parameters, so repeated keys are inferred from the OpenAPI default and
32357
+ // nothing upstream corroborates it: neither the Go client nor Terraform
32358
+ // implements this endpoint, and the KB page documents only start and end. A
32359
+ // single value is wire-identical whether the server wants repeated keys or a
32360
+ // comma-joined list, so it cannot under-report; two values under the wrong
32361
+ // guess would return a subset with no error, which in a compliance query
32362
+ // reads as "that change never happened". Lift the cap once a live call
32363
+ // settles it.
32191
32364
  inputSchema: external_exports.object({
32192
32365
  start: external_exports.string().describe("Start time in RFC3339 format (e.g. '2026-04-01T00:00:00Z'). Required."),
32193
- end: external_exports.string().optional().describe("End time in RFC3339 format. Defaults to now.")
32366
+ end: external_exports.string().optional().describe(
32367
+ "End time in RFC3339 format. Optional: when omitted the tool sends the current time, which Tailscale's API requires."
32368
+ ),
32369
+ actor: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
32370
+ "Server-side filter: one exact actor ID, or '~text' to wildcard-match a login or display name (e.g. '~bob'). One value per call -- how the API reads a repeated filter key is not verified yet."
32371
+ ),
32372
+ target: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
32373
+ "Server-side filter: one string, matched against any part of any of an entry's targets (ID or name). One value per call, as for actor."
32374
+ ),
32375
+ event: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
32376
+ "Server-side filter: one event type from Tailscale's audit event list, e.g. 'TAILNET.UPDATE.ACL', 'TAILNET.UPDATE.DNS_CONFIG', 'NODE.CREATE', 'NODE.DELETE', 'API_KEY.CREATE', 'USER.UPDATE.USER_ROLE', 'WEBHOOK_ENDPOINT.CREATE'. Not a closed set -- the list keeps growing. One value per call, as for actor."
32377
+ )
32194
32378
  }),
32195
32379
  handler: async (input) => {
32196
32380
  assertRFC3339(input.start, "start");
32197
32381
  if (input.end) assertRFC3339(input.end, "end");
32198
- assertLogRange(input.start, input.end, "tailscale_get_audit_log");
32199
- const params = new URLSearchParams({ start: input.start });
32200
- if (input.end) params.set("end", input.end);
32382
+ const wireEnd = input.end ? input.end : isoSecond(/* @__PURE__ */ new Date());
32383
+ assertLogRange(input.start, input.end, "tailscale_get_audit_log", Date.parse(wireEnd));
32384
+ const params = new URLSearchParams({ start: input.start, end: wireEnd });
32385
+ for (const actor of input.actor ?? []) params.append("actor", actor);
32386
+ for (const target of input.target ?? []) params.append("target", target);
32387
+ for (const event of input.event ?? []) params.append("event", event);
32201
32388
  return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
32202
32389
  }
32203
32390
  },
@@ -32211,16 +32398,20 @@ var auditTools = [
32211
32398
  idempotentHint: true,
32212
32399
  openWorldHint: true
32213
32400
  },
32401
+ // The spec gives this endpoint no actor/target/event filters -- they are on
32402
+ // the configuration log only.
32214
32403
  inputSchema: external_exports.object({
32215
32404
  start: external_exports.string().describe("Start time in RFC3339 format (e.g. '2026-04-01T00:00:00Z'). Required."),
32216
- end: external_exports.string().optional().describe("End time in RFC3339 format. Defaults to now.")
32405
+ end: external_exports.string().optional().describe(
32406
+ "End time in RFC3339 format. Optional: when omitted the tool sends the current time, which Tailscale's API requires."
32407
+ )
32217
32408
  }),
32218
32409
  handler: async (input) => {
32219
32410
  assertRFC3339(input.start, "start");
32220
32411
  if (input.end) assertRFC3339(input.end, "end");
32221
- assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs");
32222
- const params = new URLSearchParams({ start: input.start });
32223
- if (input.end) params.set("end", input.end);
32412
+ const wireEnd = input.end ? input.end : isoSecond(/* @__PURE__ */ new Date());
32413
+ assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs", Date.parse(wireEnd));
32414
+ const params = new URLSearchParams({ start: input.start, end: wireEnd });
32224
32415
  return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
32225
32416
  }
32226
32417
  }
@@ -32239,10 +32430,12 @@ function isCidr(s) {
32239
32430
  if (net.isIPv6(addr)) return prefixN <= 128;
32240
32431
  return false;
32241
32432
  }
32433
+ 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.";
32434
+ var DEVICE_LAST_SEEN_NOTE = " 'lastSeen' is omitted while a device is connected (connectedToControl: true) and for devices that have never been online -- on a connected device a missing lastSeen means online now, not never seen.";
32242
32435
  var deviceTools = [
32243
32436
  {
32244
32437
  name: "tailscale_list_devices",
32245
- description: "List all devices in your tailnet with their status, IP addresses, OS, and last seen time.",
32438
+ description: `List all devices in your tailnet with their status, IP addresses, OS, and last seen time.${DEVICE_LAST_SEEN_NOTE}`,
32246
32439
  annotations: {
32247
32440
  title: "List devices",
32248
32441
  readOnlyHint: true,
@@ -32251,11 +32444,9 @@ var deviceTools = [
32251
32444
  openWorldHint: true
32252
32445
  },
32253
32446
  inputSchema: external_exports.object({
32254
- fields: external_exports.string().optional().describe(
32255
- "Comma-separated list of fields to include. Omit for all fields. Valid fields: addresses, advertisedRoutes, authorized, blocksIncomingConnections, clientConnectivity, clientVersion, connectedToControl, created, distro, enabledRoutes, expires, hostname, id, isExternal, keyExpiryDisabled, lastSeen, machineKey, name, nodeId, nodeKey, os, sshEnabled, tags, tailnetLockError, tailnetLockKey, updateAvailable, user. Use 'all' for every field."
32256
- ),
32257
- filters: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
32258
- "Server-side filters as key-value pairs. Filter by any top-level device property (e.g. { isEphemeral: 'true', os: 'linux', tags: 'tag:prod' }). Multiple filters are ANDed together."
32447
+ fields: external_exports.string().optional().describe(`${DEVICE_FIELDS_DESC} Any other value is forwarded unvalidated; Tailscale documents none.`),
32448
+ filters: external_exports.record(external_exports.string(), external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)])).optional().describe(
32449
+ "Server-side filters on top-level device properties, exact match only (e.g. { isEphemeral: 'true', os: 'linux' }). All filters are ANDed. Pass an array to repeat a key: { tags: ['tag:prod', 'tag:subnetrouter'] } sends tags=..&tags=.. and matches devices whose tags contain BOTH. Properties that are complex objects (e.g. clientConnectivity) cannot be filtered; repeating a key on a non-list property is undocumented upstream."
32259
32450
  )
32260
32451
  }),
32261
32452
  handler: async (input) => {
@@ -32268,7 +32459,7 @@ var deviceTools = [
32268
32459
  "filters.fields is not allowed -- use the top-level 'fields' parameter to select which device fields to return."
32269
32460
  );
32270
32461
  }
32271
- params.set(key, value);
32462
+ for (const one of Array.isArray(value) ? value : [value]) params.append(key, one);
32272
32463
  }
32273
32464
  }
32274
32465
  const qs = params.toString();
@@ -32277,7 +32468,7 @@ var deviceTools = [
32277
32468
  },
32278
32469
  {
32279
32470
  name: "tailscale_get_device",
32280
- description: "Get detailed information about a specific device by its ID.",
32471
+ description: `Get detailed information about a specific device by its ID. Returns the default field subset unless fields: 'all'.${DEVICE_LAST_SEEN_NOTE}`,
32281
32472
  annotations: {
32282
32473
  title: "Get device",
32283
32474
  readOnlyHint: true,
@@ -32286,10 +32477,14 @@ var deviceTools = [
32286
32477
  openWorldHint: true
32287
32478
  },
32288
32479
  inputSchema: external_exports.object({
32289
- deviceId: external_exports.string().describe("The device ID (numeric id or nodeId, NOT the nodeKey)")
32480
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32481
+ fields: external_exports.enum(["all", "default"]).optional().describe(DEVICE_FIELDS_DESC)
32290
32482
  }),
32291
32483
  handler: async (input) => {
32292
- return apiGet(`/device/${encPath(input.deviceId)}`);
32484
+ const params = new URLSearchParams();
32485
+ if (input.fields) params.set("fields", input.fields);
32486
+ const qs = params.toString();
32487
+ return apiGet(`/device/${encPath(input.deviceId)}${qs ? `?${qs}` : ""}`);
32293
32488
  }
32294
32489
  },
32295
32490
  {
@@ -32303,7 +32498,7 @@ var deviceTools = [
32303
32498
  openWorldHint: true
32304
32499
  },
32305
32500
  inputSchema: external_exports.object({
32306
- deviceId: external_exports.string().describe("The device ID to authorize")
32501
+ deviceId: external_exports.string().describe(`The device ID to authorize. ${DEVICE_ID_HINT}`)
32307
32502
  }),
32308
32503
  handler: async (input) => {
32309
32504
  return apiPost(`/device/${encPath(input.deviceId)}/authorized`, { authorized: true });
@@ -32320,7 +32515,7 @@ var deviceTools = [
32320
32515
  openWorldHint: true
32321
32516
  },
32322
32517
  inputSchema: external_exports.object({
32323
- deviceId: external_exports.string().describe("The device ID to deauthorize")
32518
+ deviceId: external_exports.string().describe(`The device ID to deauthorize. ${DEVICE_ID_HINT}`)
32324
32519
  }),
32325
32520
  handler: async (input) => {
32326
32521
  return apiPost(`/device/${encPath(input.deviceId)}/authorized`, { authorized: false });
@@ -32337,7 +32532,7 @@ var deviceTools = [
32337
32532
  openWorldHint: true
32338
32533
  },
32339
32534
  inputSchema: external_exports.object({
32340
- deviceId: external_exports.string().describe("The device ID to delete")
32535
+ deviceId: external_exports.string().describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
32341
32536
  }),
32342
32537
  handler: async (input) => {
32343
32538
  return apiDelete(`/device/${encPath(input.deviceId)}`);
@@ -32345,7 +32540,7 @@ var deviceTools = [
32345
32540
  },
32346
32541
  {
32347
32542
  name: "tailscale_rename_device",
32348
- description: "Set the name of a device in the tailnet.",
32543
+ description: "Set the name of a device in the tailnet, or reset it to its OS hostname.",
32349
32544
  annotations: {
32350
32545
  title: "Rename device",
32351
32546
  readOnlyHint: false,
@@ -32354,8 +32549,10 @@ var deviceTools = [
32354
32549
  openWorldHint: true
32355
32550
  },
32356
32551
  inputSchema: external_exports.object({
32357
- deviceId: external_exports.string().describe("The device ID to rename"),
32358
- name: external_exports.string().describe("The new name for the device (FQDN within your tailnet)")
32552
+ deviceId: external_exports.string().describe(`The device ID to rename. ${DEVICE_ID_HINT}`),
32553
+ name: external_exports.string().describe(
32554
+ "New device name: the FQDN (e.g. 'nodename.your-tailnet.ts.net') or just the base name (e.g. 'nodename'). Pass an empty string to reset the name to one generated from the OS hostname (per Tailscale's API spec)."
32555
+ )
32359
32556
  }),
32360
32557
  handler: async (input) => {
32361
32558
  return apiPost(`/device/${encPath(input.deviceId)}/name`, { name: input.name });
@@ -32372,7 +32569,7 @@ var deviceTools = [
32372
32569
  openWorldHint: true
32373
32570
  },
32374
32571
  inputSchema: external_exports.object({
32375
- deviceId: external_exports.string().describe("The device ID to expire")
32572
+ deviceId: external_exports.string().describe(`The device ID to expire. ${DEVICE_ID_HINT}`)
32376
32573
  }),
32377
32574
  handler: async (input) => {
32378
32575
  return apiPost(`/device/${encPath(input.deviceId)}/expire`);
@@ -32389,7 +32586,7 @@ var deviceTools = [
32389
32586
  openWorldHint: true
32390
32587
  },
32391
32588
  inputSchema: external_exports.object({
32392
- deviceId: external_exports.string().describe("The device ID")
32589
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
32393
32590
  }),
32394
32591
  handler: async (input) => {
32395
32592
  return apiGet(`/device/${encPath(input.deviceId)}/routes`);
@@ -32408,7 +32605,7 @@ var deviceTools = [
32408
32605
  openWorldHint: true
32409
32606
  },
32410
32607
  inputSchema: external_exports.object({
32411
- deviceId: external_exports.string().describe("The device ID"),
32608
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32412
32609
  routes: external_exports.array(external_exports.string().refine(isCidr, { message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" })).describe(
32413
32610
  "Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
32414
32611
  )
@@ -32428,7 +32625,7 @@ var deviceTools = [
32428
32625
  openWorldHint: true
32429
32626
  },
32430
32627
  inputSchema: external_exports.object({
32431
- deviceId: external_exports.string().describe("The device ID")
32628
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
32432
32629
  }),
32433
32630
  handler: async (input) => {
32434
32631
  return apiGet(`/device/${encPath(input.deviceId)}/attributes`);
@@ -32445,12 +32642,22 @@ var deviceTools = [
32445
32642
  openWorldHint: true
32446
32643
  },
32447
32644
  inputSchema: external_exports.object({
32448
- deviceId: external_exports.string().describe("The device ID"),
32449
- attributeKey: external_exports.string().describe("The attribute key (must start with 'custom:', e.g. 'custom:lastAuditDate')"),
32450
- value: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()]).describe("The attribute value (string, number, or boolean)"),
32645
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32646
+ // The length and charset rules below are Tailscale's, and Tailscale is
32647
+ // the one that enforces them -- only the 'custom:' prefix is checked
32648
+ // client-side, because that one is a namespace choice an agent gets
32649
+ // wrong by accident. A local regex for the rest would be one more thing
32650
+ // to drift out of step with the API's own error.
32651
+ attributeKey: external_exports.string().describe(
32652
+ "The attribute key (must start with 'custom:', e.g. 'custom:lastAuditDate'). Max 128 characters including the prefix; letters, numbers, underscores and colons only. Keys are case-sensitive but are checked for uniqueness case-insensitively, so 'custom:MyAttribute' and 'custom:myattribute' cannot both exist in one tailnet."
32653
+ ),
32654
+ value: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()]).describe(
32655
+ "The attribute value: a string (max 50 characters, letters, numbers, underscores and periods only), an integer number (JSON-safe, up to 2^53-1), or a boolean. The type is fixed by the first value written for a key -- every device's value for that key must then be the same type."
32656
+ ),
32451
32657
  expiry: external_exports.string().optional().describe(
32452
32658
  "Optional expiry time in RFC3339 format (e.g. '2026-12-01T00:00:00Z'). Attribute is automatically removed after expiry."
32453
- )
32659
+ ),
32660
+ comment: external_exports.string().max(200).optional().describe("Optional comment added to the audit log explaining why the attribute is being set (max 200 chars)")
32454
32661
  }),
32455
32662
  handler: async (input) => {
32456
32663
  if (!input.attributeKey.startsWith("custom:")) {
@@ -32458,6 +32665,7 @@ var deviceTools = [
32458
32665
  }
32459
32666
  const body = { value: input.value };
32460
32667
  if (input.expiry !== void 0) body.expiry = input.expiry;
32668
+ if (input.comment !== void 0) body.comment = input.comment;
32461
32669
  return apiPost(`/device/${encPath(input.deviceId)}/attributes/${encPath(input.attributeKey)}`, body);
32462
32670
  }
32463
32671
  },
@@ -32472,7 +32680,7 @@ var deviceTools = [
32472
32680
  openWorldHint: true
32473
32681
  },
32474
32682
  inputSchema: external_exports.object({
32475
- deviceId: external_exports.string().describe("The device ID"),
32683
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32476
32684
  attributeKey: external_exports.string().describe("The attribute key to delete (e.g. 'custom:lastAuditDate')")
32477
32685
  }),
32478
32686
  handler: async (input) => {
@@ -32495,7 +32703,7 @@ var deviceTools = [
32495
32703
  openWorldHint: true
32496
32704
  },
32497
32705
  inputSchema: external_exports.object({
32498
- deviceId: external_exports.string().describe("The device ID"),
32706
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32499
32707
  tags: external_exports.array(external_exports.string()).describe("Full list of ACL tags (e.g. ['tag:server', 'tag:production']). Replaces all existing tags.")
32500
32708
  }),
32501
32709
  handler: async (input) => {
@@ -32514,7 +32722,7 @@ var deviceTools = [
32514
32722
  openWorldHint: true
32515
32723
  },
32516
32724
  inputSchema: external_exports.object({
32517
- deviceId: external_exports.string().describe("The device ID"),
32725
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32518
32726
  ipv4: external_exports.ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
32519
32727
  }),
32520
32728
  handler: async (input) => {
@@ -32532,7 +32740,7 @@ var deviceTools = [
32532
32740
  openWorldHint: true
32533
32741
  },
32534
32742
  inputSchema: external_exports.object({
32535
- deviceId: external_exports.string().describe("The device ID"),
32743
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
32536
32744
  keyExpiryDisabled: external_exports.boolean().describe("Whether to disable key expiry for this device")
32537
32745
  }),
32538
32746
  handler: async (input) => {
@@ -32554,7 +32762,7 @@ var deviceTools = [
32554
32762
  openWorldHint: true
32555
32763
  },
32556
32764
  inputSchema: external_exports.object({
32557
- deviceIds: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to update"),
32765
+ deviceIds: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to update (nodeIds preferred; legacy numeric ids also work)"),
32558
32766
  authorized: external_exports.boolean().describe("true to authorize, false to deauthorize")
32559
32767
  }),
32560
32768
  handler: async (input) => {
@@ -32607,7 +32815,7 @@ var deviceTools = [
32607
32815
  ])
32608
32816
  )
32609
32817
  ).describe(
32610
- 'Map of device ID to attribute config map (e.g. { "12345": { "custom:compliant": { "value": "true" } }, "67890": { "custom:compliant": { "value": false, "expiry": "2026-12-01T00:00:00Z" } } }). Pass null as the config to delete an attribute.'
32818
+ 'Map of device ID to attribute config map (e.g. { "nPM2KNuedB21DEVEL": { "custom:compliant": { "value": "true" } }, "nPpz3VEKzX11DEVEL": { "custom:compliant": { "value": false, "expiry": "2026-12-01T00:00:00Z" } } }). Keys are device IDs, nodeIds preferred. Pass null as the config to delete an attribute.'
32611
32819
  ),
32612
32820
  comment: external_exports.string().max(200).optional().describe("Optional comment added to the audit log explaining why attributes are being set (max 200 chars)")
32613
32821
  }),
@@ -32649,7 +32857,7 @@ var dnsTools = [
32649
32857
  },
32650
32858
  {
32651
32859
  name: "tailscale_set_nameservers",
32652
- description: "Set the DNS nameservers for your tailnet. Replaces all existing nameservers.",
32860
+ description: "Set the DNS nameservers for your tailnet. Replaces all existing nameservers. Removing every nameserver may also change MagicDNS: the API reference says it is switched off, while Tailscale's current MagicDNS docs say a nameserver is no longer required -- check `magicDNS` in the response.",
32653
32861
  annotations: {
32654
32862
  title: "Set nameservers",
32655
32863
  readOnlyHint: false,
@@ -32718,7 +32926,7 @@ var dnsTools = [
32718
32926
  },
32719
32927
  {
32720
32928
  name: "tailscale_set_split_dns",
32721
- description: "Set split DNS configuration. Maps domains to specific nameservers. Replaces the entire split DNS configuration.",
32929
+ description: "Set split DNS configuration. Maps domains to specific nameservers. Replaces the entire split DNS configuration: a domain you leave out is removed, and an empty object clears every domain. Per the API reference, setting a domain to null clears that domain's nameservers.",
32722
32930
  annotations: {
32723
32931
  title: "Set split DNS",
32724
32932
  readOnlyHint: false,
@@ -32729,8 +32937,11 @@ var dnsTools = [
32729
32937
  openWorldHint: true
32730
32938
  },
32731
32939
  inputSchema: external_exports.object({
32732
- splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).describe(
32733
- 'Map of domain to nameserver list (e.g. { "corp.example.com": ["10.0.0.1"], "internal.dev": ["10.0.0.2"] })'
32940
+ // `.nullable()` per the spec's SplitDns schema, which types each value as
32941
+ // an array OR null. Forwarded verbatim below, so null reaches the API as
32942
+ // JSON null rather than being pruned.
32943
+ splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).describe(
32944
+ 'Map of domain to nameserver list, or to null to clear that domain (e.g. { "corp.example.com": ["10.0.0.1"], "old.example.com": null })'
32734
32945
  )
32735
32946
  }),
32736
32947
  handler: async (input) => {
@@ -32754,7 +32965,7 @@ var dnsTools = [
32754
32965
  },
32755
32966
  {
32756
32967
  name: "tailscale_set_dns_preferences",
32757
- description: "Set DNS preferences for your tailnet, such as enabling or disabling MagicDNS.",
32968
+ description: "Set DNS preferences for your tailnet, such as enabling or disabling MagicDNS. The API reference says enabling can fail when the tailnet has no nameservers; if it does, add one with tailscale_set_nameservers first.",
32758
32969
  annotations: {
32759
32970
  title: "Set DNS preferences",
32760
32971
  readOnlyHint: false,
@@ -32773,7 +32984,7 @@ var dnsTools = [
32773
32984
  },
32774
32985
  {
32775
32986
  name: "tailscale_update_split_dns",
32776
- description: "Partially update split DNS configuration. Merges the provided domains with the existing config \u2014 only the specified domains are changed, others are untouched. Set a domain's nameservers to an empty array to remove it.",
32987
+ description: "Partially update split DNS configuration. Merges the provided domains with the existing config -- only the specified domains are changed, others are untouched. To remove a domain, set it to null: that is the idiom the API reference documents. An empty array is also accepted and forwarded as-is -- it is what Tailscale's Terraform provider sends.",
32777
32988
  annotations: {
32778
32989
  title: "Update split DNS (partial)",
32779
32990
  readOnlyHint: false,
@@ -32782,8 +32993,11 @@ var dnsTools = [
32782
32993
  openWorldHint: true
32783
32994
  },
32784
32995
  inputSchema: external_exports.object({
32785
- splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).describe(
32786
- 'Map of domain to nameserver list to merge (e.g. { "new.example.com": ["10.0.0.3"] }). Only specified domains are changed.'
32996
+ // Nullable for the same reason as the PUT sibling above: the spec's
32997
+ // SplitDns body types each value as an array OR null, and null is how it
32998
+ // documents clearing a domain.
32999
+ splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).describe(
33000
+ 'Map of domain to nameserver list to merge, or to null to remove that domain (e.g. { "new.example.com": ["10.0.0.3"], "old.example.com": null }). Only specified domains are changed.'
32787
33001
  )
32788
33002
  }),
32789
33003
  handler: async (input) => {
@@ -32850,7 +33064,7 @@ var inviteTools = [
32850
33064
  openWorldHint: true
32851
33065
  },
32852
33066
  inputSchema: external_exports.object({
32853
- deviceId: external_exports.string().describe("The device ID to list invites for")
33067
+ deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
32854
33068
  }),
32855
33069
  handler: async (input) => {
32856
33070
  return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
@@ -32867,7 +33081,7 @@ var inviteTools = [
32867
33081
  openWorldHint: true
32868
33082
  },
32869
33083
  inputSchema: external_exports.object({
32870
- deviceId: external_exports.string().describe("The device ID to create an invite for"),
33084
+ deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
32871
33085
  multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
32872
33086
  allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
32873
33087
  email: external_exports.email().optional().describe("Email address to send the invite to")
@@ -32934,7 +33148,7 @@ var inviteTools = [
32934
33148
  // --- User Invites ---
32935
33149
  {
32936
33150
  name: "tailscale_list_user_invites",
32937
- description: "List all user invites for your tailnet.",
33151
+ description: "List the open (not yet accepted) user invites for your tailnet. Accepted invites are not returned.",
32938
33152
  annotations: {
32939
33153
  title: "List user invites",
32940
33154
  readOnlyHint: true,
@@ -33044,7 +33258,7 @@ var inviteTools = [
33044
33258
  var keyTools = [
33045
33259
  {
33046
33260
  name: "tailscale_list_keys",
33047
- description: "List keys in your tailnet. By default lists auth keys only. Set 'all' to true to include OAuth clients and federated identities.",
33261
+ description: "List keys in your tailnet: auth keys, API access tokens, OAuth clients and federated identities. Without 'all', what comes back depends on the credential this server runs on -- a user-owned API key sees only that user's keys (including the API access token the server itself is using, keyType 'api'); an OAuth-client token sees the tailnet's OAuth clients; a federated-identity token sees its federated identities. Set 'all' to true for the tailnet-wide list (needs the matching :read scopes; only 'all:read' and 'all' return every API access token).",
33048
33262
  annotations: {
33049
33263
  title: "List keys",
33050
33264
  readOnlyHint: true,
@@ -33053,7 +33267,7 @@ var keyTools = [
33053
33267
  openWorldHint: true
33054
33268
  },
33055
33269
  inputSchema: external_exports.object({
33056
- all: external_exports.boolean().optional().describe("When true, returns all key types (auth keys, OAuth clients, federated identities). Default: false")
33270
+ all: external_exports.boolean().optional().describe("When true, list keys tailnet-wide instead of the credential-dependent default set. Default: false")
33057
33271
  }),
33058
33272
  handler: async (input) => {
33059
33273
  const qs = input.all ? "?all=true" : "";
@@ -33062,7 +33276,7 @@ var keyTools = [
33062
33276
  },
33063
33277
  {
33064
33278
  name: "tailscale_get_key",
33065
- description: "Get details for a specific key (auth key, OAuth client, or federated identity).",
33279
+ description: "Get details for a specific key (auth key, API access token, OAuth client, or federated identity). A revoked or expired key is still returned, with `invalid: true`.",
33066
33280
  annotations: {
33067
33281
  title: "Get key",
33068
33282
  readOnlyHint: true,
@@ -33071,7 +33285,7 @@ var keyTools = [
33071
33285
  openWorldHint: true
33072
33286
  },
33073
33287
  inputSchema: external_exports.object({
33074
- keyId: external_exports.string().describe("The key ID (auth key, OAuth client, or federated identity)")
33288
+ keyId: external_exports.string().describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
33075
33289
  }),
33076
33290
  handler: async (input) => {
33077
33291
  return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -33079,7 +33293,7 @@ var keyTools = [
33079
33293
  },
33080
33294
  {
33081
33295
  name: "tailscale_create_key",
33082
- description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value \u2014 save it immediately, as it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
33296
+ description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value -- save it immediately, as it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:core:read','dns:read']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:core:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
33083
33297
  annotations: {
33084
33298
  title: "Create key",
33085
33299
  readOnlyHint: false,
@@ -33102,7 +33316,9 @@ var keyTools = [
33102
33316
  "ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
33103
33317
  ),
33104
33318
  // Client + Federated fields
33105
- scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl'])"),
33319
+ scopes: external_exports.array(external_exports.string()).optional().describe(
33320
+ "(client/federated) OAuth scopes to grant (e.g. ['devices:core:read', 'dns:read']). Use the current scope names listed at https://tailscale.com/kb/1623/trust-credentials#scopes; the pre-2024 names such as 'devices:read' and 'acl' are legacy."
33321
+ ),
33106
33322
  // Federated-only fields
33107
33323
  issuer: external_exports.string().optional().describe("(federated only) OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com')"),
33108
33324
  subject: external_exports.string().optional().describe("(federated only) Expected subject claim, supports * wildcards"),
@@ -33165,7 +33381,7 @@ var keyTools = [
33165
33381
  },
33166
33382
  {
33167
33383
  name: "tailscale_delete_key",
33168
- description: "Delete a key (auth key, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately.",
33384
+ description: "Delete a key (auth key, API access token, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately. API access tokens are deletable here too: if keyId is the token this server authenticates with -- it shows up in tailscale_list_keys under API-key auth -- the server revokes its own credential and every later call fails with 401 until it is reconfigured.",
33169
33385
  annotations: {
33170
33386
  title: "Delete key",
33171
33387
  readOnlyHint: false,
@@ -33174,7 +33390,7 @@ var keyTools = [
33174
33390
  openWorldHint: true
33175
33391
  },
33176
33392
  inputSchema: external_exports.object({
33177
- keyId: external_exports.string().describe("The key ID to delete (auth key, OAuth client, or federated identity)")
33393
+ keyId: external_exports.string().describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
33178
33394
  }),
33179
33395
  handler: async (input) => {
33180
33396
  return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -33225,12 +33441,15 @@ var keyTools = [
33225
33441
  // tailscale_create_key above: an OAuth client is a machine credential you
33226
33442
  // hold, whereas an OAuth App is a three-legged authorization-code app that
33227
33443
  // lets a THIRD PARTY enroll one device into your tailnet after a user
33228
- // consents. The only scope it takes today is `auth_keys:create:once`, which
33229
- // mints exactly one auth key per authorization and returns no refresh token
33230
- // -- re-authorization is required per device by design.
33444
+ // consents. Tailscale's device-provisioning guide documents one scope,
33445
+ // `auth_keys:create:once`, which mints exactly one auth key per authorization
33446
+ // and returns no refresh token -- re-authorization is required per device by
33447
+ // design. The API reference's own example shows the bare `auth_keys:create`
33448
+ // instead, and the schema restricts neither, so the description names both
33449
+ // sources rather than picking one.
33231
33450
  {
33232
33451
  name: "tailscale_create_oauth_app",
33233
- description: "Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nThe supported scope is 'auth_keys:create:once' (one auth key per authorization, no refresh token). Distinct from tailscale_create_key with keyType='client', which mints a machine-to-machine OAuth client instead.",
33452
+ description: "Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nUse scope 'auth_keys:create:once' (one auth key per authorization, no refresh token) -- the scope Tailscale's device-provisioning guide documents. The API reference's example shows 'auth_keys:create'; this tool does not restrict the value. Distinct from tailscale_create_key with keyType='client', which mints a machine-to-machine OAuth client instead.",
33234
33453
  annotations: {
33235
33454
  title: "Create OAuth app",
33236
33455
  readOnlyHint: false,
@@ -33241,7 +33460,9 @@ var keyTools = [
33241
33460
  inputSchema: external_exports.object({
33242
33461
  name: external_exports.string().min(1).describe("Human-readable name for the OAuth app, shown on the consent screen"),
33243
33462
  redirectUris: external_exports.array(external_exports.url()).min(1).describe("Allowed redirect URIs for the authorization-code flow (e.g. ['https://example.com/callback'])"),
33244
- scopes: external_exports.array(external_exports.string()).min(1).describe("Scopes to grant. Currently 'auth_keys:create:once' is the supported value."),
33463
+ scopes: external_exports.array(external_exports.string()).min(1).describe(
33464
+ "Scopes to grant. Use 'auth_keys:create:once', the scope the device-provisioning guide documents; the API reference's example shows 'auth_keys:create'. Not restricted here."
33465
+ ),
33245
33466
  allowedNodeAttributes: external_exports.array(external_exports.string()).optional().describe("Optional node attributes the app may request when provisioning a device")
33246
33467
  }),
33247
33468
  handler: async (input) => {
@@ -33320,14 +33541,57 @@ import * as net2 from "node:net";
33320
33541
 
33321
33542
  // src/local-cli.ts
33322
33543
  import { execFile as execFileCb } from "node:child_process";
33544
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
33323
33545
  var DEFAULT_TIMEOUT_MS = 3e4;
33324
33546
  var MAX_BUFFER_BYTES = 10 * 1024 * 1024;
33547
+ var DARWIN_CANDIDATES = [
33548
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
33549
+ "/opt/homebrew/bin/tailscale",
33550
+ "/usr/local/bin/tailscale"
33551
+ ];
33552
+ var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
33325
33553
  var execFileImpl = execFileCb;
33326
- function getBinaryPath() {
33327
- return process.env.TAILSCALE_BINARY || "tailscale";
33554
+ function binaryCandidates(platform) {
33555
+ if (platform === "darwin") return DARWIN_CANDIDATES;
33556
+ if (platform === "linux") return LINUX_CANDIDATES;
33557
+ return [];
33558
+ }
33559
+ function resolveBinary(platform = process.platform, exists = existsSync) {
33560
+ const override = process.env.TAILSCALE_BINARY;
33561
+ if (override) return override;
33562
+ for (const candidate of binaryCandidates(platform)) {
33563
+ if (exists(candidate)) return candidate;
33564
+ }
33565
+ return "tailscale";
33566
+ }
33567
+ function looksLikeWsl(platform, procVersion = readProcVersion) {
33568
+ if (platform !== "linux") return false;
33569
+ if (process.env.WSL_DISTRO_NAME) return true;
33570
+ return /microsoft/i.test(procVersion());
33571
+ }
33572
+ function readProcVersion() {
33573
+ try {
33574
+ return readFileSync2("/proc/version", "utf-8");
33575
+ } catch {
33576
+ return "";
33577
+ }
33578
+ }
33579
+ function describeMissingBinary(binary, fromEnv, platform, wsl) {
33580
+ if (fromEnv) {
33581
+ 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.`;
33582
+ 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}`;
33583
+ }
33584
+ if (platform === "darwin") {
33585
+ return `Could not find the 'tailscale' binary in PATH, or at ${DARWIN_CANDIDATES.join(", ")}. A default macOS install keeps the CLI inside the app bundle and puts nothing on PATH, and an MCP client launched from the Dock or Spotlight sees a minimal PATH rather than your shell's. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path, usually /Applications/Tailscale.app/Contents/MacOS/Tailscale.`;
33586
+ }
33587
+ if (wsl) {
33588
+ return `Could not find the 'tailscale' binary in PATH, or at ${LINUX_CANDIDATES.join(", ")}. This looks like WSL, where the only tailscale in reach is usually the Windows one: a Linux process cannot exec tailscale.exe, and pointing TAILSCALE_BINARY at it would report the WINDOWS host's tailnet rather than this machine's, which is what these tools describe. Install Tailscale inside the distro (https://tailscale.com/download/linux) and run tailscaled there.`;
33589
+ }
33590
+ return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
33328
33591
  }
33329
33592
  async function runTailscaleCli(args, options = {}) {
33330
- const binary = getBinaryPath();
33593
+ const binary = resolveBinary();
33594
+ const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
33331
33595
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33332
33596
  return new Promise((resolve) => {
33333
33597
  execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
@@ -33338,7 +33602,7 @@ async function runTailscaleCli(args, options = {}) {
33338
33602
  if (errno.code === "ENOENT") {
33339
33603
  resolve({
33340
33604
  ok: false,
33341
- error: `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`
33605
+ error: describeMissingBinary(binary, fromEnv, process.platform, looksLikeWsl(process.platform))
33342
33606
  });
33343
33607
  return;
33344
33608
  }
@@ -33394,7 +33658,7 @@ function isValidPingTarget(s) {
33394
33658
  var localCliTools = [
33395
33659
  {
33396
33660
  name: "tailscale_local_status",
33397
- description: "Get this machine's view of its tailnet -- own connection state, peers it can see, DERP region, MagicDNS suffix, etc. Shells out to the local `tailscale` binary; distinct from `tailscale_status`, which queries the admin API for tailnet-wide info. Requires the tailscale CLI installed locally and TAILSCALE_LOCAL_CLI=1.",
33661
+ description: "Get this machine's view of its tailnet -- own connection state, peers it can see, DERP region, MagicDNS suffix, etc. Shells out to the local `tailscale` binary; distinct from `tailscale_status`, which queries the admin API for tailnet-wide info. Read a peer's path in decision order: direct when `CurAddr` is set, peer-relayed when `PeerRelay` is set, otherwise DERP via the region named in `Relay`. `Relay` is the peer's home DERP region and is populated either way, so a non-empty `Relay` on its own does not mean the traffic is relayed. The `Peer` map is what makes this response scale with the tailnet rather than with the request; `peers` and `activeOnly` narrow it. Requires the tailscale CLI installed locally and TAILSCALE_LOCAL_CLI=1.",
33398
33662
  annotations: {
33399
33663
  title: "Local tailscale status",
33400
33664
  readOnlyHint: true,
@@ -33402,8 +33666,30 @@ var localCliTools = [
33402
33666
  idempotentHint: true,
33403
33667
  openWorldHint: true
33404
33668
  },
33405
- inputSchema: external_exports.object({}),
33406
- handler: async () => runTailscaleCli(["status", "--json"], { parseJson: true })
33669
+ inputSchema: external_exports.object({
33670
+ peers: external_exports.boolean().optional().describe(
33671
+ "Set false to omit the `Peer` map (`--peers=false`), leaving this node's own state -- `Self`, `BackendState`, `Health`, `CurrentTailnet` and the rest of the top level. Omit it for the CLI's default, which includes peers."
33672
+ ),
33673
+ activeOnly: external_exports.boolean().optional().describe(
33674
+ "Set true to keep only peers with an active session (`--active`) -- upstream defines that as a packet sent to the peer in roughly the last two minutes. Omit it to get every peer."
33675
+ )
33676
+ }),
33677
+ handler: async (input = {}) => {
33678
+ const args = ["status", "--json"];
33679
+ if (input.peers === false) args.push("--peers=false");
33680
+ if (input.activeOnly) args.push("--active");
33681
+ const result = await runTailscaleCli(args, { parseJson: true });
33682
+ if (!result.ok && result.error?.includes("output limit")) {
33683
+ return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
33684
+ }
33685
+ if (!result.ok && result.error?.includes("timed out after")) {
33686
+ return {
33687
+ ...result,
33688
+ error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
33689
+ };
33690
+ }
33691
+ return result;
33692
+ }
33407
33693
  },
33408
33694
  {
33409
33695
  name: "tailscale_ping",
@@ -33667,17 +33953,30 @@ var logStreamingTools = [
33667
33953
  },
33668
33954
  {
33669
33955
  name: "tailscale_create_aws_external_id",
33670
- description: "Create or get an AWS external ID for your tailnet. Used when configuring log streaming to S3 \u2014 the external ID is included in the IAM role trust policy.",
33956
+ description: "Create or get the AWS external ID Tailscale presents when assuming your IAM role for S3 log streaming. Put it in the role trust policy's sts:ExternalId condition, then check it with tailscale_validate_aws_trust_policy.",
33671
33957
  annotations: {
33672
33958
  title: "Create AWS external ID",
33673
33959
  readOnlyHint: false,
33674
33960
  destructiveHint: false,
33675
- idempotentHint: true,
33961
+ // Not idempotent: a static hint has to hold for every accepted input, and
33962
+ // reusable:false mints a distinct ID by design. Even reusable:true mints
33963
+ // a new one once the previous ID has been linked to an AWS account.
33964
+ idempotentHint: false,
33676
33965
  openWorldHint: true
33677
33966
  },
33678
- inputSchema: external_exports.object({}),
33679
- handler: async () => {
33680
- return apiPost(`/tailnet/${getTailnet()}/aws-external-id`);
33967
+ inputSchema: external_exports.object({
33968
+ reusable: external_exports.boolean().optional().describe(
33969
+ "Default true: Tailscale returns the SAME external ID on repeat calls until that ID has been linked to an AWS account, so asking again does not invalidate the ID already pasted into an IAM trust policy. Set false to force a fresh ID (what Tailscale's Terraform provider does, one ID per resource)."
33970
+ )
33971
+ }),
33972
+ // The flag was previously never sent at all, so the server applied whatever
33973
+ // default it applies to an absent body -- undocumented either way. It now
33974
+ // goes on the wire explicitly, as the Go client always does. The default
33975
+ // lives here rather than in a Zod `.default()` because handlers are called
33976
+ // with the client's raw input, and `??` rather than `||` because false is a
33977
+ // meaningful value.
33978
+ handler: async (input) => {
33979
+ return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
33681
33980
  }
33682
33981
  },
33683
33982
  {
@@ -33776,14 +34075,16 @@ var postureTools = [
33776
34075
  "The posture provider slug: falcon (CrowdStrike Falcon), fleet, huntress, intune (Microsoft Intune), jamfpro (Jamf Pro), kandji (Iru, formerly Kandji), kolide (1Password XAM, formerly Kolide), sentinelone"
33777
34076
  ),
33778
34077
  clientId: external_exports.string().optional().describe(
33779
- "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Fleet/Huntress/Kandji/Kolide/Sentinel One: leave blank)"
34078
+ "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank). Fleet and Huntress: Tailscale does not document how their credentials map onto these API fields -- the admin console asks for a Fleet URL + API token (Fleet) and an API key + API secret, plus an optional organization ID (Huntress). Do not assume this can be left blank; confirm the mapping first."
33780
34079
  ),
33781
34080
  clientSecret: external_exports.string().describe(
33782
34081
  "The secret (auth key, token, etc.) used to authenticate with the provider. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
33783
34082
  ),
33784
- tenantId: external_exports.string().optional().describe("Microsoft Intune directory (tenant) ID. Other providers leave blank."),
34083
+ tenantId: external_exports.string().optional().describe(
34084
+ "Microsoft Intune directory (tenant) ID. Other providers leave blank. Fleet/Huntress: undocumented upstream (see clientId)."
34085
+ ),
33785
34086
  cloudId: external_exports.string().optional().describe(
33786
- "Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank."
34087
+ "Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank. Fleet/Huntress: undocumented upstream (see clientId)."
33787
34088
  )
33788
34089
  }),
33789
34090
  handler: async (input) => {
@@ -33961,7 +34262,7 @@ var serviceTools = [
33961
34262
  },
33962
34263
  inputSchema: external_exports.object({
33963
34264
  serviceName: external_exports.string().describe("The service name"),
33964
- deviceId: external_exports.string().describe("The device ID")
34265
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
33965
34266
  }),
33966
34267
  handler: async (input) => {
33967
34268
  return apiGet(
@@ -33981,7 +34282,7 @@ var serviceTools = [
33981
34282
  },
33982
34283
  inputSchema: external_exports.object({
33983
34284
  serviceName: external_exports.string().describe("The service name"),
33984
- deviceId: external_exports.string().describe("The device ID"),
34285
+ deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
33985
34286
  approved: external_exports.boolean().describe("Whether to approve (true) or reject (false) the device")
33986
34287
  }),
33987
34288
  handler: async (input) => {
@@ -34202,17 +34503,24 @@ var tailnetsTools = [
34202
34503
  },
34203
34504
  {
34204
34505
  name: "tailscale_create_org_tailnet",
34205
- description: "Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.\n\nSECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nRequires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.",
34506
+ description: "Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.\n\nSECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nRequires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.\n\nOrganizations are limited to 10 tailnets including the original unless Tailscale sales has raised the limit.\n\nThe response may include `alreadyExists: true`; Tailscale's spec ALSO documents a 400 for a name already in use and neither has been observed, so after a timeout call tailscale_list_org_tailnets before retrying rather than assuming either.",
34206
34507
  annotations: {
34207
34508
  title: "Create organization tailnet",
34208
34509
  readOnlyHint: false,
34209
34510
  destructiveHint: false,
34210
- // Each call creates a distinct tailnet; there is no idempotency key.
34511
+ // No idempotency key. The spec's `alreadyExists` field hints that a
34512
+ // duplicate displayName may return the existing tailnet, but the same
34513
+ // spec documents a 400 for that case and neither is observed --
34514
+ // idempotentHint stays false until one is.
34211
34515
  idempotentHint: false,
34212
34516
  openWorldHint: true
34213
34517
  },
34214
34518
  inputSchema: external_exports.object({
34215
- displayName: external_exports.string().trim().min(1).describe("Human-readable name for the new tailnet"),
34519
+ // No charset regex: the rule below is Tailscale's and Tailscale enforces
34520
+ // it, so a local copy would only drift out of step with its own error.
34521
+ displayName: external_exports.string().trim().min(1).describe(
34522
+ "Human-readable name for the new tailnet. May contain letters, numbers, spaces, apostrophes and hyphens, and must be unique within the organization."
34523
+ ),
34216
34524
  organization: organizationSchema
34217
34525
  }),
34218
34526
  handler: async (input) => {
@@ -34413,11 +34721,12 @@ var STATIC_WEBHOOK_EVENT_TYPES = [
34413
34721
  "subnetIPForwardingNotEnabled",
34414
34722
  "exitNodeIPForwardingNotEnabled"
34415
34723
  ];
34724
+ var WEBHOOK_CATEGORY_SUBSCRIPTIONS = ["categoryTailnetManagement", "categoryDeviceMisconfigurations"];
34416
34725
  function getAllowedWebhookEvents() {
34417
34726
  const raw = process.env.TAILSCALE_EXTRA_WEBHOOK_EVENTS;
34418
- if (!raw) return new Set(STATIC_WEBHOOK_EVENT_TYPES);
34727
+ if (!raw) return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS]);
34419
34728
  const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
34420
- return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
34729
+ return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
34421
34730
  }
34422
34731
  var endpointUrlSchema = external_exports.url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
34423
34732
  var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
@@ -34474,7 +34783,7 @@ var webhookTools = [
34474
34783
  },
34475
34784
  {
34476
34785
  name: "tailscale_create_webhook",
34477
- description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately.\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.",
34786
+ description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately. Set providerType when the endpoint is a Slack, Mattermost, Google Chat or Discord incoming-webhook URL, so the events arrive in the format that provider renders.\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.",
34478
34787
  annotations: {
34479
34788
  title: "Create webhook",
34480
34789
  readOnlyHint: false,
@@ -34484,13 +34793,20 @@ var webhookTools = [
34484
34793
  },
34485
34794
  inputSchema: external_exports.object({
34486
34795
  endpointUrl: endpointUrlSchema.describe("The HTTPS URL to send webhook events to"),
34487
- subscriptions: webhookSubscriptionsSchema.describe("Event types to subscribe to (at least one)")
34796
+ providerType: external_exports.enum(["slack", "mattermost", "googlechat", "discord"]).optional().describe(
34797
+ "Format deliveries for a chat provider's incoming-webhook URL. Omit for raw Tailscale JSON -- the default, and what a custom receiver verifying signatures wants. Set once: it cannot be changed after creation."
34798
+ ),
34799
+ subscriptions: webhookSubscriptionsSchema.describe(
34800
+ "Event types to subscribe to (at least one). 'categoryTailnetManagement' and 'categoryDeviceMisconfigurations' subscribe to a whole category, including events Tailscale adds to it later."
34801
+ )
34488
34802
  }),
34489
34803
  handler: async (input) => {
34490
- return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
34804
+ const body = {
34491
34805
  endpointUrl: input.endpointUrl,
34492
34806
  subscriptions: input.subscriptions
34493
- });
34807
+ };
34808
+ if (input.providerType !== void 0) body.providerType = input.providerType;
34809
+ return apiPost(`/tailnet/${getTailnet()}/webhooks`, body);
34494
34810
  }
34495
34811
  },
34496
34812
  {
@@ -34506,7 +34822,9 @@ var webhookTools = [
34506
34822
  inputSchema: external_exports.object({
34507
34823
  webhookId: external_exports.string().describe("The webhook ID to update"),
34508
34824
  endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
34509
- subscriptions: webhookSubscriptionsSchema.optional().describe("Updated list of event types to subscribe to (at least one)")
34825
+ subscriptions: webhookSubscriptionsSchema.optional().describe(
34826
+ "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."
34827
+ )
34510
34828
  }),
34511
34829
  handler: async (input) => {
34512
34830
  const body = {};
@@ -34601,7 +34919,13 @@ var LARGE_RESULT_TOOLS = [
34601
34919
  "tailscale_get_audit_log",
34602
34920
  "tailscale_get_network_flow_logs",
34603
34921
  "tailscale_list_devices",
34604
- "tailscale_list_users"
34922
+ "tailscale_list_users",
34923
+ // The one opt-in entry: it registers only under TAILSCALE_LOCAL_CLI=1, so a
34924
+ // test comparing this list against a session's `_meta` has to filter it to
34925
+ // the names that session registered. Its payload is the local client's whole
34926
+ // peer map, which scales with the tailnet exactly as the admin reads above
34927
+ // do -- the tool takes `peers` and `activeOnly` for callers who want less.
34928
+ "tailscale_local_status"
34605
34929
  ];
34606
34930
  function buildToolMeta(toolName, options) {
34607
34931
  const meta3 = {};
@@ -34882,7 +35206,26 @@ function buildMetaTools(state) {
34882
35206
  }
34883
35207
 
34884
35208
  // src/index.ts
34885
- var version2 = true ? "0.20.2" : resolveVersionFallback();
35209
+ var NODE_MIN = [20, 11, 0];
35210
+ function belowNodeFloor(reported) {
35211
+ const found = /(\d+)\.(\d+)\.(\d+)/.exec(reported);
35212
+ if (!found) return false;
35213
+ for (const [i, min] of NODE_MIN.entries()) {
35214
+ const part = Number(found[i + 1]);
35215
+ if (part > min) return false;
35216
+ if (part < min) return true;
35217
+ }
35218
+ return false;
35219
+ }
35220
+ if (process.versions.oam === void 0 && belowNodeFloor(process.versions.node)) {
35221
+ process.stderr.write(
35222
+ `tailscale-mcp: needs Node ${NODE_MIN.join(".")} or newer, found ${process.versions.node}.
35223
+ Install a newer Node (https://nodejs.org/en/download), or point your MCP client's "command" at one.
35224
+ `
35225
+ );
35226
+ process.exit(1);
35227
+ }
35228
+ var version2 = true ? "0.21.0" : resolveVersionFallback();
34886
35229
  var subcommand = process.argv[2];
34887
35230
  var USAGE = `Usage: tailscale-mcp [command]
34888
35231