@yawlabs/tailscale-mcp 0.17.1 → 0.19.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
@@ -31035,6 +31035,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31035
31035
  })();
31036
31036
  return oauthRefreshPromise;
31037
31037
  }
31038
+ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
31039
+ if (!oauthToken) return;
31040
+ if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
31041
+ oauthToken = null;
31042
+ }
31038
31043
  async function getAuthHeader() {
31039
31044
  const config2 = getAuthConfig();
31040
31045
  if (config2.kind === "apiKey") {
@@ -31130,14 +31135,17 @@ function getRetryBaseDelayMs() {
31130
31135
  }
31131
31136
  async function withConcurrencyLimit(fn) {
31132
31137
  const limit = getConcurrencyLimit();
31133
- if (limit === 0) return fn();
31138
+ if (limit === 0) return fn(0);
31139
+ let queuedForMs = 0;
31134
31140
  if (inFlight >= limit) {
31141
+ const queueStartedAt = Date.now();
31135
31142
  await new Promise((resolve) => concurrencyQueue.push(resolve));
31143
+ queuedForMs = Date.now() - queueStartedAt;
31136
31144
  } else {
31137
31145
  inFlight++;
31138
31146
  }
31139
31147
  try {
31140
- return await fn();
31148
+ return await fn(queuedForMs);
31141
31149
  } finally {
31142
31150
  const next = concurrencyQueue.shift();
31143
31151
  if (next) {
@@ -31188,6 +31196,15 @@ function describeTransportError(err, method, attemptTimeoutMs) {
31188
31196
  }
31189
31197
  return `${method} request failed: ${String(err)}`;
31190
31198
  }
31199
+ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
31200
+ if (lastTransportError) {
31201
+ return `${lastTransportError}; request budget of ${budgetMs}ms exhausted before next attempt could begin.`;
31202
+ }
31203
+ if (queuedForMs > 0) {
31204
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin: ${queuedForMs}ms of it was spent waiting for a free slot under TAILSCALE_MAX_CONCURRENT, so no request was ever sent. Raise TAILSCALE_MAX_CONCURRENT or TAILSCALE_REQUEST_BUDGET_MS -- this is queueing, not a network fault.`;
31205
+ }
31206
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
31207
+ }
31191
31208
  async function apiRequest(method, path, body, options) {
31192
31209
  const headers = {};
31193
31210
  if (options?.accept) {
@@ -31217,7 +31234,7 @@ async function apiRequest(method, path, body, options) {
31217
31234
  debugLog(`${method} ${url2}`);
31218
31235
  const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
31219
31236
  const requestBudgetMs = getRequestBudgetMs();
31220
- return withConcurrencyLimit(async () => {
31237
+ return withConcurrencyLimit(async (queuedForMs) => {
31221
31238
  headers.Authorization = await getAuthHeader();
31222
31239
  let res;
31223
31240
  let lastTransportError;
@@ -31227,7 +31244,7 @@ async function apiRequest(method, path, body, options) {
31227
31244
  return {
31228
31245
  ok: false,
31229
31246
  status: 0,
31230
- error: lastTransportError ? `${lastTransportError}; request budget of ${requestBudgetMs}ms exhausted before next attempt could begin.` : `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
31247
+ error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
31231
31248
  };
31232
31249
  }
31233
31250
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
@@ -31268,6 +31285,9 @@ async function apiRequest(method, path, body, options) {
31268
31285
  const etag = response.headers.get("etag") || void 0;
31269
31286
  const elapsed = Date.now() - startedAt;
31270
31287
  debugLog(` <- ${response.status} (${elapsed}ms)`);
31288
+ if (response.status === 401) {
31289
+ invalidateOAuthTokenOnUnauthorized(headers.Authorization);
31290
+ }
31271
31291
  try {
31272
31292
  if (options?.acceptRaw) {
31273
31293
  const rawBody = await response.text();
@@ -31396,6 +31416,11 @@ var PROFILES = {
31396
31416
  full: []
31397
31417
  // empty = all groups
31398
31418
  };
31419
+ function parseGroupList(value) {
31420
+ if (!value) return null;
31421
+ const parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
31422
+ return parsed.length > 0 ? parsed : null;
31423
+ }
31399
31424
  function parseReadonlyFlag(value) {
31400
31425
  return value === "1" || value === "true";
31401
31426
  }
@@ -31414,8 +31439,7 @@ function filterTools(groups, options) {
31414
31439
  unknownProfile = profileKey;
31415
31440
  }
31416
31441
  }
31417
- const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
31418
- const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31442
+ const explicitTools = parseGroupList(options.tools);
31419
31443
  const explicitToolsAllUnknown = explicitTools?.every((g) => !validNames.has(g)) ?? false;
31420
31444
  const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
31421
31445
  const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
@@ -31423,11 +31447,15 @@ function filterTools(groups, options) {
31423
31447
  const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : [];
31424
31448
  const unknownProfileGroups = profileGroups && !effectiveExplicitTools ? profileGroups.filter((g) => !validNames.has(g)) : [];
31425
31449
  const readonly2 = parseReadonlyFlag(options.readonly);
31450
+ const requestedWriteGroups = parseGroupList(options.writeGroups);
31451
+ const unknownWriteGroups = requestedWriteGroups ? requestedWriteGroups.filter((g) => !validNames.has(g)) : [];
31452
+ const writeScope = requestedWriteGroups ? new Set(requestedWriteGroups.filter((g) => validNames.has(g))) : null;
31426
31453
  const out = [];
31427
31454
  for (const [name, tools] of Object.entries(groups)) {
31428
31455
  if (enabledGroups && !enabledGroups.has(name)) continue;
31456
+ const writesAllowed = !readonly2 && (writeScope === null || writeScope.has(name));
31429
31457
  for (const t of tools) {
31430
- if (readonly2 && t.annotations.readOnlyHint !== true) continue;
31458
+ if (t.annotations.readOnlyHint !== true && !writesAllowed) continue;
31431
31459
  out.push(t);
31432
31460
  }
31433
31461
  }
@@ -31438,10 +31466,71 @@ function filterTools(groups, options) {
31438
31466
  if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
31439
31467
  if (profileWouldFilter) result.profileWouldFilter = true;
31440
31468
  if (explicitToolsAllUnknown) result.toolsAllUnknown = true;
31469
+ if (writeScope) {
31470
+ const loaded = [...writeScope].filter((g) => !enabledGroups || enabledGroups.has(g));
31471
+ result.writeGroups = readonly2 ? [] : loaded.sort();
31472
+ const overridden = readonly2 && writeScope.size > 0;
31473
+ if (overridden) result.writeGroupsOverriddenByReadonly = true;
31474
+ const notLoaded = overridden ? [] : [...writeScope].filter((g) => enabledGroups && !enabledGroups.has(g));
31475
+ if (notLoaded.length > 0) result.writeGroupsNotLoaded = notLoaded.sort();
31476
+ }
31477
+ if (unknownWriteGroups.length > 0) result.unknownWriteGroups = unknownWriteGroups;
31441
31478
  return result;
31442
31479
  }
31443
31480
 
31444
31481
  // src/tools/acl.ts
31482
+ var DEFAULT_PRINCIPAL_CAP = 25;
31483
+ var DIFF_TIME_BUDGET_MS = 6e4;
31484
+ function accessSet(matches) {
31485
+ const out = /* @__PURE__ */ new Set();
31486
+ for (const match of matches) {
31487
+ const via = [...match.via ?? []].sort();
31488
+ const postures = [...match.postures ?? []].sort();
31489
+ const qualifier = `${via.length > 0 ? ` via ${via.join(",")}` : ""}${postures.length > 0 ? ` posture ${postures.join(",")}` : ""}`;
31490
+ for (const port of match.ports ?? []) out.add(`${port}${qualifier}`);
31491
+ }
31492
+ return out;
31493
+ }
31494
+ async function previewAccess(policy, principal) {
31495
+ const params = new URLSearchParams({ type: "user", previewFor: principal });
31496
+ const res = await apiPost(`/tailnet/${getTailnet()}/acl/preview?${params}`, void 0, {
31497
+ rawBody: policy,
31498
+ contentType: "application/hujson",
31499
+ acceptRaw: true,
31500
+ accept: "application/hujson"
31501
+ });
31502
+ if (!res.ok) return { ok: false, error: res.error || `HTTP ${res.status}`, status: res.status };
31503
+ let parsed;
31504
+ try {
31505
+ parsed = JSON.parse(res.rawBody ?? "");
31506
+ } catch {
31507
+ return {
31508
+ ok: false,
31509
+ error: "the preview response was not valid JSON, so its rules could not be compared",
31510
+ status: res.status
31511
+ };
31512
+ }
31513
+ if (!Array.isArray(parsed.matches)) {
31514
+ return {
31515
+ ok: false,
31516
+ error: "the preview response contained no `matches` array, so its rules could not be compared",
31517
+ status: res.status
31518
+ };
31519
+ }
31520
+ return { ok: true, access: accessSet(parsed.matches) };
31521
+ }
31522
+ var ETAG_FOOTER_MARKER = "// ETag: ";
31523
+ function stripEtagFooter(body) {
31524
+ const lines = body.split("\n");
31525
+ let cut = lines.length;
31526
+ for (let i = lines.length - 1; i >= 0; i--) {
31527
+ const line = lines[i].trim();
31528
+ if (line === "") continue;
31529
+ if (!line.startsWith("//")) break;
31530
+ if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
31531
+ }
31532
+ return lines.slice(0, cut).join("\n");
31533
+ }
31445
31534
  var aclTools = [
31446
31535
  {
31447
31536
  name: "tailscale_get_acl",
@@ -31462,12 +31551,12 @@ var aclTools = [
31462
31551
  if (res.ok && res.etag) {
31463
31552
  const footer = [
31464
31553
  "",
31465
- `// ETag: ${res.etag}`,
31554
+ `${ETAG_FOOTER_MARKER}${res.etag}`,
31466
31555
  "// Pass this ETag to tailscale_update_acl when updating the policy.",
31467
31556
  "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
31468
31557
  ""
31469
31558
  ].join("\n");
31470
- return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
31559
+ return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
31471
31560
  }
31472
31561
  return res;
31473
31562
  }
@@ -31478,7 +31567,10 @@ var aclTools = [
31478
31567
  annotations: {
31479
31568
  title: "Update ACL policy",
31480
31569
  readOnlyHint: false,
31481
- destructiveHint: false,
31570
+ // Overwrites the whole policy file in one call, and a bad push can lock
31571
+ // every device out of the tailnet -- the widest blast radius of any write
31572
+ // here, so clients must gate it rather than auto-approve it.
31573
+ destructiveHint: true,
31482
31574
  idempotentHint: true,
31483
31575
  openWorldHint: true
31484
31576
  },
@@ -31486,8 +31578,16 @@ var aclTools = [
31486
31578
  policy: external_exports.string().describe(
31487
31579
  "The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
31488
31580
  ),
31489
- etag: external_exports.string().describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
31581
+ 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.")
31490
31582
  }),
31583
+ // `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
31584
+ // `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
31585
+ // omitted entirely -- the write then overwrites a concurrent admin edit instead
31586
+ // of coming back 412, on the widest-blast-radius write in the package, with
31587
+ // no diagnostic anywhere. `.trim()` is load-bearing for the same reason it is
31588
+ // on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
31589
+ // header goes out carrying a precondition that cannot match any real ETag --
31590
+ // a confusing 412 instead of a local validation error naming the field.
31491
31591
  handler: async (input) => {
31492
31592
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31493
31593
  rawBody: input.policy,
@@ -31551,6 +31651,158 @@ var aclTools = [
31551
31651
  accept: "application/hujson"
31552
31652
  });
31553
31653
  }
31654
+ },
31655
+ {
31656
+ name: "tailscale_diff_acl_access",
31657
+ 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; it does not detect a change to a posture DEFINITION, because the comparison keys on posture names; and a narrowed port list shows as a paired loss and gain of the whole entry rather than a clean loss. An empty result is never proof a change is safe. 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.",
31658
+ annotations: {
31659
+ title: "Diff ACL access",
31660
+ readOnlyHint: true,
31661
+ destructiveHint: false,
31662
+ idempotentHint: true,
31663
+ openWorldHint: true
31664
+ },
31665
+ inputSchema: external_exports.object({
31666
+ policy: external_exports.string().describe("The proposed ACL policy text to compare against the current live policy"),
31667
+ principals: external_exports.array(external_exports.string().trim().min(1)).optional().describe(
31668
+ "User emails to check. Omit to enumerate the tailnet's users automatically. Pass an explicit list to bound the request count, or to check specific users beyond the cap."
31669
+ ),
31670
+ maxPrincipals: external_exports.number().int().positive().optional().describe(
31671
+ `Maximum users to check (default ${DEFAULT_PRINCIPAL_CAP}). Each costs two preview requests. Raising this on a large tailnet can be slow and may hit rate limits.`
31672
+ )
31673
+ }),
31674
+ handler: async (input) => {
31675
+ const startedAt = Date.now();
31676
+ const current = await apiGet(`/tailnet/${getTailnet()}/acl`, {
31677
+ acceptRaw: true,
31678
+ accept: "application/hujson"
31679
+ });
31680
+ if (!current.ok) {
31681
+ return {
31682
+ ok: false,
31683
+ error: `could not read the current ACL to diff against: ${current.error || `HTTP ${current.status}`}`
31684
+ };
31685
+ }
31686
+ const baselinePolicy = current.rawBody ?? "";
31687
+ let principals;
31688
+ let availableTotal;
31689
+ if (input.principals !== void 0) {
31690
+ principals = [...new Set(input.principals)];
31691
+ if (principals.length === 0) {
31692
+ return {
31693
+ ok: false,
31694
+ error: "`principals` was passed as an empty list, so no users were named to compare. Omit the argument entirely to enumerate the tailnet's users, or name at least one email."
31695
+ };
31696
+ }
31697
+ availableTotal = principals.length;
31698
+ } else {
31699
+ const usersRes = await apiGet(`/tailnet/${getTailnet()}/users`);
31700
+ if (!usersRes.ok) {
31701
+ return { ok: false, error: `could not list users to diff: ${usersRes.error || `HTTP ${usersRes.status}`}` };
31702
+ }
31703
+ const emails = (usersRes.data?.users ?? []).map((u) => u.loginName ?? u.email ?? u.name).filter((v) => typeof v === "string" && v.trim().length > 0);
31704
+ principals = [...new Set(emails)];
31705
+ availableTotal = principals.length;
31706
+ if (principals.length === 0) {
31707
+ return {
31708
+ ok: false,
31709
+ error: "no user emails could be read from the tailnet's user list, so there is nothing to compare. Pass `principals` explicitly with the emails to check -- an empty diff here would wrongly suggest the change affects nobody."
31710
+ };
31711
+ }
31712
+ }
31713
+ const cap = input.maxPrincipals ?? DEFAULT_PRINCIPAL_CAP;
31714
+ const checked = principals.slice(0, cap);
31715
+ const changed = [];
31716
+ const unchanged = [];
31717
+ const failed = [];
31718
+ let attempted = 0;
31719
+ let stoppedOnTime = false;
31720
+ for (const principal of checked) {
31721
+ if (Date.now() - startedAt > DIFF_TIME_BUDGET_MS) {
31722
+ stoppedOnTime = true;
31723
+ break;
31724
+ }
31725
+ attempted++;
31726
+ const [before, after] = await Promise.all([
31727
+ previewAccess(baselinePolicy, principal),
31728
+ previewAccess(input.policy, principal)
31729
+ ]);
31730
+ if (!before.ok || !after.ok) {
31731
+ const failure = !before.ok ? before : !after.ok ? after : void 0;
31732
+ const which = !before.ok ? "current" : "proposed";
31733
+ failed.push({
31734
+ principal,
31735
+ error: `preview against the ${which} policy failed: ${failure?.error ?? "unknown"}`
31736
+ });
31737
+ if (failure?.status === 401 || failure?.status === 403) {
31738
+ return {
31739
+ ok: false,
31740
+ error: `authentication failed while previewing ${principal}, so the diff was abandoned rather than continued against a credential the API has already refused: ${failure?.error ?? "unknown"}`
31741
+ };
31742
+ }
31743
+ continue;
31744
+ }
31745
+ const lost = [...before.access].filter((a) => !after.access.has(a)).sort();
31746
+ const gained = [...after.access].filter((a) => !before.access.has(a)).sort();
31747
+ if (lost.length === 0 && gained.length === 0) unchanged.push(principal);
31748
+ else changed.push({ principal, lost, gained });
31749
+ }
31750
+ const compared = attempted - failed.length;
31751
+ if (compared === 0) {
31752
+ return {
31753
+ ok: false,
31754
+ error: stoppedOnTime ? `no users could be compared: the ${DIFF_TIME_BUDGET_MS / 1e3}s budget for this call was spent before any comparison finished. Narrow the run with \`principals\`, or lower \`maxPrincipals\`.` : `no users could be compared: all ${attempted} preview attempts failed. First failure: ${failed[0]?.error ?? "unknown"}`
31755
+ };
31756
+ }
31757
+ const losing = changed.filter((c) => c.lost.length > 0).length;
31758
+ const gaining = changed.filter((c) => c.gained.length > 0).length;
31759
+ const notChecked = principals.length - attempted;
31760
+ const truncated = notChecked > 0;
31761
+ const summary = [
31762
+ // Failures lead when present, so the headline cannot read as an
31763
+ // all-clear over a partially-compared run.
31764
+ failed.length > 0 ? `${failed.length} of ${attempted} users could not be checked` : null,
31765
+ `${losing} of ${compared} users compared lose access`,
31766
+ `${gaining} gain access`,
31767
+ `${unchanged.length} unchanged`,
31768
+ // Names WHICH limit stopped the run: "cap 25" tells the caller to raise
31769
+ // maxPrincipals, and the time budget tells them the opposite -- that
31770
+ // raising it would make things worse, and the run needs narrowing.
31771
+ truncated ? `${notChecked} not checked (${stoppedOnTime ? `${DIFF_TIME_BUDGET_MS / 1e3}s time budget` : `cap ${cap}`})` : null
31772
+ ].filter(Boolean).join(", ");
31773
+ return {
31774
+ ok: true,
31775
+ data: {
31776
+ tailnet: getTailnet(),
31777
+ summary,
31778
+ // Three numbers, because two of them were being conflated. `Compared`
31779
+ // is the only one that describes work actually done.
31780
+ principalsCompared: compared,
31781
+ principalsFailed: failed.length,
31782
+ principalsAvailable: availableTotal,
31783
+ truncated,
31784
+ // Distinguishes the two truncation causes for a machine reader, which
31785
+ // the summary string does only in prose. They call for opposite
31786
+ // responses: a cap stop means raise maxPrincipals, a time stop means
31787
+ // narrow the run.
31788
+ stoppedOnTimeBudget: stoppedOnTime,
31789
+ // Restated in the payload, not just the tool description: whoever
31790
+ // reads this output is deciding whether to apply the change, and may
31791
+ // never have read the description. Each clause names a way this diff
31792
+ // can come back empty while real access changed.
31793
+ scope: [
31794
+ "User principals only.",
31795
+ "Not compared: access granted via tags or groups;",
31796
+ "changes to a posture DEFINITION (the diff keys on posture names, so redefining `posture:corp` more strictly leaves every key identical);",
31797
+ "and a destination whose port list is narrowed appears as a paired loss and gain of the whole entry (`tag:prod:22,80` -> `tag:prod:22`) rather than a clean loss, so `gained` is not a literal list of newly-reachable destinations.",
31798
+ "An empty diff is not proof the change is safe."
31799
+ ].join(" "),
31800
+ changed,
31801
+ unchanged,
31802
+ failed
31803
+ }
31804
+ };
31805
+ }
31554
31806
  }
31555
31807
  ];
31556
31808
 
@@ -31805,7 +32057,9 @@ var deviceTools = [
31805
32057
  annotations: {
31806
32058
  title: "Set device routes",
31807
32059
  readOnlyHint: false,
31808
- destructiveHint: false,
32060
+ // Replace-all: the routes array is the new enabled set, so `[]` silently
32061
+ // withdraws every subnet the device currently routes.
32062
+ destructiveHint: true,
31809
32063
  idempotentHint: true,
31810
32064
  openWorldHint: true
31811
32065
  },
@@ -31890,7 +32144,9 @@ var deviceTools = [
31890
32144
  annotations: {
31891
32145
  title: "Set device tags",
31892
32146
  readOnlyHint: false,
31893
- destructiveHint: false,
32147
+ // Replace-all: `[]` strips every ACL tag, which can drop the device out of
32148
+ // the policy rules that grant it access.
32149
+ destructiveHint: true,
31894
32150
  idempotentHint: true,
31895
32151
  openWorldHint: true
31896
32152
  },
@@ -31969,7 +32225,7 @@ var deviceTools = [
31969
32225
  const failed = {};
31970
32226
  for (const { deviceId, res } of results) {
31971
32227
  if (res.ok) succeeded.push(deviceId);
31972
- else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
32228
+ else failed[deviceId] = { status: res.status, error: res.error || `HTTP ${res.status}` };
31973
32229
  }
31974
32230
  const failedCount = Object.keys(failed).length;
31975
32231
  if (failedCount === unique.length) {
@@ -32053,7 +32309,9 @@ var dnsTools = [
32053
32309
  annotations: {
32054
32310
  title: "Set nameservers",
32055
32311
  readOnlyHint: false,
32056
- destructiveHint: false,
32312
+ // Replace-all: `[]` clears tailnet DNS resolution rather than leaving the
32313
+ // current nameservers in place.
32314
+ destructiveHint: true,
32057
32315
  idempotentHint: true,
32058
32316
  openWorldHint: true
32059
32317
  },
@@ -32085,7 +32343,8 @@ var dnsTools = [
32085
32343
  annotations: {
32086
32344
  title: "Set DNS search paths",
32087
32345
  readOnlyHint: false,
32088
- destructiveHint: false,
32346
+ // Replace-all: `[]` clears every configured search domain.
32347
+ destructiveHint: true,
32089
32348
  idempotentHint: true,
32090
32349
  openWorldHint: true
32091
32350
  },
@@ -32119,7 +32378,9 @@ var dnsTools = [
32119
32378
  annotations: {
32120
32379
  title: "Set split DNS",
32121
32380
  readOnlyHint: false,
32122
- destructiveHint: false,
32381
+ // Replace-all (PUT): every domain absent from the map is dropped, so `{}`
32382
+ // clears the whole split DNS config. The PATCH sibling below merges instead.
32383
+ destructiveHint: true,
32123
32384
  idempotentHint: true,
32124
32385
  openWorldHint: true
32125
32386
  },
@@ -32206,7 +32467,9 @@ var dnsTools = [
32206
32467
  annotations: {
32207
32468
  title: "Set DNS configuration (unified)",
32208
32469
  readOnlyHint: false,
32209
- destructiveHint: false,
32470
+ // Replace-all across every DNS setting at once -- a superset of the wipe
32471
+ // the individual setters above can do.
32472
+ destructiveHint: true,
32210
32473
  idempotentHint: true,
32211
32474
  openWorldHint: true
32212
32475
  },
@@ -32663,6 +32926,48 @@ var keyTools = [
32663
32926
  handler: async (input) => {
32664
32927
  return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32665
32928
  }
32929
+ },
32930
+ {
32931
+ name: "tailscale_list_oauth_apps",
32932
+ description: "List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an `oauthApps` array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.",
32933
+ annotations: {
32934
+ title: "List OAuth apps",
32935
+ readOnlyHint: true,
32936
+ destructiveHint: false,
32937
+ idempotentHint: true,
32938
+ openWorldHint: true
32939
+ },
32940
+ // No limit/cursor: the live endpoint takes no parameters and returns the
32941
+ // whole collection. Advertising pagination the API ignores would let a
32942
+ // caller believe it had paged through the list when it had not.
32943
+ inputSchema: external_exports.object({}),
32944
+ handler: async () => {
32945
+ return apiGet(`/tailnet/${getTailnet()}/oauth-apps`);
32946
+ }
32947
+ },
32948
+ {
32949
+ name: "tailscale_delete_oauth_app",
32950
+ description: "Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.",
32951
+ annotations: {
32952
+ title: "Delete OAuth app",
32953
+ readOnlyHint: false,
32954
+ destructiveHint: true,
32955
+ idempotentHint: true,
32956
+ openWorldHint: true
32957
+ },
32958
+ inputSchema: external_exports.object({
32959
+ // `.trim().min(1)` rather than the bare `.min(1)` on
32960
+ // tailscale_get_oauth_app's appId, for the reason tailnets.ts spells out
32961
+ // on tailscale_delete_tailnet: a bare min(1) accepts " ", which encPath
32962
+ // then sends as the literal segment "%20". On a read that costs a wasted
32963
+ // round-trip; on an irreversible revoke it returns a 404 that reads like
32964
+ // the app is already gone. Trimming at the schema makes it a validation
32965
+ // error instead.
32966
+ appId: external_exports.string().trim().min(1).describe("The OAuth app ID to delete (see tailscale_list_oauth_apps)")
32967
+ }),
32968
+ handler: async (input) => {
32969
+ return apiDelete(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32970
+ }
32666
32971
  }
32667
32972
  ];
32668
32973
 
@@ -32866,8 +33171,8 @@ var logStreamingTools = [
32866
33171
  apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
32867
33172
  ]);
32868
33173
  const errors = {};
32869
- if (!configuration.ok) errors.configuration = configuration.error ?? `HTTP ${configuration.status}`;
32870
- if (!network.ok) errors.network = network.error ?? `HTTP ${network.status}`;
33174
+ if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
33175
+ if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
32871
33176
  if (!configuration.ok && !network.ok) {
32872
33177
  return {
32873
33178
  ok: false,
@@ -33353,8 +33658,8 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
33353
33658
  settings: settingsRes.ok ? settingsRes.data : null
33354
33659
  };
33355
33660
  const errors = {};
33356
- if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
33357
- if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
33661
+ if (!devicesRes.ok) errors.devices = devicesRes.error || `HTTP ${devicesRes.status}`;
33662
+ if (!settingsRes.ok) errors.settings = settingsRes.error || `HTTP ${settingsRes.status}`;
33358
33663
  if (Object.keys(errors).length > 0) data.errors = errors;
33359
33664
  return data;
33360
33665
  }
@@ -33486,7 +33791,7 @@ var tailnetTools = [
33486
33791
  const failed = {};
33487
33792
  for (const { contactType, res } of results) {
33488
33793
  if (res.ok) applied[contactType] = res.data;
33489
- else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
33794
+ else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
33490
33795
  }
33491
33796
  const hasFailed = Object.keys(failed).length > 0;
33492
33797
  const hasApplied = Object.keys(applied).length > 0;
@@ -33573,7 +33878,7 @@ var tailnetsTools = [
33573
33878
  },
33574
33879
  {
33575
33880
  name: "tailscale_delete_tailnet",
33576
- description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33881
+ description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. That check is a typo guard, not an authorization gate: when you also pass `tailnet` you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-`tailnet` path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33577
33882
  annotations: {
33578
33883
  title: "Delete tailnet",
33579
33884
  readOnlyHint: false,
@@ -33586,7 +33891,7 @@ var tailnetsTools = [
33586
33891
  "Tailnet to delete (e.g. an id from tailscale_list_org_tailnets). Omit to target the configured tailnet. Requires credentials scoped to reach it."
33587
33892
  ),
33588
33893
  confirmTailnet: external_exports.string().trim().min(1).describe(
33589
- "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A deliberate second look before an irreversible org-wide delete."
33894
+ "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A typo guard, not an authorization gate: on the explicit-`tailnet` path the caller writes both halves of the comparison, so it proves only self-agreement. It is a real second look only when `tailnet` is omitted and the value has to match the operator's environment."
33590
33895
  )
33591
33896
  }),
33592
33897
  // `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
@@ -33604,8 +33909,9 @@ var tailnetsTools = [
33604
33909
  );
33605
33910
  }
33606
33911
  if (input.confirmTailnet !== target) {
33912
+ const source = input.tailnet?.trim() ? "tailnet you named" : "configured tailnet";
33607
33913
  throw new Error(
33608
- `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the configured tailnet ${JSON.stringify(target)}. Refusing to delete.`
33914
+ `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the ${source} ${JSON.stringify(target)}. Refusing to delete.`
33609
33915
  );
33610
33916
  }
33611
33917
  return apiDelete(`/tailnet/${encPath(target)}`);
@@ -33927,6 +34233,42 @@ var webhookTools = [
33927
34233
  function isLocalCliEnabled(env) {
33928
34234
  return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
33929
34235
  }
34236
+ function isRequireApprovalEnabled(env) {
34237
+ return env.TAILSCALE_REQUIRE_APPROVAL === "1" || env.TAILSCALE_REQUIRE_APPROVAL === "true";
34238
+ }
34239
+ var FORCED_APPROVAL_TOOLS = [
34240
+ "tailscale_delete_device",
34241
+ "tailscale_delete_key",
34242
+ "tailscale_delete_log_stream_config",
34243
+ "tailscale_delete_oauth_app",
34244
+ "tailscale_delete_posture_integration",
34245
+ "tailscale_delete_tailnet",
34246
+ "tailscale_delete_user",
34247
+ "tailscale_delete_webhook",
34248
+ "tailscale_update_acl"
34249
+ ];
34250
+ var MAX_RESULT_SIZE_CHARS = 5e5;
34251
+ var LARGE_RESULT_TOOLS = [
34252
+ // Scales with BOTH the number of principals checked and each one's grant
34253
+ // count, so for a given tailnet its payload is strictly larger than
34254
+ // list_users below.
34255
+ "tailscale_diff_acl_access",
34256
+ "tailscale_get_acl",
34257
+ "tailscale_get_audit_log",
34258
+ "tailscale_get_network_flow_logs",
34259
+ "tailscale_list_devices",
34260
+ "tailscale_list_users"
34261
+ ];
34262
+ function buildToolMeta(toolName, options) {
34263
+ const meta3 = {};
34264
+ if (options.requireApproval && FORCED_APPROVAL_TOOLS.includes(toolName)) {
34265
+ meta3["anthropic/requiresUserInteraction"] = true;
34266
+ }
34267
+ if (LARGE_RESULT_TOOLS.includes(toolName)) {
34268
+ meta3["anthropic/maxResultSizeChars"] = MAX_RESULT_SIZE_CHARS;
34269
+ }
34270
+ return Object.keys(meta3).length > 0 ? meta3 : void 0;
34271
+ }
33930
34272
  function buildToolGroups(env) {
33931
34273
  const toolGroups = {
33932
34274
  status: statusTools,
@@ -33968,7 +34310,11 @@ function formatBannerFilterSuffix(inputs) {
33968
34310
  return [
33969
34311
  profileLabel,
33970
34312
  groupsLabel,
33971
- inputs.readonlyMode ? "readonly" : null,
34313
+ inputs.readonlyMode ? inputs.writeGroupsOverriddenByReadonly ? "readonly (TAILSCALE_WRITE_GROUPS ignored)" : "readonly" : null,
34314
+ // `write=none` (configured, granted nothing) is a different state from no segment
34315
+ // at all (knob unset, every write served), and an operator debugging "why can it
34316
+ // not write" needs to tell them apart at a glance.
34317
+ inputs.writeGroups ? inputs.writeGroups.length > 0 ? `write=${inputs.writeGroups.join(",")}` : "write=none" : null,
33972
34318
  inputs.localCliEnabled ? "local-cli=on" : null
33973
34319
  ].filter(Boolean).join(", ");
33974
34320
  }
@@ -34011,7 +34357,7 @@ async function tailnetStatusResource(uri) {
34011
34357
  }
34012
34358
  async function tailnetDevicesResource(uri) {
34013
34359
  const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
34014
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
34360
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error || `HTTP ${res.status}` }, null, 2);
34015
34361
  return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
34016
34362
  }
34017
34363
  async function tailnetAclResource(uri) {
@@ -34019,7 +34365,7 @@ async function tailnetAclResource(uri) {
34019
34365
  if (res.ok) {
34020
34366
  return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
34021
34367
  }
34022
- const lines = `Error: ${res.error ?? `HTTP ${res.status}`}`.split("\n");
34368
+ const lines = `Error: ${res.error || `HTTP ${res.status}`}`.split("\n");
34023
34369
  const text = `${lines.map((l) => `// ${l}`).join("\n")}
34024
34370
  `;
34025
34371
  return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
@@ -34038,16 +34384,161 @@ async function tailnetDnsResource(uri) {
34038
34384
  preferences: preferences.ok ? preferences.data : null
34039
34385
  };
34040
34386
  const errors = {};
34041
- if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
34042
- if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
34043
- if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
34044
- if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
34387
+ if (!nameservers.ok) errors.nameservers = nameservers.error || `HTTP ${nameservers.status}`;
34388
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error || `HTTP ${searchPaths.status}`;
34389
+ if (!splitDns.ok) errors.splitDns = splitDns.error || `HTTP ${splitDns.status}`;
34390
+ if (!preferences.ok) errors.preferences = preferences.error || `HTTP ${preferences.status}`;
34045
34391
  if (Object.keys(errors).length > 0) data.errors = errors;
34046
34392
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
34047
34393
  }
34048
34394
 
34395
+ // src/tools/meta.ts
34396
+ function explainNotLoaded(group, state) {
34397
+ if (group === "local-cli" && !state.localCliEnabled) {
34398
+ return {
34399
+ reason: "the local-CLI group is opt-in and is not enabled in this process",
34400
+ toEnable: "set TAILSCALE_LOCAL_CLI=1"
34401
+ };
34402
+ }
34403
+ if (state.toolsEnv) {
34404
+ return {
34405
+ reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
34406
+ toEnable: `add "${group}" to TAILSCALE_TOOLS`
34407
+ };
34408
+ }
34409
+ if (state.profileEnv) {
34410
+ return {
34411
+ reason: `TAILSCALE_PROFILE="${state.profileEnv}" does not include this group`,
34412
+ toEnable: `set TAILSCALE_PROFILE=full, or list the groups you want in TAILSCALE_TOOLS`
34413
+ };
34414
+ }
34415
+ return {
34416
+ reason: "this group did not register, and no load filter is configured to explain why",
34417
+ toEnable: "no env change is known to enable it -- please report this at https://github.com/YawLabs/tailscale-mcp/issues"
34418
+ };
34419
+ }
34420
+ function explainWritesWithheld(group, state) {
34421
+ if (state.readonlyMode) {
34422
+ return {
34423
+ reason: "TAILSCALE_READONLY is enabled, so no group serves writes",
34424
+ toEnable: "unset TAILSCALE_READONLY"
34425
+ };
34426
+ }
34427
+ const current = state.writeGroupsEnv?.trim();
34428
+ return {
34429
+ reason: current ? `TAILSCALE_WRITE_GROUPS is set to "${current}", which does not grant writes here` : "writes are withheld in this group",
34430
+ toEnable: current ? `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${current},${group}")` : `add "${group}" to TAILSCALE_WRITE_GROUPS`
34431
+ };
34432
+ }
34433
+ function buildGroupReports(state) {
34434
+ const reports = [];
34435
+ for (const [group, tools] of Object.entries(state.fullRegistry)) {
34436
+ const available = tools.filter((t) => state.registeredNames.has(t.name));
34437
+ const writes = tools.filter((t) => t.annotations.readOnlyHint !== true);
34438
+ const writesAvailable = writes.filter((t) => state.registeredNames.has(t.name));
34439
+ const base = {
34440
+ group,
34441
+ tools: tools.length,
34442
+ available: available.length,
34443
+ writes: writes.length,
34444
+ writesAvailable: writesAvailable.length
34445
+ };
34446
+ if (available.length === 0) {
34447
+ reports.push({ ...base, status: "unavailable", ...explainNotLoaded(group, state) });
34448
+ continue;
34449
+ }
34450
+ if (writes.length > 0 && writesAvailable.length === 0) {
34451
+ reports.push({ ...base, status: "read-only", ...explainWritesWithheld(group, state) });
34452
+ continue;
34453
+ }
34454
+ reports.push({ ...base, status: "full" });
34455
+ }
34456
+ return reports;
34457
+ }
34458
+ function explainTool(toolName, state) {
34459
+ const query = toolName.trim();
34460
+ for (const [group, tools] of Object.entries(state.fullRegistry)) {
34461
+ const tool = tools.find((t) => t.name === query);
34462
+ if (!tool) continue;
34463
+ const isWrite = tool.annotations.readOnlyHint !== true;
34464
+ if (state.registeredNames.has(query)) {
34465
+ return {
34466
+ tool: query,
34467
+ available: true,
34468
+ group,
34469
+ kind: isWrite ? "write" : "read",
34470
+ reason: "this tool is registered and callable right now"
34471
+ };
34472
+ }
34473
+ const groupHasAny = tools.some((t) => state.registeredNames.has(t.name));
34474
+ const explain = groupHasAny ? explainWritesWithheld(group, state) : explainNotLoaded(group, state);
34475
+ return {
34476
+ tool: query,
34477
+ available: false,
34478
+ group,
34479
+ kind: isWrite ? "write" : "read",
34480
+ ...explain
34481
+ };
34482
+ }
34483
+ return {
34484
+ tool: query,
34485
+ available: false,
34486
+ // The case an agent most needs separated from the others: no configuration
34487
+ // change will produce this tool, so retrying or asking the operator is wasted.
34488
+ // Only here is "find another way" the right conclusion.
34489
+ reason: "no tool by that name exists in this server, under any configuration. Check the spelling, or call this tool with no arguments to see what is available."
34490
+ };
34491
+ }
34492
+ function buildMetaTools(state) {
34493
+ return [
34494
+ {
34495
+ name: "tailscale_tool_groups",
34496
+ description: "Explain which of this server's tools are available and why. Call this FIRST when a Tailscale tool you expected is missing, instead of assuming the capability does not exist -- tools can be withheld by configuration, and the fix is usually one environment variable. Pass `toolName` to ask about one specific tool (e.g. 'tailscale_delete_device'): the answer distinguishes 'no such tool exists' -- where you should find another approach -- from 'it exists but its group is not loaded' and 'it exists and loaded but writes are withheld there', both of which the operator can enable and neither of which you should work around. With no arguments it lists every group with its availability and, where something is withheld, the exact environment change that would restore it. Always available regardless of filters.",
34497
+ annotations: {
34498
+ title: "Explain available tools",
34499
+ readOnlyHint: true,
34500
+ destructiveHint: false,
34501
+ idempotentHint: true,
34502
+ // The only tool here that touches no network: it reports this process's own
34503
+ // configuration, so it cannot fail on credentials, scope or connectivity.
34504
+ openWorldHint: false
34505
+ },
34506
+ inputSchema: external_exports.object({
34507
+ toolName: external_exports.string().trim().min(1).optional().describe("A specific tool to ask about, e.g. 'tailscale_delete_device'. Omit to list every group.")
34508
+ }),
34509
+ handler: async (input) => {
34510
+ if (input.toolName) {
34511
+ return { ok: true, data: explainTool(input.toolName, state) };
34512
+ }
34513
+ const groups = buildGroupReports(state);
34514
+ const withheld = groups.filter((g) => g.status !== "full");
34515
+ const totalTools = groups.reduce((n, g) => n + g.tools, 0);
34516
+ const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
34517
+ const activeFilters = [
34518
+ state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
34519
+ state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
34520
+ state.readonlyMode ? "TAILSCALE_READONLY=1" : null,
34521
+ state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
34522
+ state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
34523
+ ].filter(Boolean);
34524
+ return {
34525
+ ok: true,
34526
+ data: {
34527
+ summary: withheld.length === 0 ? `All ${totalAvailable} tools are available; nothing is withheld by configuration.` : `${totalAvailable} of ${totalTools} tools are available. ${withheld.length} group(s) are limited by configuration -- see \`groups\` for the exact environment change for each.`,
34528
+ activeFilters: activeFilters.length > 0 ? activeFilters : ["none -- no filters are configured"],
34529
+ groups,
34530
+ // Addressed to the model, because it is the model that has to decide
34531
+ // what to do next when a tool is absent.
34532
+ guidance: "A tool listed as unavailable here EXISTS -- it is withheld by this server's configuration, not missing from the API. Do not work around it by using a different tool to achieve the same effect; report the `toEnable` value to the human instead, since only they can change it."
34533
+ }
34534
+ };
34535
+ }
34536
+ }
34537
+ ];
34538
+ }
34539
+
34049
34540
  // src/index.ts
34050
- var version2 = true ? "0.17.1" : resolveVersionFallback();
34541
+ var version2 = true ? "0.19.0" : resolveVersionFallback();
34051
34542
  var subcommand = process.argv[2];
34052
34543
  var cliSubcommandHandled = false;
34053
34544
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -34080,11 +34571,16 @@ if (!cliSubcommandHandled) {
34080
34571
  unknownProfile,
34081
34572
  explicitTools,
34082
34573
  profileWouldFilter,
34083
- toolsAllUnknown
34574
+ toolsAllUnknown,
34575
+ writeGroups,
34576
+ unknownWriteGroups,
34577
+ writeGroupsNotLoaded,
34578
+ writeGroupsOverriddenByReadonly
34084
34579
  } = filterTools(toolGroups, {
34085
34580
  tools: process.env.TAILSCALE_TOOLS,
34086
34581
  readonly: process.env.TAILSCALE_READONLY,
34087
- profile: process.env.TAILSCALE_PROFILE
34582
+ profile: process.env.TAILSCALE_PROFILE,
34583
+ writeGroups: process.env.TAILSCALE_WRITE_GROUPS
34088
34584
  });
34089
34585
  if (unknownGroups.length > 0) {
34090
34586
  const validNames = Object.keys(toolGroups);
@@ -34093,6 +34589,30 @@ if (!cliSubcommandHandled) {
34093
34589
  `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
34094
34590
  );
34095
34591
  }
34592
+ if (unknownWriteGroups && unknownWriteGroups.length > 0) {
34593
+ const validNames = Object.keys(toolGroups);
34594
+ const everyPossibleGroup = Object.keys(buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }));
34595
+ const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
34596
+ const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
34597
+ if (notEnabled.length > 0) {
34598
+ console.error(
34599
+ `@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS names group(s) that exist but are not enabled in this process: ${notEnabled.join(", ")}. Set TAILSCALE_LOCAL_CLI=1 to register the local-cli group. Not a typo -- the grant simply had nothing to apply to.`
34600
+ );
34601
+ }
34602
+ if (realTypos.length > 0) {
34603
+ const sentinels = /* @__PURE__ */ new Set(["none", "off", "false", "0", "all", "*"]);
34604
+ const guessed = realTypos.filter((g) => sentinels.has(g.toLowerCase()));
34605
+ const hint = guessed.some((g) => ["all", "*"].includes(g.toLowerCase())) ? ' Note: "all" is not a group name -- leave TAILSCALE_WRITE_GROUPS unset to allow writes in every loaded group.' : guessed.length > 0 ? " Note: to disable writes entirely, TAILSCALE_READONLY=1 is the shipped spelling." : "";
34606
+ console.error(
34607
+ `@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS includes unknown group(s): ${realTypos.join(", ")}. Valid groups: ${validNames.join(", ")}. Those names granted no write access; tools outside the granted groups are served read-only.${hint}`
34608
+ );
34609
+ }
34610
+ }
34611
+ if (writeGroupsNotLoaded && writeGroupsNotLoaded.length > 0) {
34612
+ console.error(
34613
+ `@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS names group(s) that your TAILSCALE_TOOLS / TAILSCALE_PROFILE filter does not load: ${writeGroupsNotLoaded.join(", ")}. Those grants had no effect.`
34614
+ );
34615
+ }
34096
34616
  if (unknownProfileGroups && unknownProfileGroups.length > 0) {
34097
34617
  console.error(
34098
34618
  `@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.`
@@ -34104,35 +34624,79 @@ if (!cliSubcommandHandled) {
34104
34624
  }
34105
34625
  if (unknownProfile) {
34106
34626
  console.error(
34107
- `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
34627
+ `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
34108
34628
  );
34109
34629
  }
34110
34630
  const server = new McpServer({
34111
34631
  name: "@yawlabs/tailscale-mcp",
34112
34632
  version: version2
34113
34633
  });
34634
+ const requireApproval = isRequireApprovalEnabled(process.env);
34635
+ const metaTools = buildMetaTools({
34636
+ // The FULL registry, with opt-ins forced on, so the catalog can report on a group
34637
+ // that is currently disabled -- which is exactly the group an agent needs
34638
+ // explained. Reporting only what loaded would make local-cli invisible rather
34639
+ // than explained.
34640
+ fullRegistry: buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }),
34641
+ // Ground truth for availability: what this server actually serves. Deliberately
34642
+ // not a re-derivation of the filter logic, so the catalog cannot disagree with
34643
+ // the server about what exists.
34644
+ registeredNames: new Set(allTools.map((t) => t.name)),
34645
+ toolsEnv: process.env.TAILSCALE_TOOLS,
34646
+ profileEnv: process.env.TAILSCALE_PROFILE,
34647
+ writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
34648
+ readonlyMode: parseReadonlyFlag(process.env.TAILSCALE_READONLY),
34649
+ localCliEnabled
34650
+ });
34651
+ for (const tool of metaTools) {
34652
+ server.registerTool(
34653
+ tool.name,
34654
+ {
34655
+ title: tool.annotations.title,
34656
+ description: tool.description,
34657
+ inputSchema: tool.inputSchema.shape,
34658
+ annotations: tool.annotations,
34659
+ _meta: buildToolMeta(tool.name, { requireApproval })
34660
+ },
34661
+ wrapToolHandler(tool)
34662
+ );
34663
+ }
34114
34664
  for (const tool of allTools) {
34115
- server.tool(tool.name, tool.description, tool.inputSchema.shape, tool.annotations, wrapToolHandler(tool));
34665
+ server.registerTool(
34666
+ tool.name,
34667
+ {
34668
+ // Hoisted out of annotations.title, which every tool file already sets.
34669
+ // The annotations copy deliberately stays where it is: clients reading
34670
+ // the legacy location keep working, so this is purely additive on the
34671
+ // wire rather than a move.
34672
+ title: tool.annotations.title,
34673
+ description: tool.description,
34674
+ inputSchema: tool.inputSchema.shape,
34675
+ annotations: tool.annotations,
34676
+ _meta: buildToolMeta(tool.name, { requireApproval })
34677
+ },
34678
+ wrapToolHandler(tool)
34679
+ );
34116
34680
  }
34117
- server.resource(
34681
+ server.registerResource(
34118
34682
  "tailnet-status",
34119
34683
  "tailscale://tailnet/status",
34120
34684
  { description: "Current tailnet status including device count and settings", mimeType: "application/json" },
34121
34685
  tailnetStatusResource
34122
34686
  );
34123
- server.resource(
34687
+ server.registerResource(
34124
34688
  "tailnet-devices",
34125
34689
  "tailscale://tailnet/devices",
34126
34690
  { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
34127
34691
  tailnetDevicesResource
34128
34692
  );
34129
- server.resource(
34693
+ server.registerResource(
34130
34694
  "tailnet-acl",
34131
34695
  "tailscale://tailnet/acl",
34132
34696
  { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
34133
34697
  tailnetAclResource
34134
34698
  );
34135
- server.resource(
34699
+ server.registerResource(
34136
34700
  "tailnet-dns",
34137
34701
  "tailscale://tailnet/dns",
34138
34702
  {
@@ -34154,18 +34718,30 @@ if (!cliSubcommandHandled) {
34154
34718
  profileWouldFilter,
34155
34719
  profileEnv: process.env.TAILSCALE_PROFILE,
34156
34720
  readonlyMode,
34157
- localCliEnabled
34721
+ localCliEnabled,
34722
+ writeGroups,
34723
+ writeGroupsOverriddenByReadonly
34158
34724
  });
34159
34725
  console.error(
34160
34726
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
34161
34727
  );
34162
34728
  const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
34729
+ const ADMIN_EQUIVALENT = ["keys", "users", "acl"];
34730
+ const registeredNames = new Set(allTools.map((t) => t.name));
34731
+ const adminWritable = ADMIN_EQUIVALENT.filter(
34732
+ (g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
34733
+ );
34734
+ if (adminWritable.length > 0 && hasCreds) {
34735
+ console.error(
34736
+ `@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.`
34737
+ );
34738
+ }
34163
34739
  if (!filterSuffix && hasCreds) {
34164
34740
  const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
34165
34741
  const coreCount = profileCount(PROFILES.core);
34166
34742
  const minimalCount = profileCount(PROFILES.minimal);
34167
34743
  console.error(
34168
- `@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34744
+ `@yawlabs/tailscale-mcp: tip -- set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34169
34745
  );
34170
34746
  }
34171
34747
  }