@yawlabs/tailscale-mcp 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![Release](https://img.shields.io/badge/release-local-blue)](./release.sh)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 96 admin-API tools + 6 optional local-CLI diagnostics + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 97 admin-API tools + 6 optional local-CLI diagnostics + 1 always-on catalog tool + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -104,29 +104,29 @@ That's it. Now ask your agent:
104
104
 
105
105
  ## Too many tools? Subset them.
106
106
 
107
- 96 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
107
+ 97 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
108
+
109
+ > The `env` blocks below show only the variable under discussion. Your credentials come from the environment, as set in [Quick start](#quick-start) — keep them in your shell profile rather than in the client's JSON config, which is world-readable on most systems and easy to commit by accident.
108
110
 
109
111
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
110
112
 
111
113
  ```json
112
114
  {
113
115
  "env": {
114
- "TAILSCALE_API_KEY": "tskey-api-...",
115
116
  "TAILSCALE_PROFILE": "core"
116
117
  }
117
118
  }
118
119
  ```
119
120
 
120
121
  - **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
121
- - **`core`** (51 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
- - **`full`** (96 tools, default) — everything. Same as omitting the env var.
122
+ - **`core`** (52 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
123
+ - **`full`** (97 tools, default) — everything. Same as omitting the env var.
123
124
 
124
125
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
125
126
 
126
127
  ```json
127
128
  {
128
129
  "env": {
129
- "TAILSCALE_API_KEY": "tskey-api-...",
130
130
  "TAILSCALE_TOOLS": "devices,acl,dns,audit"
131
131
  }
132
132
  }
@@ -141,7 +141,6 @@ Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`
141
141
  ```json
142
142
  {
143
143
  "env": {
144
- "TAILSCALE_API_KEY": "tskey-api-...",
145
144
  "TAILSCALE_PROFILE": "core",
146
145
  "TAILSCALE_READONLY": "1"
147
146
  }
@@ -161,13 +160,151 @@ The server logs the active filter to stderr on startup:
161
160
  When both `TAILSCALE_PROFILE` and `TAILSCALE_TOOLS` are set, `TAILSCALE_TOOLS` wins. The banner marks the profile as overridden so the precedence is obvious at a glance — no need to guess which filter actually applied:
162
161
 
163
162
  ```
164
- @yawlabs/tailscale-mcp v0.12.0 ready (21 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)
163
+ @yawlabs/tailscale-mcp v0.12.0 ready (22 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)
165
164
  ```
166
165
 
167
166
  The "(overridden)" marker only fires for substantive profiles (`minimal` / `core`); `profile=full` is a no-op preset, so it's shown without the marker when `TAILSCALE_TOOLS` is also set.
168
167
 
169
168
  If you don't set any filter, startup prints a tip pointing you at the profiles.
170
169
 
170
+ ### And how the *agent* knows
171
+
172
+ Everything above is stderr -- your MCP client's log. The model never sees it, so a
173
+ withheld tool and a tool that was never built look identical from the agent's side.
174
+ That is how an agent ends up working around a restriction instead of reporting it.
175
+
176
+ `tailscale_tool_groups` closes that gap. It is **always registered**, whatever the
177
+ filters say, and answers the question in-band:
178
+
179
+ ```
180
+ > "Why can't you delete that device?"
181
+
182
+ tailscale_tool_groups({ toolName: "tailscale_delete_device" })
183
+
184
+ {
185
+ "tool": "tailscale_delete_device",
186
+ "available": false,
187
+ "group": "devices",
188
+ "kind": "write",
189
+ "reason": "TAILSCALE_WRITE_GROUPS is set to \"dns\", which does not grant writes here",
190
+ "toEnable": "add \"devices\" to TAILSCALE_WRITE_GROUPS (e.g. \"dns,devices\")"
191
+ }
192
+ ```
193
+
194
+ It separates the three cases an agent otherwise cannot tell apart:
195
+
196
+ | Case | What the agent should do |
197
+ |---|---|
198
+ | No such tool exists, under any configuration | Find another approach -- no setting will produce it |
199
+ | Exists, but its group is not loaded | Report `toEnable` to you; do not work around it |
200
+ | Exists and loaded, but writes are withheld there | Same -- the fix is yours, not a workaround |
201
+
202
+ Called with no arguments it lists every group with its availability and, for anything
203
+ withheld, the exact environment change that would restore it. It reads no network and
204
+ needs no credentials, so it works even when the server is misconfigured.
205
+
206
+ ## Scoping writes to areas
207
+
208
+ `TAILSCALE_WRITE_GROUPS` names the areas an agent may **write** to. Everything else stays readable:
209
+
210
+ ```json
211
+ {
212
+ "env": {
213
+ "TAILSCALE_WRITE_GROUPS": "devices,keys"
214
+ }
215
+ }
216
+ ```
217
+
218
+ That serves all 47 read tools plus the 18 writes in `devices` and `keys`, and withholds the other 38 writes — the ACL, DNS, users, webhooks, posture, services, invites, org-tailnets and log-streaming writes are simply not registered. Unset means no write gate, which is the shipped default.
219
+
220
+ > **Read this first: this filters the tool list, not your API token.** The server still holds one credential with full tailnet authority in every configuration. An agent that also has a shell can `curl api.tailscale.com` with that same token and do everything this knob withheld. Scope the Tailscale OAuth client itself to the areas you actually need — that bound survives outside this process; this one does not. `TAILSCALE_WRITE_GROUPS` is the low-friction complement to credential scoping, not a replacement for it.
221
+
222
+ ### What a grant actually contains
223
+
224
+ Group names are the same ones `TAILSCALE_TOOLS` uses. Writes per group:
225
+
226
+ | Group | Writes | Group | Writes |
227
+ |---|---|---|---|
228
+ | `devices` | 13 | `webhooks` | 5 |
229
+ | `invites` | 7 | `posture` | 3 |
230
+ | `dns` | 6 | `services` | 3 |
231
+ | `keys` | 5 | `tailnet` | 3 |
232
+ | `users` | 5 | `log-streaming` | 3 |
233
+ | `org-tailnets` | 2 | `acl` | 1 |
234
+
235
+ `status`, `audit` and the opt-in `local-cli` group contain no writes at all, so granting them does nothing.
236
+
237
+ **`devices` is the widest grant, and the one most likely to be set.** In a compose file `write=devices` reads like "device admin", but it hands over `delete_device`, `set_devices_authorized`, `expire_device`, `deauthorize_device`, `set_device_routes`, `set_device_tags` and `update_device_key` alongside `rename_device`. There is no finer setting: an honest "safe subset" of `devices` is `rename_device` alone, and a knob whose useful value is one tool is not a knob.
238
+
239
+ ### Three grants are tailnet-admin-equivalent
240
+
241
+ `keys`, `users` and `acl` are not blocked — CI key rotation legitimately needs `keys` — but grant them knowing:
242
+
243
+ - **`keys`** — `tailscale_create_key` mints an OAuth client with whatever scopes the caller asks for, including `acl`. That credential outlives the agent's session and is not subject to this or any other setting here.
244
+ - **`users`** — `tailscale_update_user_role` accepts `owner`.
245
+ - **`acl`** — `tailscale_update_acl` rewrites policy for every principal in the tailnet.
246
+
247
+ The server prints this on startup when your grant includes one of them.
248
+
249
+ ### What it does and does not bound
250
+
251
+ It bounds **where** an agent may write. It does not bound **severity within** a granted area: inside a granted group, writes run unattended, including the grant-direction ones. Pair it with `TAILSCALE_REQUIRE_APPROVAL=1` when a human is at the keyboard — but understand that for an unattended agent that pairing contributes nothing, because a never-prompt client *denies* those calls rather than prompting.
252
+
253
+ ### Precedence, and what happens when you get it wrong
254
+
255
+ | Situation | Result |
256
+ |---|---|
257
+ | Unset, empty, whitespace, or commas-only | No write gate. `-e VAR` with no value must not silently revoke every write. |
258
+ | `TAILSCALE_READONLY=1` also set | Readonly wins; banner says `readonly (TAILSCALE_WRITE_GROUPS ignored)`. |
259
+ | A name is misspelled (`devises`) | **Grants nothing** and names the typo. Unlike `TAILSCALE_TOOLS`, there is no fallback — a typo'd write grant that fell back would hand over all 56 writes at the moment you were restricting them. |
260
+ | Partly misspelled (`devices,dnss`) | Grants the valid half, warns about the rest. |
261
+ | A granted group is not loaded by `TAILSCALE_TOOLS` / `TAILSCALE_PROFILE` | The grant has no effect; a separate warning says so, since the name is not a typo. |
262
+ | `none`, `all`, `off`, `*` | Not reserved words — they are unknown group names, so they grant nothing. Use `TAILSCALE_READONLY=1` for no writes, or leave this unset for all writes. The server points you at the right spelling. |
263
+
264
+ Names are case-sensitive, matching `TAILSCALE_TOOLS`.
265
+
266
+ **The upgrade contract:** upgrading this package can never widen the set of *areas* an agent may write to. A new group is in nobody's grant until a human types its name. It can, however, add tools inside an area you already granted — a pinned test makes that a reviewed line in the diff rather than a silent change.
267
+
268
+ The startup banner shows what applied:
269
+
270
+ ```
271
+ @yawlabs/tailscale-mcp v0.19.0 ready (59 tools, write=devices,keys)
272
+ ```
273
+
274
+ ## Requiring approval on irreversible tools
275
+
276
+ `readOnlyHint` / `destructiveHint` are *advisory* — the MCP spec says clients MUST treat annotations as untrusted, and most don't gate on them. `TAILSCALE_REQUIRE_APPROVAL=1` adds a stronger signal that supported clients enforce:
277
+
278
+ ```json
279
+ {
280
+ "env": {
281
+ "TAILSCALE_REQUIRE_APPROVAL": "1"
282
+ }
283
+ }
284
+ ```
285
+
286
+ Nine tools are then advertised with `_meta["anthropic/requiresUserInteraction"]`, which forces a confirmation prompt even when an allow-rule would otherwise auto-approve the call:
287
+
288
+ | Tool | Why it's on the list |
289
+ |---|---|
290
+ | `tailscale_update_acl` | Can lock every device out of the tailnet; the previous HuJSON (comments included) is gone unless you captured it |
291
+ | `tailscale_delete_device` | The device must re-enroll |
292
+ | `tailscale_delete_user` | No undelete |
293
+ | `tailscale_delete_tailnet` | Destroys an entire tailnet |
294
+ | `tailscale_delete_key` | The secret is never returned again |
295
+ | `tailscale_delete_oauth_app` | Same |
296
+ | `tailscale_delete_webhook` | Same |
297
+ | `tailscale_delete_log_stream_config` | Same |
298
+ | `tailscale_delete_posture_integration` | Same |
299
+
300
+ The line drawn is **"this server cannot undo it with information you still hold"**, which is narrower than the 23 tools annotated `destructiveHint: true`. `tailscale_suspend_user` is deliberately *excluded* — `tailscale_restore_user` reverses it. So are `tailscale_deauthorize_device` (reversed by `tailscale_authorize_device`) and the replace-all setters, which all have a `get_*` counterpart you can read before writing.
301
+
302
+ **Opt-in on purpose, and read this before turning it on.** In a client mode that never prompts (an unattended agent, a CI run), the flag causes those calls to be **denied** rather than run. That is the right default when a human is at the keyboard and the wrong one when nobody is, so it stays off unless you set it.
303
+
304
+ Requires a client that honors the annotation; Claude Code added support in v2.1.199. Clients that don't recognize it ignore it, so setting the variable is never worse than leaving it off.
305
+
306
+ Separately and always on, five tools whose response size scales with the tailnet rather than with the request — `tailscale_list_devices`, `tailscale_list_users`, `tailscale_get_acl`, `tailscale_get_audit_log`, `tailscale_get_network_flow_logs` — declare `_meta["anthropic/maxResultSizeChars"]`, so a large-but-legitimate result stays inline instead of being truncated into a file reference the agent has to read back mid-task.
307
+
171
308
  ## Using with mcp.hosting / mcph
172
309
 
173
310
  If you run this server through [mcp.hosting](https://mcp.hosting) (via the `@yawlabs/mcph` local agent), the two filtering layers compose cleanly:
@@ -227,7 +364,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add si
227
364
 
228
365
  Requirements: the `tailscale` binary must be in `PATH`. If it's installed somewhere unusual, set `TAILSCALE_BINARY` to its absolute path. The MCP server doesn't need root to run these — they're all diagnostic, not state-mutating. Operations that would need elevation (`tailscale up`, `set --advertise-routes`, `lock sign`) are deliberately not exposed.
229
366
 
230
- When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (102 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 96.
367
+ When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (103 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 97.
231
368
 
232
369
  ## Resources (4)
233
370
 
@@ -240,7 +377,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
240
377
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
241
378
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
242
379
 
243
- ## Tools (96 + 6 opt-in)
380
+ ## Tools (97 + 6 opt-in)
244
381
 
245
382
  <details>
246
383
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -277,7 +414,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
277
414
  </details>
278
415
 
279
416
  <details>
280
- <summary><strong>ACL / Policy</strong> (4 tools) — with HuJSON formatting preservation and ETag safety</summary>
417
+ <summary><strong>ACL / Policy</strong> (5 tools) — with HuJSON formatting preservation and ETag safety</summary>
281
418
 
282
419
  | Tool | Description |
283
420
  |------|-------------|
@@ -285,6 +422,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
285
422
  | `tailscale_update_acl` | Update ACL policy (requires ETag for safe concurrent edits) |
286
423
  | `tailscale_validate_acl` | Validate a policy without applying it |
287
424
  | `tailscale_preview_acl` | Preview rules that would apply to a user or IP |
425
+ | `tailscale_diff_acl_access` | Compare a proposed policy against the live one — who gains and loses access |
288
426
 
289
427
  </details>
290
428
 
@@ -538,7 +676,7 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
538
676
 
539
677
  ## Running on oam.js (optional)
540
678
 
541
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 96 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
679
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 97 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
542
680
 
543
681
  **oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument. This server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `TAILSCALE_MCP_RUNTIME=oam` turns that into a hard error.
544
682
 
@@ -553,8 +691,7 @@ It is opt-in rather than default because a wrong grant does not fail loudly. oam
553
691
  "mcpServers": {
554
692
  "tailscale": {
555
693
  "command": "oam",
556
- "args": ["run", "/path/to/tailscale-mcp/dist/index.js"],
557
- "env": { "TAILSCALE_API_KEY": "tskey-api-..." }
694
+ "args": ["run", "/path/to/tailscale-mcp/dist/index.js"]
558
695
  }
559
696
  }
560
697
  }
@@ -192,9 +192,11 @@ function sandboxFlags() {
192
192
  "TAILSCALE_PROFILE",
193
193
  "TAILSCALE_READONLY",
194
194
  "TAILSCALE_REQUEST_BUDGET_MS",
195
+ "TAILSCALE_REQUIRE_APPROVAL",
195
196
  "TAILSCALE_RETRY_BASE_DELAY_MS",
196
197
  "TAILSCALE_TAILNET",
197
198
  "TAILSCALE_TOOLS",
199
+ "TAILSCALE_WRITE_GROUPS",
198
200
  ];
199
201
 
200
202
  const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
package/dist/index.js CHANGED
@@ -31416,6 +31416,11 @@ var PROFILES = {
31416
31416
  full: []
31417
31417
  // empty = all groups
31418
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
+ }
31419
31424
  function parseReadonlyFlag(value) {
31420
31425
  return value === "1" || value === "true";
31421
31426
  }
@@ -31434,8 +31439,7 @@ function filterTools(groups, options) {
31434
31439
  unknownProfile = profileKey;
31435
31440
  }
31436
31441
  }
31437
- const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
31438
- const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31442
+ const explicitTools = parseGroupList(options.tools);
31439
31443
  const explicitToolsAllUnknown = explicitTools?.every((g) => !validNames.has(g)) ?? false;
31440
31444
  const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
31441
31445
  const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
@@ -31443,11 +31447,15 @@ function filterTools(groups, options) {
31443
31447
  const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : [];
31444
31448
  const unknownProfileGroups = profileGroups && !effectiveExplicitTools ? profileGroups.filter((g) => !validNames.has(g)) : [];
31445
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;
31446
31453
  const out = [];
31447
31454
  for (const [name, tools] of Object.entries(groups)) {
31448
31455
  if (enabledGroups && !enabledGroups.has(name)) continue;
31456
+ const writesAllowed = !readonly2 && (writeScope === null || writeScope.has(name));
31449
31457
  for (const t of tools) {
31450
- if (readonly2 && t.annotations.readOnlyHint !== true) continue;
31458
+ if (t.annotations.readOnlyHint !== true && !writesAllowed) continue;
31451
31459
  out.push(t);
31452
31460
  }
31453
31461
  }
@@ -31458,10 +31466,70 @@ function filterTools(groups, options) {
31458
31466
  if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
31459
31467
  if (profileWouldFilter) result.profileWouldFilter = true;
31460
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;
31461
31478
  return result;
31462
31479
  }
31463
31480
 
31464
31481
  // src/tools/acl.ts
31482
+ var DEFAULT_PRINCIPAL_CAP = 25;
31483
+ var DIFF_TIME_BUDGET_MS = 6e4;
31484
+ function accessSet(matches, postureDefs, definitionsAvailable) {
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 posturePart = postures.length > 0 ? ` posture ${postures.map((name) => {
31490
+ if (!definitionsAvailable) return name;
31491
+ const rules = [...postureDefs?.[name] ?? []].sort();
31492
+ return rules.length > 0 ? `${name}(${rules.join(";")})` : name;
31493
+ }).join(",")}` : "";
31494
+ const qualifier = `${via.length > 0 ? ` via ${via.join(",")}` : ""}${posturePart}`;
31495
+ for (const port of match.ports ?? []) out.add(`${port}${qualifier}`);
31496
+ }
31497
+ return out;
31498
+ }
31499
+ async function previewAccess(policy, principal) {
31500
+ const params = new URLSearchParams({ type: "user", previewFor: principal });
31501
+ const res = await apiPost(`/tailnet/${getTailnet()}/acl/preview?${params}`, void 0, {
31502
+ rawBody: policy,
31503
+ contentType: "application/hujson",
31504
+ acceptRaw: true,
31505
+ accept: "application/hujson"
31506
+ });
31507
+ if (!res.ok) return { ok: false, error: res.error || `HTTP ${res.status}`, status: res.status };
31508
+ let parsed;
31509
+ try {
31510
+ parsed = JSON.parse(res.rawBody ?? "");
31511
+ } catch {
31512
+ return {
31513
+ ok: false,
31514
+ error: "the preview response was not valid JSON, so its rules could not be compared",
31515
+ status: res.status
31516
+ };
31517
+ }
31518
+ if (!Array.isArray(parsed.matches)) {
31519
+ return {
31520
+ ok: false,
31521
+ error: "the preview response contained no `matches` array, so its rules could not be compared",
31522
+ status: res.status
31523
+ };
31524
+ }
31525
+ return {
31526
+ ok: true,
31527
+ access: accessSet(parsed.matches, parsed.postures, false),
31528
+ hasPostureDefs: !!parsed.postures,
31529
+ matches: parsed.matches,
31530
+ postureDefs: parsed.postures
31531
+ };
31532
+ }
31465
31533
  var ETAG_FOOTER_MARKER = "// ETag: ";
31466
31534
  function stripEtagFooter(body) {
31467
31535
  const lines = body.split("\n");
@@ -31594,6 +31662,160 @@ var aclTools = [
31594
31662
  accept: "application/hujson"
31595
31663
  });
31596
31664
  }
31665
+ },
31666
+ {
31667
+ name: "tailscale_diff_acl_access",
31668
+ description: "Answer 'who loses access?' before applying an ACL change. Compares the CURRENT policy against a proposed one and reports, per user, which destinations they gain and lose. Run this before tailscale_update_acl -- validate_acl only checks syntax and the policy's own tests block, so a policy with no tests validates clean while revoking everyone. LIMITS, all reported in the response rather than left to be discovered. It compares USER principals only, so a revocation that runs through a tag or group can show a clean diff, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening `posture:corp` shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops after 60 seconds regardless; either way it sets `truncated`, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in `failed` and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.",
31669
+ annotations: {
31670
+ title: "Diff ACL access",
31671
+ readOnlyHint: true,
31672
+ destructiveHint: false,
31673
+ idempotentHint: true,
31674
+ openWorldHint: true
31675
+ },
31676
+ inputSchema: external_exports.object({
31677
+ policy: external_exports.string().describe("The proposed ACL policy text to compare against the current live policy"),
31678
+ principals: external_exports.array(external_exports.string().trim().min(1)).optional().describe(
31679
+ "Principals to check, as they appear in `loginName` from tailscale_list_users. That is often an email, but on a GitHub or SSO tailnet it is not (e.g. 'alice@github') -- pass the loginName verbatim rather than an address you assume. 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."
31680
+ ),
31681
+ maxPrincipals: external_exports.number().int().positive().optional().describe(
31682
+ `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.`
31683
+ )
31684
+ }),
31685
+ handler: async (input) => {
31686
+ const startedAt = Date.now();
31687
+ const current = await apiGet(`/tailnet/${getTailnet()}/acl`, {
31688
+ acceptRaw: true,
31689
+ accept: "application/hujson"
31690
+ });
31691
+ if (!current.ok) {
31692
+ return {
31693
+ ok: false,
31694
+ error: `could not read the current ACL to diff against: ${current.error || `HTTP ${current.status}`}`
31695
+ };
31696
+ }
31697
+ const baselinePolicy = current.rawBody ?? "";
31698
+ let principals;
31699
+ let availableTotal;
31700
+ if (input.principals !== void 0) {
31701
+ principals = [...new Set(input.principals)];
31702
+ if (principals.length === 0) {
31703
+ return {
31704
+ ok: false,
31705
+ 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."
31706
+ };
31707
+ }
31708
+ availableTotal = principals.length;
31709
+ } else {
31710
+ const usersRes = await apiGet(`/tailnet/${getTailnet()}/users`);
31711
+ if (!usersRes.ok) {
31712
+ return { ok: false, error: `could not list users to diff: ${usersRes.error || `HTTP ${usersRes.status}`}` };
31713
+ }
31714
+ const emails = (usersRes.data?.users ?? []).map((u) => u.loginName ?? u.email ?? u.name).filter((v) => typeof v === "string" && v.trim().length > 0);
31715
+ principals = [...new Set(emails)];
31716
+ availableTotal = principals.length;
31717
+ if (principals.length === 0) {
31718
+ return {
31719
+ ok: false,
31720
+ 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."
31721
+ };
31722
+ }
31723
+ }
31724
+ const cap = input.maxPrincipals ?? DEFAULT_PRINCIPAL_CAP;
31725
+ const checked = principals.slice(0, cap);
31726
+ const changed = [];
31727
+ const unchanged = [];
31728
+ const failed = [];
31729
+ let attempted = 0;
31730
+ let stoppedOnTime = false;
31731
+ for (const principal of checked) {
31732
+ if (Date.now() - startedAt > DIFF_TIME_BUDGET_MS) {
31733
+ stoppedOnTime = true;
31734
+ break;
31735
+ }
31736
+ attempted++;
31737
+ const [before, after] = await Promise.all([
31738
+ previewAccess(baselinePolicy, principal),
31739
+ previewAccess(input.policy, principal)
31740
+ ]);
31741
+ if (!before.ok || !after.ok) {
31742
+ const failure = !before.ok ? before : !after.ok ? after : void 0;
31743
+ const which = !before.ok ? "current" : "proposed";
31744
+ failed.push({
31745
+ principal,
31746
+ error: `preview against the ${which} policy failed: ${failure?.error ?? "unknown"}`
31747
+ });
31748
+ if (failure?.status === 401 || failure?.status === 403) {
31749
+ return {
31750
+ ok: false,
31751
+ 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"}`
31752
+ };
31753
+ }
31754
+ continue;
31755
+ }
31756
+ const definitionsAvailable = before.hasPostureDefs && after.hasPostureDefs;
31757
+ const beforeAccess = accessSet(before.matches, before.postureDefs, definitionsAvailable);
31758
+ const afterAccess = accessSet(after.matches, after.postureDefs, definitionsAvailable);
31759
+ const lost = [...beforeAccess].filter((a) => !afterAccess.has(a)).sort();
31760
+ const gained = [...afterAccess].filter((a) => !beforeAccess.has(a)).sort();
31761
+ if (lost.length === 0 && gained.length === 0) unchanged.push(principal);
31762
+ else changed.push({ principal, lost, gained });
31763
+ }
31764
+ const compared = attempted - failed.length;
31765
+ if (compared === 0) {
31766
+ return {
31767
+ ok: false,
31768
+ 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"}`
31769
+ };
31770
+ }
31771
+ const losing = changed.filter((c) => c.lost.length > 0).length;
31772
+ const gaining = changed.filter((c) => c.gained.length > 0).length;
31773
+ const notChecked = principals.length - attempted;
31774
+ const truncated = notChecked > 0;
31775
+ const summary = [
31776
+ // Failures lead when present, so the headline cannot read as an
31777
+ // all-clear over a partially-compared run.
31778
+ failed.length > 0 ? `${failed.length} of ${attempted} users could not be checked` : null,
31779
+ `${losing} of ${compared} users compared lose access`,
31780
+ `${gaining} gain access`,
31781
+ `${unchanged.length} unchanged`,
31782
+ // Names WHICH limit stopped the run: "cap 25" tells the caller to raise
31783
+ // maxPrincipals, and the time budget tells them the opposite -- that
31784
+ // raising it would make things worse, and the run needs narrowing.
31785
+ truncated ? `${notChecked} not checked (${stoppedOnTime ? `${DIFF_TIME_BUDGET_MS / 1e3}s time budget` : `cap ${cap}`})` : null
31786
+ ].filter(Boolean).join(", ");
31787
+ return {
31788
+ ok: true,
31789
+ data: {
31790
+ tailnet: getTailnet(),
31791
+ summary,
31792
+ // Three numbers, because two of them were being conflated. `Compared`
31793
+ // is the only one that describes work actually done.
31794
+ principalsCompared: compared,
31795
+ principalsFailed: failed.length,
31796
+ principalsAvailable: availableTotal,
31797
+ truncated,
31798
+ // Distinguishes the two truncation causes for a machine reader, which
31799
+ // the summary string does only in prose. They call for opposite
31800
+ // responses: a cap stop means raise maxPrincipals, a time stop means
31801
+ // narrow the run.
31802
+ stoppedOnTimeBudget: stoppedOnTime,
31803
+ // Restated in the payload, not just the tool description: whoever
31804
+ // reads this output is deciding whether to apply the change, and may
31805
+ // never have read the description. Each clause names a way this diff
31806
+ // can come back empty while real access changed.
31807
+ scope: [
31808
+ "User principals only.",
31809
+ "Not compared: access granted via tags or groups.",
31810
+ "Posture definition changes ARE compared -- names are resolved to their rules -- unless a preview omitted the definitions map, in which case names alone are compared and a redefinition would not show.",
31811
+ "An empty diff is not proof the change is safe."
31812
+ ].join(" "),
31813
+ changed,
31814
+ unchanged,
31815
+ failed
31816
+ }
31817
+ };
31818
+ }
31597
31819
  }
