@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 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.** 94 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
- 94 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`** (49 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
- - **`full`** (94 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 (100 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 94.
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 (94 + 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
 
@@ -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> (7 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
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 94 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.
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 `login.tailscale.com`, 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.
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
  }
@@ -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 control-plane hosts, filesystem denied.
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
- const hosts = ["api.tailscale.com","login.tailscale.com"];
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 = ["PATH","TAILSCALE_API_KEY","TAILSCALE_BINARY","TAILSCALE_DEBUG","TAILSCALE_EXTRA_POSTURE_PROVIDERS","TAILSCALE_EXTRA_WEBHOOK_EVENTS","TAILSCALE_LOCAL_CLI","TAILSCALE_MAX_CONCURRENT","TAILSCALE_OAUTH_CLIENT_ID","TAILSCALE_OAUTH_CLIENT_SECRET","TAILSCALE_OAUTH_TAILNET","TAILSCALE_PROFILE","TAILSCALE_READONLY","TAILSCALE_REQUEST_BUDGET_MS","TAILSCALE_RETRY_BASE_DELAY_MS","TAILSCALE_TAILNET","TAILSCALE_TOOLS"];
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
- const mode = (process.env.TAILSCALE_MCP_RUNTIME ?? "auto").toLowerCase();
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" + shimNote +
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 \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n"
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