@yawlabs/tailscale-mcp 0.18.0 → 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/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,59 @@ 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) {
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
+ }
31465
31522
  var ETAG_FOOTER_MARKER = "// ETag: ";
31466
31523
  function stripEtagFooter(body) {
31467
31524
  const lines = body.split("\n");
@@ -31594,6 +31651,158 @@ var aclTools = [
31594
31651
  accept: "application/hujson"
31595
31652
  });
31596
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
+ }
31597
31806
  }
31598
31807
  ];
31599
31808
 
@@ -34024,6 +34233,42 @@ var webhookTools = [
34024
34233
  function isLocalCliEnabled(env) {
34025
34234
  return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
34026
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
+ }
34027
34272
  function buildToolGroups(env) {
34028
34273
  const toolGroups = {
34029
34274
  status: statusTools,
@@ -34065,7 +34310,11 @@ function formatBannerFilterSuffix(inputs) {
34065
34310
  return [
34066
34311
  profileLabel,
34067
34312
  groupsLabel,
34068
- 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,
34069
34318
  inputs.localCliEnabled ? "local-cli=on" : null
34070
34319
  ].filter(Boolean).join(", ");
34071
34320
  }
@@ -34143,8 +34392,153 @@ async function tailnetDnsResource(uri) {
34143
34392
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
34144
34393
  }
34145
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
+
34146
34540
  // src/index.ts
34147
- var version2 = true ? "0.18.0" : resolveVersionFallback();
34541
+ var version2 = true ? "0.19.0" : resolveVersionFallback();
34148
34542
  var subcommand = process.argv[2];
34149
34543
  var cliSubcommandHandled = false;
34150
34544
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -34177,11 +34571,16 @@ if (!cliSubcommandHandled) {
34177
34571
  unknownProfile,
34178
34572
  explicitTools,
34179
34573
  profileWouldFilter,
34180
- toolsAllUnknown
34574
+ toolsAllUnknown,
34575
+ writeGroups,
34576
+ unknownWriteGroups,
34577
+ writeGroupsNotLoaded,
34578
+ writeGroupsOverriddenByReadonly
34181
34579
  } = filterTools(toolGroups, {
34182
34580
  tools: process.env.TAILSCALE_TOOLS,
34183
34581
  readonly: process.env.TAILSCALE_READONLY,
34184
- profile: process.env.TAILSCALE_PROFILE
34582
+ profile: process.env.TAILSCALE_PROFILE,
34583
+ writeGroups: process.env.TAILSCALE_WRITE_GROUPS
34185
34584
  });
34186
34585
  if (unknownGroups.length > 0) {
34187
34586
  const validNames = Object.keys(toolGroups);
@@ -34190,6 +34589,30 @@ if (!cliSubcommandHandled) {
34190
34589
  `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
34191
34590
  );
34192
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
+ }
34193
34616
  if (unknownProfileGroups && unknownProfileGroups.length > 0) {
34194
34617
  console.error(
34195
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.`
@@ -34208,28 +34631,72 @@ if (!cliSubcommandHandled) {
34208
34631
  name: "@yawlabs/tailscale-mcp",
34209
34632
  version: version2
34210
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
+ }
34211
34664
  for (const tool of allTools) {
34212
- 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
+ );
34213
34680
  }
34214
- server.resource(
34681
+ server.registerResource(
34215
34682
  "tailnet-status",
34216
34683
  "tailscale://tailnet/status",
34217
34684
  { description: "Current tailnet status including device count and settings", mimeType: "application/json" },
34218
34685
  tailnetStatusResource
34219
34686
  );
34220
- server.resource(
34687
+ server.registerResource(
34221
34688
  "tailnet-devices",
34222
34689
  "tailscale://tailnet/devices",
34223
34690
  { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
34224
34691
  tailnetDevicesResource
34225
34692
  );
34226
- server.resource(
34693
+ server.registerResource(
34227
34694
  "tailnet-acl",
34228
34695
  "tailscale://tailnet/acl",
34229
34696
  { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
34230
34697
  tailnetAclResource
34231
34698
  );
34232
- server.resource(
34699
+ server.registerResource(
34233
34700
  "tailnet-dns",
34234
34701
  "tailscale://tailnet/dns",
34235
34702
  {
@@ -34251,12 +34718,24 @@ if (!cliSubcommandHandled) {
34251
34718
  profileWouldFilter,
34252
34719
  profileEnv: process.env.TAILSCALE_PROFILE,
34253
34720
  readonlyMode,
34254
- localCliEnabled
34721
+ localCliEnabled,
34722
+ writeGroups,
34723
+ writeGroupsOverriddenByReadonly
34255
34724
  });
34256
34725
  console.error(
34257
34726
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
34258
34727
  );
34259
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
+ }
34260
34739
  if (!filterSuffix && hasCreds) {
34261
34740
  const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
34262
34741
  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.0",
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",