31598
31820
  ];
31599
31821
 
@@ -34024,6 +34246,42 @@ var webhookTools = [
34024
34246
  function isLocalCliEnabled(env) {
34025
34247
  return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
34026
34248
  }
34249
+ function isRequireApprovalEnabled(env) {
34250
+ return env.TAILSCALE_REQUIRE_APPROVAL === "1" || env.TAILSCALE_REQUIRE_APPROVAL === "true";
34251
+ }
34252
+ var FORCED_APPROVAL_TOOLS = [
34253
+ "tailscale_delete_device",
34254
+ "tailscale_delete_key",
34255
+ "tailscale_delete_log_stream_config",
34256
+ "tailscale_delete_oauth_app",
34257
+ "tailscale_delete_posture_integration",
34258
+ "tailscale_delete_tailnet",
34259
+ "tailscale_delete_user",
34260
+ "tailscale_delete_webhook",
34261
+ "tailscale_update_acl"
34262
+ ];
34263
+ var MAX_RESULT_SIZE_CHARS = 5e5;
34264
+ var LARGE_RESULT_TOOLS = [
34265
+ // Scales with BOTH the number of principals checked and each one's grant
34266
+ // count, so for a given tailnet its payload is strictly larger than
34267
+ // list_users below.
34268
+ "tailscale_diff_acl_access",
34269
+ "tailscale_get_acl",
34270
+ "tailscale_get_audit_log",
34271
+ "tailscale_get_network_flow_logs",
34272
+ "tailscale_list_devices",
34273
+ "tailscale_list_users"
34274
+ ];
34275
+ function buildToolMeta(toolName, options) {
34276
+ const meta3 = {};
34277
+ if (options.requireApproval && FORCED_APPROVAL_TOOLS.includes(toolName)) {
34278
+ meta3["anthropic/requiresUserInteraction"] = true;
34279
+ }
34280
+ if (LARGE_RESULT_TOOLS.includes(toolName)) {
34281
+ meta3["anthropic/maxResultSizeChars"] = MAX_RESULT_SIZE_CHARS;
34282
+ }
34283
+ return Object.keys(meta3).length > 0 ? meta3 : void 0;
34284
+ }
34027
34285
  function buildToolGroups(env) {
34028
34286
  const toolGroups = {
34029
34287
  status: statusTools,
@@ -34065,7 +34323,11 @@ function formatBannerFilterSuffix(inputs) {
34065
34323
  return [
34066
34324
  profileLabel,
34067
34325
  groupsLabel,
34068
- inputs.readonlyMode ? "readonly" : null,
34326
+ inputs.readonlyMode ? inputs.writeGroupsOverriddenByReadonly ? "readonly (TAILSCALE_WRITE_GROUPS ignored)" : "readonly" : null,
34327
+ // `write=none` (configured, granted nothing) is a different state from no segment
34328
+ // at all (knob unset, every write served), and an operator debugging "why can it
34329
+ // not write" needs to tell them apart at a glance.
34330
+ inputs.writeGroups ? inputs.writeGroups.length > 0 ? `write=${inputs.writeGroups.join(",")}` : "write=none" : null,
34069
34331
  inputs.localCliEnabled ? "local-cli=on" : null
34070
34332
  ].filter(Boolean).join(", ");
34071
34333
  }
@@ -34143,8 +34405,153 @@ async function tailnetDnsResource(uri) {
34143
34405
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
34144
34406
  }
34145
34407
 
34408
+ // src/tools/meta.ts
34409
+ function explainNotLoaded(group, state) {
34410
+ if (group === "local-cli" && !state.localCliEnabled) {
34411
+ return {
34412
+ reason: "the local-CLI group is opt-in and is not enabled in this process",
34413
+ toEnable: "set TAILSCALE_LOCAL_CLI=1"
34414
+ };
34415
+ }
34416
+ if (state.toolsEnv) {
34417
+ return {
34418
+ reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
34419
+ toEnable: `add "${group}" to TAILSCALE_TOOLS`
34420
+ };
34421
+ }
34422
+ if (state.profileEnv) {
34423
+ return {
34424
+ reason: `TAILSCALE_PROFILE="${state.profileEnv}" does not include this group`,
34425
+ toEnable: `set TAILSCALE_PROFILE=full, or list the groups you want in TAILSCALE_TOOLS`
34426
+ };
34427
+ }
34428
+ return {
34429
+ reason: "this group did not register, and no load filter is configured to explain why",
34430
+ toEnable: "no env change is known to enable it -- please report this at https://github.com/YawLabs/tailscale-mcp/issues"
34431
+ };
34432
+ }
34433
+ function explainWritesWithheld(group, state) {
34434
+ if (state.readonlyMode) {
34435
+ return {
34436
+ reason: "TAILSCALE_READONLY is enabled, so no group serves writes",
34437
+ toEnable: "unset TAILSCALE_READONLY"
34438
+ };
34439
+ }
34440
+ const current = state.writeGroupsEnv?.trim();
34441
+ return {
34442
+ reason: current ? `TAILSCALE_WRITE_GROUPS is set to "${current}", which does not grant writes here` : "writes are withheld in this group",
34443
+ toEnable: current ? `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${current},${group}")` : `add "${group}" to TAILSCALE_WRITE_GROUPS`
34444
+ };
34445
+ }
34446
+ function buildGroupReports(state) {
34447
+ const reports = [];
34448
+ for (const [group, tools] of Object.entries(state.fullRegistry)) {
34449
+ const available = tools.filter((t) => state.registeredNames.has(t.name));
34450
+ const writes = tools.filter((t) => t.annotations.readOnlyHint !== true);
34451
+ const writesAvailable = writes.filter((t) => state.registeredNames.has(t.name));
34452
+ const base = {
34453
+ group,
34454
+ tools: tools.length,
34455
+ available: available.length,
34456
+ writes: writes.length,
34457
+ writesAvailable: writesAvailable.length
34458
+ };
34459
+ if (available.length === 0) {
34460
+ reports.push({ ...base, status: "unavailable", ...explainNotLoaded(group, state) });
34461
+ continue;
34462
+ }
34463
+ if (writes.length > 0 && writesAvailable.length === 0) {
34464
+ reports.push({ ...base, status: "read-only", ...explainWritesWithheld(group, state) });
34465
+ continue;
34466
+ }
34467
+ reports.push({ ...base, status: "full" });
34468
+ }
34469
+ return reports;
34470
+ }
34471
+ function explainTool(toolName, state) {
34472
+ const query = toolName.trim();
34473
+ for (const [group, tools] of Object.entries(state.fullRegistry)) {
34474
+ const tool = tools.find((t) => t.name === query);
34475
+ if (!tool) continue;
34476
+ const isWrite = tool.annotations.readOnlyHint !== true;
34477
+ if (state.registeredNames.has(query)) {
34478
+ return {
34479
+ tool: query,
34480
+ available: true,
34481
+ group,
34482
+ kind: isWrite ? "write" : "read",
34483
+ reason: "this tool is registered and callable right now"
34484
+ };
34485
+ }
34486
+ const groupHasAny = tools.some((t) => state.registeredNames.has(t.name));
34487
+ const explain = groupHasAny ? explainWritesWithheld(group, state) : explainNotLoaded(group, state);
34488
+ return {
34489
+ tool: query,
34490
+ available: false,
34491
+ group,
34492
+ kind: isWrite ? "write" : "read",
34493
+ ...explain
34494
+ };
34495
+ }
34496
+ return {
34497
+ tool: query,
34498
+ available: false,
34499
+ // The case an agent most needs separated from the others: no configuration
34500
+ // change will produce this tool, so retrying or asking the operator is wasted.
34501
+ // Only here is "find another way" the right conclusion.
34502
+ 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."
34503
+ };
34504
+ }
34505
+ function buildMetaTools(state) {
34506
+ return [
34507
+ {
34508
+ name: "tailscale_tool_groups",
34509
+ 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.",
34510
+ annotations: {
34511
+ title: "Explain available tools",
34512
+ readOnlyHint: true,
34513
+ destructiveHint: false,
34514
+ idempotentHint: true,
34515
+ // The only tool here that touches no network: it reports this process's own
34516
+ // configuration, so it cannot fail on credentials, scope or connectivity.
34517
+ openWorldHint: false
34518
+ },
34519
+ inputSchema: external_exports.object({
34520
+ 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.")
34521
+ }),
34522
+ handler: async (input) => {
34523
+ if (input.toolName) {
34524
+ return { ok: true, data: explainTool(input.toolName, state) };
34525
+ }
34526
+ const groups = buildGroupReports(state);
34527
+ const withheld = groups.filter((g) => g.status !== "full");
34528
+ const totalTools = groups.reduce((n, g) => n + g.tools, 0);
34529
+ const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
34530
+ const activeFilters = [
34531
+ state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
34532
+ state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
34533
+ state.readonlyMode ? "TAILSCALE_READONLY=1" : null,
34534
+ state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
34535
+ state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
34536
+ ].filter(Boolean);
34537
+ return {
34538
+ ok: true,
34539
+ data: {
34540
+ 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.`,
34541
+ activeFilters: activeFilters.length > 0 ? activeFilters : ["none -- no filters are configured"],
34542
+ groups,
34543
+ // Addressed to the model, because it is the model that has to decide
34544
+ // what to do next when a tool is absent.
34545
+ 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."
34546
+ }
34547
+ };
34548
+ }
34549
+ }
34550
+ ];
34551
+ }
34552
+
34146
34553
  // src/index.ts
34147
- var version2 = true ? "0.18.0" : resolveVersionFallback();
34554
+ var version2 = true ? "0.19.1" : resolveVersionFallback();
34148
34555
  var subcommand = process.argv[2];
34149
34556
  var cliSubcommandHandled = false;
34150
34557
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -34177,11 +34584,16 @@ if (!cliSubcommandHandled) {
34177
34584
  unknownProfile,
34178
34585
  explicitTools,
34179
34586
  profileWouldFilter,
34180
- toolsAllUnknown
34587
+ toolsAllUnknown,
34588
+ writeGroups,
34589
+ unknownWriteGroups,
34590
+ writeGroupsNotLoaded,
34591
+ writeGroupsOverriddenByReadonly
34181
34592
  } = filterTools(toolGroups, {
34182
34593
  tools: process.env.TAILSCALE_TOOLS,
34183
34594
  readonly: process.env.TAILSCALE_READONLY,
34184
- profile: process.env.TAILSCALE_PROFILE
34595
+ profile: process.env.TAILSCALE_PROFILE,
34596
+ writeGroups: process.env.TAILSCALE_WRITE_GROUPS
34185
34597
  });
34186
34598
  if (unknownGroups.length > 0) {
34187
34599
  const validNames = Object.keys(toolGroups);
@@ -34190,6 +34602,30 @@ if (!cliSubcommandHandled) {
34190
34602
  `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
34191
34603
  );
34192
34604
  }
34605
+ if (unknownWriteGroups && unknownWriteGroups.length > 0) {
34606
+ const validNames = Object.keys(toolGroups);
34607
+ const everyPossibleGroup = Object.keys(buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }));
34608
+ const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
34609
+ const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
34610
+ if (notEnabled.length > 0) {
34611
+ console.error(
34612
+ `@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.`
34613
+ );
34614
+ }
34615
+ if (realTypos.length > 0) {
34616
+ const sentinels = /* @__PURE__ */ new Set(["none", "off", "false", "0", "all", "*"]);
34617
+ const guessed = realTypos.filter((g) => sentinels.has(g.toLowerCase()));
34618
+ 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." : "";
34619
+ console.error(
34620
+ `@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}`
34621
+ );
34622
+ }
34623
+ }
34624
+ if (writeGroupsNotLoaded && writeGroupsNotLoaded.length > 0) {
34625
+ console.error(
34626
+ `@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.`
34627
+ );
34628
+ }
34193
34629
  if (unknownProfileGroups && unknownProfileGroups.length > 0) {
34194
34630
  console.error(
34195
34631
  `@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.`
@@ -34208,28 +34644,72 @@ if (!cliSubcommandHandled) {
34208
34644
  name: "@yawlabs/tailscale-mcp",
34209
34645
  version: version2
34210
34646
  });
34647
+ const requireApproval = isRequireApprovalEnabled(process.env);
34648
+ const metaTools = buildMetaTools({
34649
+ // The FULL registry, with opt-ins forced on, so the catalog can report on a group
34650
+ // that is currently disabled -- which is exactly the group an agent needs
34651
+ // explained. Reporting only what loaded would make local-cli invisible rather
34652
+ // than explained.
34653
+ fullRegistry: buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }),
34654
+ // Ground truth for availability: what this server actually serves. Deliberately
34655
+ // not a re-derivation of the filter logic, so the catalog cannot disagree with
34656
+ // the server about what exists.
34657
+ registeredNames: new Set(allTools.map((t) => t.name)),
34658
+ toolsEnv: process.env.TAILSCALE_TOOLS,
34659
+ profileEnv: process.env.TAILSCALE_PROFILE,
34660
+ writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
34661
+ readonlyMode: parseReadonlyFlag(process.env.TAILSCALE_READONLY),
34662
+ localCliEnabled
34663
+ });
34664
+ for (const tool of metaTools) {
34665
+ server.registerTool(
34666
+ tool.name,
34667
+ {
34668
+ title: tool.annotations.title,
34669
+ description: tool.description,
34670
+ inputSchema: tool.inputSchema.shape,
34671
+ annotations: tool.annotations,
34672
+ _meta: buildToolMeta(tool.name, { requireApproval })
34673
+ },
34674
+ wrapToolHandler(tool)
34675
+ );
34676
+ }
34211
34677
  for (const tool of allTools) {
34212
- server.tool(tool.name, tool.description, tool.inputSchema.shape, tool.annotations, wrapToolHandler(tool));
34678
+ server.registerTool(
34679
+ tool.name,
34680
+ {
34681
+ // Hoisted out of annotations.title, which every tool file already sets.
34682
+ // The annotations copy deliberately stays where it is: clients reading
34683
+ // the legacy location keep working, so this is purely additive on the
34684
+ // wire rather than a move.
34685
+ title: tool.annotations.title,
34686
+ description: tool.description,
34687
+ inputSchema: tool.inputSchema.shape,
34688
+ annotations: tool.annotations,
34689
+ _meta: buildToolMeta(tool.name, { requireApproval })
34690
+ },
34691
+ wrapToolHandler(tool)
34692
+ );
34213
34693
  }
34214
- server.resource(
34694
+ server.registerResource(
34215
34695
  "tailnet-status",
34216
34696
  "tailscale://tailnet/status",
34217
34697
  { description: "Current tailnet status including device count and settings", mimeType: "application/json" },
34218
34698
  tailnetStatusResource
34219
34699
  );
34220
- server.resource(
34700
+ server.registerResource(
34221
34701
  "tailnet-devices",
34222
34702
  "tailscale://tailnet/devices",
34223
34703
  { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
34224
34704
  tailnetDevicesResource
34225
34705
  );
34226
- server.resource(
34706
+ server.registerResource(
34227
34707
  "tailnet-acl",
34228
34708
  "tailscale://tailnet/acl",
34229
34709
  { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
34230
34710
  tailnetAclResource
34231
34711
  );
34232
- server.resource(
34712
+ server.registerResource(
34233
34713
  "tailnet-dns",
34234
34714
  "tailscale://tailnet/dns",
34235
34715
  {
@@ -34251,12 +34731,24 @@ if (!cliSubcommandHandled) {
34251
34731
  profileWouldFilter,
34252
34732
  profileEnv: process.env.TAILSCALE_PROFILE,
34253
34733
  readonlyMode,
34254
- localCliEnabled
34734
+ localCliEnabled,
34735
+ writeGroups,
34736
+ writeGroupsOverriddenByReadonly
34255
34737
  });
34256
34738
  console.error(
34257
34739
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
34258
34740
  );
34259
34741
  const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
34742
+ const ADMIN_EQUIVALENT = ["keys", "users", "acl"];
34743
+ const registeredNames = new Set(allTools.map((t) => t.name));
34744
+ const adminWritable = ADMIN_EQUIVALENT.filter(
34745
+ (g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
34746
+ );
34747
+ if (adminWritable.length > 0 && hasCreds) {
34748
+ console.error(
34749
+ `@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.`
34750
+ );
34751
+ }
34260
34752
  if (!filterSuffix && hasCreds) {
34261
34753
  const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
34262
34754
  const coreCount = profileCount(PROFILES.core);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",