@yawlabs/tailscale-mcp 0.20.1 → 0.21.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.** 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.
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 1900+ 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
 
@@ -17,11 +17,11 @@ One click adds this to your local Yaw MCP config so it's available in every Yaw
17
17
 
18
18
  You could `curl` the Tailscale API. The point isn't replacing `curl` — it's letting an agent compose multi-endpoint workflows in one turn without writing a script:
19
19
 
20
- - **"Which devices haven't checked in for 30 days and have key expiry disabled?"** — lists devices, filters by `lastSeen`, filters by `keyExpiryDisabled`, returns a table. Three endpoints, one question.
20
+ - **"Which devices haven't checked in for 30 days and have key expiry disabled?"** — lists devices, filters by `lastSeen` (online devices carry none), filters by `keyExpiryDisabled`, returns a table. Three endpoints, one question.
21
21
  - **"Someone broke DNS at 2am — who changed what in the last 24 hours?"** — pulls the audit log, filters by DNS-related actors and endpoints, reads each change's before/after, summarizes in English.
22
22
  - **"Draft an ACL change that lets `tag:mobile` reach `tag:dashboard` but not `tag:db`, preserving my comments"** — reads the current HuJSON, proposes a minimal diff, validates it against the API, returns the diff for you to apply.
23
23
  - **"Rotate every auth key older than 90 days and print the new ones"** — iterates, creates new keys with matching tags, revokes the old ones.
24
- - **"Create an OAuth client for our CI pipeline scoped to `devices:read` and `dns`"** — creates a trust credential via `tailscale_create_key` with `keyType=client`, returns the credentials once (save them immediately).
24
+ - **"Create an OAuth client for our CI pipeline scoped to `devices:core:read` and `dns:read`"** — creates a trust credential via `tailscale_create_key` with `keyType=client`, returns the credentials once (save them immediately).
25
25
 
26
26
  A curl can do each step. The agent composes them. That's where the lift is, and that's what the tool surface is designed for — every read endpoint is first-class so the agent can synthesize, and every write endpoint is tagged `destructiveHint` or `idempotentHint` so your MCP client can gate mutations the way you configured it.
27
27
 
@@ -33,17 +33,19 @@ Reasonable question. Both have their place. Where this MCP is better:
33
33
 
34
34
  - **Broad admin API coverage.** The `tailscale` CLI is scoped to the node it runs on. Admin concerns — ACLs, users, invites, webhooks, log streaming, posture integrations, auth keys, OAuth clients, and federated identities — live in the v2 HTTP API. You'd be shelling out to `curl` anyway.
35
35
  - **Typed tool surface, not string parsing.** Every tool has a Zod-validated input schema and a structured response. No brittle `tailscale status --json | jq` pipelines that break when the schema evolves.
36
- - **Cross-client, no user rewriting.** A Claude Code skill only loads in Claude Code. An MCP server works in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and anything else that speaks MCP. Version bumps ship through `npx` — users don't re-author their skill when Tailscale adds an endpoint.
36
+ - **Cross-client, and updates arrive as a version bump.** An MCP server works in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and anything else that speaks MCP; a skill written to the Agent Skills standard travels between agents too, but it is prose you maintain. Version bumps ship through `npx` — nobody rewrites a skill's instructions when Tailscale adds an endpoint.
37
37
  - **Safe-by-default writes.** Every tool declares `readOnlyHint` / `destructiveHint` / `idempotentHint` so clients can skip confirmation on reads and require it on mutations. A skill that shells out to the CLI can't express that.
38
- - **Real tests.** 700+ unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
38
+ - **Real tests.** 1900+ unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
39
39
 
40
40
  If you already have a skill that covers your 10% of Tailscale workflows, great — keep it. The MCP is for the other 90%.
41
41
 
