@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 +151 -14
- package/bin/tailscale-mcp.mjs +2 -0
- package/dist/index.js +492 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/tailscale-mcp/stargazers)
|
|
6
6
|
[](./release.sh)
|
|
7
7
|
|
|
8
|
-
**Ask your agent questions about your tailnet and have it act on the answers.**
|
|
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
|
-
|
|
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`** (
|
|
122
|
-
- **`full`** (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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> (
|
|
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
|
|
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
|
}
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|