@yawlabs/tailscale-mcp 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -16
- package/bin/tailscale-mcp.mjs +54 -7
- package/dist/index.js +620 -44
- package/package.json +4 -3
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
|
|
|
@@ -308,7 +446,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
308
446
|
</details>
|
|
309
447
|
|
|
310
448
|
<details>
|
|
311
|
-
<summary><strong>Keys / Trust Credentials</strong> (
|
|
449
|
+
<summary><strong>Keys / Trust Credentials</strong> (9 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
|
|
312
450
|
|
|
313
451
|
| Tool | Description |
|
|
314
452
|
|------|-------------|
|
|
@@ -319,6 +457,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
319
457
|
| `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
|
|
320
458
|
| `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
|
|
321
459
|
| `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
|
|
460
|
+
| `tailscale_list_oauth_apps` | List every OAuth App registered in the tailnet |
|
|
461
|
+
| `tailscale_delete_oauth_app` | Delete an OAuth App, revoking its ability to provision devices |
|
|
322
462
|
|
|
323
463
|
</details>
|
|
324
464
|
|
|
@@ -536,13 +676,13 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
536
676
|
|
|
537
677
|
## Running on oam.js (optional)
|
|
538
678
|
|
|
539
|
-
[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.
|
|
540
680
|
|
|
541
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.
|
|
542
682
|
|
|
543
683
|
### Sandboxing (opt-in)
|
|
544
684
|
|
|
545
|
-
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` and
|
|
685
|
+
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` -- the only host the bundle contacts, including the OAuth token exchange -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
|
|
546
686
|
|
|
547
687
|
It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `TAILSCALE_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
|
|
548
688
|
|
|
@@ -551,8 +691,7 @@ It is opt-in rather than default because a wrong grant does not fail loudly. oam
|
|
|
551
691
|
"mcpServers": {
|
|
552
692
|
"tailscale": {
|
|
553
693
|
"command": "oam",
|
|
554
|
-
"args": ["run", "/path/to/tailscale-mcp/dist/index.js"]
|
|
555
|
-
"env": { "TAILSCALE_API_KEY": "tskey-api-..." }
|
|
694
|
+
"args": ["run", "/path/to/tailscale-mcp/dist/index.js"]
|
|
556
695
|
}
|
|
557
696
|
}
|
|
558
697
|
}
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
|
|
26
26
|
* `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
27
|
-
* network limited to the
|
|
27
|
+
* network limited to the one host the bundle actually calls
|
|
28
|
+
* (api.tailscale.com), filesystem denied.
|
|
28
29
|
*
|
|
29
30
|
* Child-process is granted unconditionally because the local-CLI tools shell out
|
|
30
31
|
* to the `tailscale` binary; that is also why PATH stays in the env grant, since
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
* TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
50
51
|
* TAILSCALE_MCP_RUNTIME=node never use oam
|
|
51
52
|
* TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
53
|
+
* anything else warns on stderr, then behaves as auto
|
|
52
54
|
* TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
|
|
53
55
|
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
54
56
|
*/
|
|
@@ -156,7 +158,16 @@ function atLeast(v, min) {
|
|
|
156
158
|
function sandboxFlags() {
|
|
157
159
|
if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
|
|
158
160
|
|
|
159
|
-
|
|
161
|
+
// ONE host, deliberately. Every outbound request the bundle makes targets
|
|
162
|
+
// api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
|
|
163
|
+
// allow-list that refuses to send credentials anywhere else. login.tailscale.com
|
|
164
|
+
// was granted here as well until an audit found no code path that contacts it
|
|
165
|
+
// -- console.tailscale.com appears in error TEXT, never as a request target.
|
|
166
|
+
// An unused grant is the one kind of over-permission nothing ever surfaces:
|
|
167
|
+
// removing it cannot break a call that was never made, and keeping it widens
|
|
168
|
+
// the sandbox for no behaviour. launcher.test.ts pins this list exactly so a
|
|
169
|
+
// future host lands as a reviewed diff rather than a quiet widening.
|
|
170
|
+
const hosts = ["api.tailscale.com"];
|
|
160
171
|
|
|
161
172
|
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
162
173
|
|
|
@@ -166,7 +177,27 @@ function sandboxFlags() {
|
|
|
166
177
|
// meant the local-CLI tool group silently failed to register under the
|
|
167
178
|
// sandbox even though --allow-child-process is granted below precisely so
|
|
168
179
|
// those tools can shell out.
|
|
169
|
-
const env = [
|
|
180
|
+
const env = [
|
|
181
|
+
"PATH",
|
|
182
|
+
"TAILSCALE_API_KEY",
|
|
183
|
+
"TAILSCALE_BINARY",
|
|
184
|
+
"TAILSCALE_DEBUG",
|
|
185
|
+
"TAILSCALE_EXTRA_POSTURE_PROVIDERS",
|
|
186
|
+
"TAILSCALE_EXTRA_WEBHOOK_EVENTS",
|
|
187
|
+
"TAILSCALE_LOCAL_CLI",
|
|
188
|
+
"TAILSCALE_MAX_CONCURRENT",
|
|
189
|
+
"TAILSCALE_OAUTH_CLIENT_ID",
|
|
190
|
+
"TAILSCALE_OAUTH_CLIENT_SECRET",
|
|
191
|
+
"TAILSCALE_OAUTH_TAILNET",
|
|
192
|
+
"TAILSCALE_PROFILE",
|
|
193
|
+
"TAILSCALE_READONLY",
|
|
194
|
+
"TAILSCALE_REQUEST_BUDGET_MS",
|
|
195
|
+
"TAILSCALE_REQUIRE_APPROVAL",
|
|
196
|
+
"TAILSCALE_RETRY_BASE_DELAY_MS",
|
|
197
|
+
"TAILSCALE_TAILNET",
|
|
198
|
+
"TAILSCALE_TOOLS",
|
|
199
|
+
"TAILSCALE_WRITE_GROUPS",
|
|
200
|
+
];
|
|
170
201
|
|
|
171
202
|
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
172
203
|
flags.push("--allow-child-process");
|
|
@@ -231,7 +262,23 @@ async function runInProcess() {
|
|
|
231
262
|
await import(SERVER_URL.href);
|
|
232
263
|
}
|
|
233
264
|
|
|
234
|
-
|
|
265
|
+
// Every value below is compared against `mode` after lowercasing, so an
|
|
266
|
+
// unrecognized one matched nothing and fell through to the auto branch --
|
|
267
|
+
// `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
|
|
268
|
+
// which is the opposite of what was asked for. Same handling as index.ts gives
|
|
269
|
+
// an unknown subcommand: auto is still the right landing place, it just stops
|
|
270
|
+
// being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
|
|
271
|
+
// wrapper script is how it usually gets there.
|
|
272
|
+
const RUNTIMES = ["auto", "node", "oam"];
|
|
273
|
+
const requested = process.env.TAILSCALE_MCP_RUNTIME;
|
|
274
|
+
const mode = (requested ?? "auto").toLowerCase();
|
|
275
|
+
if (requested && !RUNTIMES.includes(mode)) {
|
|
276
|
+
// Echo what was SET, not the lowercased form, so the typo is recognisable in
|
|
277
|
+
// the host's log next to the config line that produced it.
|
|
278
|
+
await errSync(
|
|
279
|
+
`tailscale-mcp: unrecognized TAILSCALE_MCP_RUNTIME "${requested}" -- known values: ${RUNTIMES.join(", ")}. Using auto.\n`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
235
282
|
|
|
236
283
|
if (mode === "node") {
|
|
237
284
|
await runInProcess();
|
|
@@ -259,7 +306,8 @@ if (mode === "node") {
|
|
|
259
306
|
const { writeSync } = await import("node:fs");
|
|
260
307
|
writeSync(
|
|
261
308
|
2,
|
|
262
|
-
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
309
|
+
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
310
|
+
shimNote +
|
|
263
311
|
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
|
|
264
312
|
);
|
|
265
313
|
process.exit(1);
|
|
@@ -280,7 +328,7 @@ if (mode === "node") {
|
|
|
280
328
|
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
281
329
|
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
282
330
|
const remedy = found
|
|
283
|
-
? "Run
|
|
331
|
+
? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
|
|
284
332
|
: "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
|
|
285
333
|
if (mode === "oam") {
|
|
286
334
|
await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
@@ -337,7 +385,6 @@ if (mode === "node") {
|
|
|
337
385
|
}
|
|
338
386
|
|
|
339
387
|
if (child) {
|
|
340
|
-
|
|
341
388
|
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
342
389
|
// wrong arch, permission), fall back rather than failing the whole server.
|
|
343
390
|
// `spawned` prevents falling back AFTER the child started, which would
|