42
+ **What about Tailscale's own MCP endpoints and skill?** As of 2026-09-19 they solve different problems, and nothing here duplicates them. Tailscale's official MCP tools are two alpha [built-in connectors](https://tailscale.com/docs/aperture/connectors/built-in-connectors) inside Aperture: *Tailnet*, whose `Tailnet_provision_node` returns a single-use auth key so an agent can join one new node after a person approves it, and *Tailscale SSH*, whose `TailnetSSH_list_machines` and `TailnetSSH_run_command` discover SSH-enabled machines and run one command on one of them. They require Aperture. [`tailscale/tailscale-skill`](https://github.com/tailscale/tailscale-skill) is an alpha, knowledge-only skill — reference material that teaches an agent to `curl` the v2 API, with no server of its own. Neither exposes the admin API as typed tools, so the overlap with this server is close to nil. Nor can Aperture front this one today: this server speaks stdio, and Aperture proxies only URL-addressable Streamable-HTTP or SSE servers. Both of those products are alpha, so treat the date on this paragraph as its expiry.
43
+
42
44
  ## Trust signals
43
45
 
44
46
  Fair critique from Reddit: a new repo claiming "actively maintained" with no visible tests is worth exactly zero trust. Here's what's actually verifiable:
45
47
 
46
- - **700+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
48
+ - **1900+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
49
  - **Local release flow** via [`release.sh`](./release.sh): lint + test + bump + tag + push + npm publish + MCP Registry publish, all from the workstation. No CI workflow to babysit.
48
50
  - **Dependabot alerts** surface on this repo and get fixed, not ignored.
49
51
  - **Every tool verified against the live API.** If it's in the tool list, it calls a real endpoint that exists in the current v2 API. No placeholder 404 tools.
@@ -54,12 +56,26 @@ Issues and PRs are triaged. File one if something is off — [github.com/YawLabs
54
56
 
55
57
  **1. Set your API key**
56
58
 
57
- Get an API key from [Tailscale Admin Console > Settings > Keys](https://console.tailscale.com/admin/settings/keys) and add it to your shell profile (`~/.bashrc`, `~/.zshrc`, or Windows system environment variables):
59
+ Get an API key from [Tailscale Admin Console > Settings > Keys](https://console.tailscale.com/admin/settings/keys) and set it where your MCP client will see it. The `.mcp.json` `env` block in step 2 works identically on every platform and is the option to prefer; to export it from a shell profile instead (`~/.bashrc`, `~/.zshrc`, `~/.config/fish/config.fish`):
60
+
61
+ macOS / Linux / WSL (bash, zsh):
58
62
 
59
63
  ```bash
60
64
  export TAILSCALE_API_KEY="tskey-api-..."
61
65
  ```
62
66
 
67
+ fish:
68
+
69
+ ```fish
70
+ set -Ux TAILSCALE_API_KEY tskey-api-...
71
+ ```
72
+
73
+ Windows (PowerShell 5.1 and 7) — `[Environment]::SetEnvironmentVariable` persists it for the user, where `$env:` alone lasts only for the session:
74
+
75
+ ```powershell
76
+ [Environment]::SetEnvironmentVariable('TAILSCALE_API_KEY', 'tskey-api-...', 'User')
77
+ ```
78
+
63
79
  **2. Create `.mcp.json` in your project root**
64
80
 
65
81
  macOS / Linux / WSL:
@@ -215,9 +231,9 @@ needs no credentials, so it works even when the server is misconfigured.
215
231
  }
216
232
  ```
217
233
 
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.
234
+ That serves all 41 read tools plus the 18 writes in `devices` and `keys`, and withholds the other 38 writes — the ACL, DNS, users, tailnet, 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
235
 
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.
236
+ > **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 ([scopes per tool group](#oauth-scopes-by-tool-group)) — 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
237
 
222
238
  ### What a grant actually contains
223
239
 
@@ -240,7 +256,7 @@ Group names are the same ones `TAILSCALE_TOOLS` uses. Writes per group:
240
256
 
241
257
  `keys`, `users` and `acl` are not blocked — CI key rotation legitimately needs `keys` — but grant them knowing:
242
258
 
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.
259
+ - **`keys`** — `tailscale_create_key` mints an OAuth client with whatever scopes the caller asks for, including `policy_file` and `all`. That credential outlives the agent's session and is not subject to this or any other setting here.
244
260
  - **`users`** — `tailscale_update_user_role` accepts `owner`.
245
261
  - **`acl`** — `tailscale_update_acl` rewrites policy for every principal in the tailnet.
246
262
 
@@ -303,7 +319,7 @@ The line drawn is **"this server cannot undo it with information you still hold"
303
319
 
304
320
  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
321
 
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.
322
+ Separately and always on, the tools whose response size scales with the tailnet rather than with the request — `tailscale_list_devices`, `tailscale_list_users`, `tailscale_get_acl`, `tailscale_diff_acl_access`, `tailscale_get_audit_log`, `tailscale_get_network_flow_logs`, `tailscale_local_status` — 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. The last of those is the one entry behind an opt-in: it declares the cap whenever `TAILSCALE_LOCAL_CLI=1` registers it, and is absent entirely otherwise.
307
323
 
308
324
  ## Using with mcp.hosting / mcph
309
325
 
@@ -318,17 +334,41 @@ Recommended pattern for mcph users: set `TAILSCALE_PROFILE=core` (or narrower) i
318
334
 
319
335
  **API key (simplest):** Set `TAILSCALE_API_KEY` in your shell or MCP config.
320
336
 
321
- **OAuth (scoped access):** For fine-grained permissions, set `TAILSCALE_OAUTH_CLIENT_ID` and `TAILSCALE_OAUTH_CLIENT_SECRET` instead. Create an OAuth client at [Tailscale Admin Console > Settings > OAuth](https://console.tailscale.com/admin/settings/oauth).
337
+ **OAuth (scoped access):** For fine-grained permissions, set `TAILSCALE_OAUTH_CLIENT_ID` and `TAILSCALE_OAUTH_CLIENT_SECRET` instead. Create an OAuth client at [Tailscale Admin Console > Settings > Trust credentials](https://console.tailscale.com/admin/settings/trust-credentials), with the scopes [listed below](#oauth-scopes-by-tool-group) for the tool groups you load.
322
338
 
323
339
  The server checks for an API key first, then falls back to OAuth. If neither is set, tools return a clear error telling you what to configure — the server still starts, so your MCP client doesn't loop restarting.
324
340
 
325
- **Tailnet:** Uses your default tailnet automatically. Set `TAILSCALE_TAILNET` to specify one explicitly.
341
+ **Tailnet:** Uses the credential's own tailnet (`-`) automatically, which is what most setups want. To name one explicitly, set `TAILSCALE_TAILNET` to the **Tailnet ID** shown under [Settings > General](https://console.tailscale.com/admin/settings/general) in the admin console — it looks like `T1234CNTRL`. Tailnets created before October 2025 can still use their legacy organization name; newer ones have no such name to use. Per the OpenAPI spec, the Tailnet ID is the preferred identifier either way.
326
342
 
327
343
  **`TAILSCALE_OAUTH_TAILNET`** — target an **API-only tailnet** (one created by `tailscale_create_org_tailnet`). Those tailnets are not reachable with a plain client-credentials exchange: you authenticate with an OAuth client belonging to the *creating* tailnet (`all` scope) and the target rides on the token request. Set this to the new tailnet's id. Deliberately separate from `TAILSCALE_TAILNET` so the default token exchange is unchanged for everyone else. If you set this, leave `TAILSCALE_TAILNET` unset (or `-`) so tool requests follow the token — pointing the two at different tailnets makes every tailnet-scoped tool return 403, and the server warns about it at startup.
328
344
 
345
+ ### OAuth scopes by tool group
346
+
347
+ The scopes an OAuth client needs for each `TAILSCALE_TOOLS` group. Grant the Read column for the groups you load, and add the Write column for the ones you let it change; Notes lists what a group needs beyond that. `all:read` (every read scope) and `all` (everything) are the broadest grants there are.
348
+
349
+ | Group | Read | Write | Notes |
350
+ |---|---|---|---|
351
+ | `status` | `devices:core:read`, `feature_settings:read` | none | Reads the device list and the tailnet settings, and still returns one when the other is refused. |
352
+ | `devices` | `devices:core:read`, `devices:routes:read`, `devices:posture_attributes:read` | `devices:core`, `devices:routes`, `devices:posture_attributes` | The route tools use the `devices:routes` pair and the posture-attribute tools the `devices:posture_attributes` pair; the rest use `devices:core`. A credential holding `devices:core` must be created with at least one tag. |
353
+ | `acl` | `policy_file:read`, `devices:core:read`, `devices:posture_attributes:read` | `policy_file`, `devices:posture_attributes` | Tailscale requires the device scopes alongside `policy_file:read` and `policy_file`. `tailscale_diff_acl_access` also needs `users:read` when you omit `principals` and it lists the users itself. |
354
+ | `dns` | `dns:read` | `dns` | **Unverified.** The OpenAPI spec names no scope on any DNS operation, and the trust credentials doc these come from does not list `/dns/configuration`, the endpoint behind `tailscale_get_dns_configuration` and `tailscale_set_dns_configuration`. |
355
+ | `keys` | `auth_keys:read`, `oauth_keys:read`, `federated_keys:read`, `api_access_tokens:read`, `oauth_apps:read` | `auth_keys`, `oauth_keys`, `federated_keys`, `api_access_tokens`, `oauth_apps` | Key scopes go by key type: `auth_keys` for auth keys, `oauth_keys` for OAuth clients, `federated_keys` for federated identities, `api_access_tokens` for personal API access tokens (read and delete only). Grant only the types you manage; only `all:read` and `all` can list every access token in the tailnet. The OAuth-app tools use `oauth_apps`, and `tailscale_create_oauth_app` also needs `devices:posture_attributes` when it sends `allowedNodeAttributes`. |
356
+ | `users` | `users:read` | `users` | |
357
+ | `tailnet` | `feature_settings:read`, `account_settings:read` | `feature_settings`, `account_settings` | Settings are split by field: network flow logging needs `logs:network:read` / `logs:network`, HTTPS certificates `networking_settings:read` / `networking_settings`, and the two externally-managed-ACL fields `policy_file:read` / `policy_file`; `feature_settings` covers the rest. The contacts tools use `account_settings`. |
358
+ | `org-tailnets` | `tailnets:read` | `tailnets` | `tailscale_delete_tailnet` needs `all` (see `TAILSCALE_OAUTH_TAILNET` above). |
359
+ | `webhooks` | `webhooks:read` | `webhooks` | |
360
+ | `posture` | `feature_settings:read` | `feature_settings` | The same scope governs most tailnet settings, so a client that can manage posture integrations can change those too. |
361
+ | `audit` | `logs:configuration:read`, `logs:network:read` | none | `tailscale_get_audit_log` uses the first, `tailscale_get_network_flow_logs` the second. |
362
+ | `invites` | `device_invites:read` | `device_invites` (delete only) | Creating, resending and accepting a device invite (`tailscale_create_device_invite`, `tailscale_resend_device_invite`, `tailscale_accept_device_invite`) cannot be done with a token from an OAuth client at all, and creating, deleting and resending a user invite (`tailscale_create_user_invite`, `tailscale_delete_user_invite`, `tailscale_resend_user_invite`) is permitted only with a user-owned key. Use `TAILSCALE_API_KEY` for those. The spec names no scope for reading user invites. |
363
+ | `services` | `services:read` | `services` | `tailscale_list_service_hosts`, `tailscale_get_service_device_approval` and `tailscale_set_service_device_approval` need both `services` and `devices:core`, so the two reads among them do not work on a read-only client. |
364
+ | `log-streaming` | `log_streaming:read` | `log_streaming` | Streaming to a private endpoint also needs `device_invites` and `policy_file`. `tailscale_create_aws_external_id` and `tailscale_validate_aws_trust_policy` both need `log_streaming`, although the second is a read. |
365
+ | `local-cli` | none | none | Runs the local `tailscale` binary and makes no admin-API call. |
366
+
367
+ Scopes are taken from Tailscale's [OpenAPI spec](https://tailscale.com/api) as of 2026-09-19. The DNS row, which the spec omits, and the device scopes in the `acl` row come from the [trust credentials doc](https://tailscale.com/docs/reference/trust-credentials). All of it is read from the documentation, not observed against a tailnet.
368
+
329
369
  ## Reliability and debugging
330
370
 
331
- **429 retry (built-in).** API responses with HTTP 429 are retried up to 3 times, honoring the `Retry-After` header (both seconds-integer and HTTP-date forms). Falls back to exponential backoff with jitter, capped at 30s per wait. No env var needed — this is on by default. Workflows like "rotate every key older than 90 days" no longer fail mid-loop on Tailscale's per-tenant rate limits.
371
+ **429 and gateway-error retry (built-in).** HTTP 429, 502, 503 and 504 are retried up to 3 times on the idempotent methods (GET, PUT, DELETE), honoring the `Retry-After` header (both seconds-integer and HTTP-date forms). Falls back to exponential backoff with jitter, capped at 30s per wait. No env var needed — this is on by default. Workflows like "rotate every key older than 90 days" no longer fail mid-loop on Tailscale's per-tenant rate limits, and a gateway blip no longer fails a whole tool call: per the OpenAPI spec, 504 is documented on every device and service operation with the message "request took too long to process, please try again later", and 502 on the log reads. HTTP 500 is *not* retried — it means the server failed to process the request, not that something in front of it gave up. POST and PATCH are never retried, on any status. A DELETE that retried past a gateway error and then got a 404 says so in its error, because the attempt that timed out may already have deleted the resource.
332
372
 
333
373
  **`TAILSCALE_DEBUG=1`** — log every HTTP method, URL, status, and elapsed time to stderr. Authorization headers are never logged. Use this when a tool returns an unexpected error and you want to see the actual request that went out. Example:
334
374
 
@@ -339,30 +379,41 @@ The server checks for an API key first, then falls back to OAuth. If neither is
339
379
 
340
380
  **`TAILSCALE_MAX_CONCURRENT=N`** — cap in-flight API requests at `N`. Default is unlimited (no behavior change for users who don't opt in). Useful when an agent fans out aggressively against a tailnet that has stricter limits than the per-call retry can absorb.
341
381
 
342
- **`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including 429 retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the 429 immediately instead of holding the line. Tune lower if your MCP client has a tighter outer timeout. 429s on non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
382
+ **`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the error immediately instead of holding the line. For a gateway 5xx that prediction also charges the duration of the attempt that just failed: a 504 arrives only after the gateway has already waited, so the retry most likely costs the same again, and spending the rest of the budget on it would leave your client with silence instead of the 504. A call that retries past a gateway 5xx is also held to **half** this budget from that point on — 45s by default, under the 60s low end of the usual MCP client timeout — since those attempts cost gateway wait time rather than backoff, and a chain of them can outlast the client while a 429 chain cannot. Raising this value raises that ceiling with it; a 429 chain keeps the whole budget either way. Tune lower if your MCP client has a tighter outer timeout. Non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
343
383
 
344
384
  **`TAILSCALE_RETRY_BASE_DELAY_MS=N`** — base delay for the exponential backoff between retries; attempt `N` waits `base * 2^N` (capped at 30s, plus jitter). Default `1000` (1s), so a fully-exhausted retry chain spends roughly 1s + 2s + 4s sleeping. Pairs with `TAILSCALE_REQUEST_BUDGET_MS`: lowering the budget on its own doesn't get you more retries, it just makes the default backoff exhaust the budget sooner and give up. Shrink both if you want "retry hard, fail fast". A server-supplied `Retry-After` header always wins over this value.
345
385
 
346
- **`TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB`** — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate `subscriptions` against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. Please also [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) so the static list catches up.
386
+ **`TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB`** — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate `subscriptions` against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. The two category subscriptions (`categoryTailnetManagement`, `categoryDeviceMisconfigurations`) are in the catalog, so they need no entry here. Please also [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) so the static list catches up.
347
387
 
348
388
  **`TAILSCALE_EXTRA_POSTURE_PROVIDERS=providerA,providerB`** — the same escape hatch for device-posture integration providers. `tailscale_create_posture_integration` validates `provider` against a static list (`falcon`, `fleet`, `huntress`, `intune`, `jamfpro`, `kandji`, `kolide`, `sentinelone`); if Tailscale adds one before this package catches up, list it here rather than waiting for a release. This field used to be a closed enum, which made a newly-supported provider *uncreatable* rather than merely unvalidated.
349
389
 
350
- **Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
390
+ **Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. When the body also carries a `data` array — which the ACL endpoints use to report a failing policy test — it is rendered under the message, so a rejected policy says which user and which assertion failed instead of just `test(s) failed`. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
351
391
 
352
392
  ## Local CLI integration (opt-in)
353
393
 
354
394
  Most tools talk to the Tailscale v2 admin API — they describe **the tailnet**. Sometimes you want to ask about **this machine's** view: is it actually connected? What DERP region is it on? How far is `my-laptop` from here? Those answers come from the local `tailscale` binary, not the admin API.
355
395
 
356
- Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add six read-only diagnostic tools:
396
+ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add 6 read-only diagnostic tools:
357
397
 
358
398
  | Tool | Equivalent CLI command | Use it for |
359
399
  |---|---|---|
360
- | `tailscale_local_status` | `tailscale status --json` | This machine's connection state + peers it can see |
400
+ | `tailscale_local_status` | `tailscale status --json [--peers=false] [--active]` | This machine's connection state + peers it can see; `peers: false` and `activeOnly: true` narrow the peer map |
361
401
  | `tailscale_ping` | `tailscale ping <target>` | Latency probe to another tailnet node (direct vs DERP-relayed) |
362
402
  | `tailscale_netcheck` | `tailscale netcheck --format=json` | NAT type, DERP latency map, IPv4/IPv6 support |
363
403
  | `tailscale_local_version` | `tailscale version` | Which client version is actually running |
404
+ | `tailscale_local_whoami` | `tailscale whoami` | Which user and device this machine is authenticated as (needs tailscale >= 1.102.1) |
405
+ | `tailscale_local_service_list` | `tailscale service list` | Tailscale Services visible to *this* node (needs tailscale >= 1.102.1) |
364
406
 
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.
407
+ Requirements: the `tailscale` binary has to be findable. It's looked up on `PATH` first, then at the default install paths below, and `TAILSCALE_BINARY` overrides both with an absolute path of your choosing.
408
+
409
+ | Platform | Where it looks beyond `PATH` | Notes |
410
+ |---|---|---|
411
+ | macOS | `/Applications/Tailscale.app/Contents/MacOS/Tailscale`, `/opt/homebrew/bin/tailscale`, `/usr/local/bin/tailscale` | The standard install keeps the CLI **inside the app bundle** and adds nothing to `PATH`. An MCP client launched from the Dock or Spotlight also inherits a minimal `PATH`, not your shell's — so a bare lookup can fail even when `tailscale` works in your terminal. |
412
+ | Linux | `/usr/bin/tailscale`, `/snap/bin/tailscale` | The snap wrapper is outside some minimal `PATH`s. |
413
+ | Windows | — | The installer puts `tailscale.exe` on the machine `PATH`. If you set `TAILSCALE_BINARY`, use a Windows path (`C:/Program Files/Tailscale/tailscale.exe`), not a Git Bash one (`/c/...`) — that spelling is translated when you type it at an MSYS prompt, but not when it's read from a JSON config or a `.env`. |
414
+ | WSL | `/usr/bin/tailscale`, `/snap/bin/tailscale` | **These tools report the Linux node, and need Tailscale installed inside the distro with `tailscaled` running there.** In a fresh WSL install the only `tailscale` in reach is the Windows one; a Linux process can't exec `tailscale.exe`, and pointing `TAILSCALE_BINARY` at `/mnt/c/.../tailscale.exe` would report the **Windows** host's `Self`, peers, `whoami` identity and netcheck results while every tool here says "this machine's". `tailscale.exe` is deliberately never picked up automatically. |
415
+
416
+ 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.
366
417
 
367
418
  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.
368
419
 
@@ -393,18 +444,18 @@ MCP Resources expose read-only data clients can browse without a tool call.
393
444
 
394
445
  | Tool | Description |
395
446
  |------|-------------|
396
- | `tailscale_list_devices` | List all devices with status, IPs, OS, and last seen |
397
- | `tailscale_get_device` | Get detailed info for a specific device |
447
+ | `tailscale_list_devices` | List devices (default field subset; `fields: "all"` adds routes, connectivity, SSH, distro, posture identity). `lastSeen` is absent while a device is online |
448
+ | `tailscale_get_device` | Get one device (`fields: "all"` for the full record) |
398
449
  | `tailscale_authorize_device` | Authorize a pending device |
399
450
  | `tailscale_deauthorize_device` | Deauthorize a device |
400
451
  | `tailscale_set_devices_authorized` | Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
401
452
  | `tailscale_delete_device` | Remove a device from the tailnet |
402
- | `tailscale_rename_device` | Rename a device |
453
+ | `tailscale_rename_device` | Rename a device (FQDN or base name; empty string resets to the OS hostname) |
403
454
  | `tailscale_expire_device` | Expire a device's key, forcing re-authentication |
404
455
  | `tailscale_get_device_routes` | Get advertised and enabled subnet routes |
405
456
  | `tailscale_set_device_routes` | Enable or disable subnet routes |
406
457
  | `tailscale_get_device_posture_attributes` | Get all posture attributes for a device |
407
- | `tailscale_set_device_posture_attribute` | Set a custom posture attribute (with optional expiry) |
458
+ | `tailscale_set_device_posture_attribute` | Set a custom posture attribute (optional expiry and audit-log comment) |
408
459
  | `tailscale_delete_device_posture_attribute` | Delete a custom posture attribute |
409
460
  | `tailscale_set_device_tags` | Set ACL tags on a device |
410
461
  | `tailscale_set_device_ip` | Set a device's Tailscale IPv4 address |
@@ -419,7 +470,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
419
470
  | Tool | Description |
420
471
  |------|-------------|
421
472
  | `tailscale_get_acl` | Get ACL policy with formatting preserved (HuJSON) + ETag |
422
- | `tailscale_update_acl` | Update ACL policy (requires ETag for safe concurrent edits) |
473
+ | `tailscale_update_acl` | Update ACL policy (requires ETag for safe concurrent edits; `ts-default` for a first write) |
423
474
  | `tailscale_validate_acl` | Validate a policy without applying it |
424
475
  | `tailscale_preview_acl` | Preview rules that would apply to a user or IP |
425
476
  | `tailscale_diff_acl_access` | Compare a proposed policy against the live one — who gains and loses access |
@@ -436,8 +487,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
436
487
  | `tailscale_get_search_paths` | Get DNS search paths |
437
488
  | `tailscale_set_search_paths` | Set DNS search paths |
438
489
  | `tailscale_get_split_dns` | Get split DNS configuration |
439
- | `tailscale_set_split_dns` | Set split DNS configuration (full replace) |
440
- | `tailscale_update_split_dns` | Update split DNS configuration (partial merge) |
490
+ | `tailscale_set_split_dns` | Set split DNS configuration (full replace; `null` clears a domain) |
491
+ | `tailscale_update_split_dns` | Update split DNS configuration (partial merge; `null` removes a domain) |
441
492
  | `tailscale_get_dns_preferences` | Get DNS preferences (MagicDNS) |
442
493
  | `tailscale_set_dns_preferences` | Set DNS preferences (MagicDNS) |
443
494
  | `tailscale_get_dns_configuration` | Get unified DNS configuration (all settings in one call) |
@@ -450,10 +501,10 @@ MCP Resources expose read-only data clients can browse without a tool call.
450
501
 
451
502
  | Tool | Description |
452
503
  |------|-------------|
453
- | `tailscale_list_keys` | List keys (auth keys; pass `all=true` to include OAuth clients and federated identities) |
454
- | `tailscale_get_key` | Get details for a key |
504
+ | `tailscale_list_keys` | List keys (default set depends on the credential; `all=true` for tailnet-wide: auth keys, API access tokens, OAuth clients, federated identities) |
505
+ | `tailscale_get_key` | Get details for a key of any type |
455
506
  | `tailscale_create_key` | Create an auth key, OAuth client (`keyType=client`), or federated identity (`keyType=federated`) |
456
- | `tailscale_delete_key` | Delete a key |
507
+ | `tailscale_delete_key` | Delete a key of any type, including the API access token this server runs on |
457
508
  | `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
458
509
  | `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
459
510
  | `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
@@ -497,7 +548,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
497
548
  |------|-------------|
498
549
  | `tailscale_list_webhooks` | List webhooks |
499
550
  | `tailscale_get_webhook` | Get a specific webhook |
500
- | `tailscale_create_webhook` | Create a webhook |
551
+ | `tailscale_create_webhook` | Create a webhook (raw JSON, or formatted for Slack / Mattermost / Google Chat / Discord via `providerType`) |
501
552
  | `tailscale_update_webhook` | Update a webhook's endpoint URL and/or subscriptions |
502
553
  | `tailscale_delete_webhook` | Delete a webhook |
503
554
  | `tailscale_rotate_webhook_secret` | Rotate a webhook's secret |
@@ -537,10 +588,11 @@ MCP Resources expose read-only data clients can browse without a tool call.
537
588
  <summary><strong>Organization Tailnets</strong> (3 tools) — API-only tailnets; OAuth authentication required</summary>
538
589
 
539
590
  Create and tear down whole tailnets programmatically — useful for per-agent sandboxes,
540
- per-tenant isolation, and ephemeral CI environments. Unlike every other group here these
541
- endpoints live under `/organizations`, authenticate **only** with an OAuth client (the
542
- `tailnets` scope to create, `all` to then reach the tailnet), and produce tailnets that are
543
- not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on one.
591
+ per-tenant isolation, and ephemeral CI environments. Organizations get 10 tailnets
592
+ including the original by default. Unlike every other group here these endpoints live
593
+ under `/organizations`, authenticate **only** with an OAuth client (the `tailnets` scope
594
+ to create, `all` to then reach the tailnet), and produce tailnets that are not managed in
595
+ the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on one.
544
596
 
545
597
  | Tool | Description |
546
598
  |------|-------------|
@@ -560,7 +612,7 @@ not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on on
560
612
  | `tailscale_set_log_stream_config` | Set where logs are sent (Axiom, Datadog, Splunk, etc.) |
561
613
  | `tailscale_delete_log_stream_config` | Delete a log streaming configuration |
562
614
  | `tailscale_get_log_stream_status` | Check if log streaming is delivering successfully |
563
- | `tailscale_create_aws_external_id` | Create/get AWS external ID for S3 log streaming |
615
+ | `tailscale_create_aws_external_id` | Create/get the AWS external ID for S3 role-based log streaming (`reusable`, default true, returns the same ID until it is linked) |
564
616
  | `tailscale_validate_aws_trust_policy` | Validate AWS IAM role trust policy for S3 log streaming |
565
617
 
566
618
  </details>
@@ -584,7 +636,7 @@ not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on on
584
636
 
585
637
  | Tool | Description |
586
638
  |------|-------------|
587
- | `tailscale_list_user_invites` | List user invites |
639
+ | `tailscale_list_user_invites` | List open (not yet accepted) user invites |
588
640
  | `tailscale_create_user_invite` | Create a user invite |
589
641
  | `tailscale_get_user_invite` | Get a user invite |
590
642
  | `tailscale_delete_user_invite` | Delete a user invite |
@@ -597,7 +649,7 @@ not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on on
597
649
 
598
650
  | Tool | Description |
599
651
  |------|-------------|
600
- | `tailscale_get_audit_log` | Get configuration audit log (who changed what, when) |
652
+ | `tailscale_get_audit_log` | Get configuration audit log (who changed what, when); optional server-side actor / target / event filter |
601
653
  | `tailscale_get_network_flow_logs` | Get network traffic flow logs between devices |
602
654
 
603
655
  </details>
@@ -607,7 +659,7 @@ not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on on
607
659
 
608
660
  | Tool | Description |
609
661
  |------|-------------|
610
- | `tailscale_local_status` | This machine's view of the tailnet (own connection state, peers, DERP region) |
662
+ | `tailscale_local_status` | This machine's view of the tailnet (own connection state, peers, DERP region); narrow with `peers: false` or `activeOnly: true` |
611
663
  | `tailscale_ping` | Latency probe to another tailnet node from this machine |
612
664
  | `tailscale_netcheck` | NAT type, DERP latency map, IPv4/IPv6 support diagnostics |
613
665
  | `tailscale_local_version` | Local `tailscale` binary version |
@@ -628,7 +680,9 @@ npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
628
680
  npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
629
681
  ```
630
682
 
631
- Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
683
+ Works in any CI system. Set `TAILSCALE_API_KEY` as an env var; `TAILSCALE_TAILNET` is optional — leave it unset to act on the key's own tailnet, or set it to the [Tailnet ID](#authentication) to name one explicitly. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
684
+
685
+ When validation reports a failing policy test, the CI log names the user and the assertion (`For user user1@example.com:` / `Errors found:`), the same detail upstream's `gitops-pusher` prints. Validation *warnings* — a SCIM group that is not syncing, for instance — fail the run too, matching `gitops-pusher` and Tailscale's own Go client; their text is printed alongside so you can see what was flagged.
632
686
 
633
687
  A complete GitHub Actions workflow — validate on PR, deploy on merge:
634
688
 
@@ -646,7 +700,7 @@ jobs:
646
700
  runs-on: ubuntu-latest
647
701
  env:
648
702
  TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
649
- TAILSCALE_TAILNET: your-tailnet.ts.net # or omit: defaults to the key's tailnet
703
+ TAILSCALE_TAILNET: T1234CNTRL # Tailnet ID from Settings > General; or omit: defaults to the key's tailnet
650
704
  steps:
651
705
  - uses: actions/checkout@v4
652
706
  - uses: actions/setup-node@v4
@@ -126,6 +126,17 @@ import { fileURLToPath } from "node:url";
126
126
  /** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
127
127
  const OAM_MIN = [0, 15, 2];
128
128
 
129
+ /**
130
+ * The oldest Node this package supports, matching package.json `engines.node`.
131
+ *
132
+ * `engines` is advisory: npm only warns, nothing here sets engine-strict, and
133
+ * an MCP client that spawns `node` never consults it at all -- so the floor
134
+ * this launcher already enforces for oam had no counterpart for the runtime it
135
+ * falls back to. src/node-floor.test.ts holds this array, package.json and the
136
+ * copy in src/index.ts to the same three numbers.
137
+ */
138
+ const NODE_MIN = [20, 11, 0];
139
+
129
140
  /**
130
141
  * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
131
142
  * the bound only exists so a wedged binary on PATH cannot hang the launch.
@@ -223,6 +234,30 @@ function oamVersion(cmd) {
223
234
  }
224
235
  }
225
236
 
237
+ /**
238
+ * The sub-floor-Node message, or null when this process may run the server.
239
+ *
240
+ * Gated on `versions.oam`: on the oam branch `versions.node` is absent or means
241
+ * something other than the runtime executing this file, and oam carries its own
242
+ * floor (OAM_MIN) a few lines down -- so only a real Node process is measured
243
+ * here. An unreadable version passes rather than refuses: a runtime that reports
244
+ * no version at all is not evidence of a sub-floor Node, and refusing would be a
245
+ * new way for the launcher to fail something that works today.
246
+ *
247
+ * Pure, and taking `versions` rather than reading `process` itself, so
248
+ * node-floor.test.ts can exercise it the way launcher.test.ts exercises
249
+ * sandboxFlags() and runtimePlan().
250
+ */
251
+ function nodeFloorFailure(versions) {
252
+ if (versions.oam !== undefined) return null;
253
+ const found = parseVersion(versions.node ?? "");
254
+ if (!found || atLeast(found, NODE_MIN)) return null;
255
+ return (
256
+ `tailscale-mcp: needs Node ${NODE_MIN.join(".")} or newer, found ${versions.node}.\n` +
257
+ `Install a newer Node (https://nodejs.org/en/download), or point your MCP client's "command" at one.\n`
258
+ );
259
+ }
260
+
226
261
  /** True when `v` is at least `min`, comparing major/minor/patch in order. */
227
262
  function atLeast(v, min) {
228
263
  if (!v) return false;
@@ -640,6 +675,16 @@ async function fallBack(hostOam, why) {
640
675
  await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}`);
641
676
  }
642
677
 
678
+ // Before anything else: a Node below the floor cannot be relied on to reach
679
+ // the runtime selection, let alone the server. Refusing here with the version
680
+ // it found beats whatever a post-20.11 API happens to throw three imports
681
+ // deep, and it costs one comparison on every launch.
682
+ const nodeFloorMessage = nodeFloorFailure(process.versions);
683
+ if (nodeFloorMessage) {
684
+ await errSync(nodeFloorMessage);
685
+ process.exit(1);
686
+ }
687
+
643
688
  // Every value below is compared against `mode` after lowercasing, so an
644
689
  // unrecognized one matched nothing and fell through to the auto branch --
645
690
  // `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,