@yawlabs/tailscale-mcp 0.15.0 → 0.17.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.** 89 admin-API tools + 4 optional local-CLI diagnostics + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ 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.** 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.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -31,7 +31,7 @@ If all you need is one endpoint in a CI job, use `curl` — we even have a [CLI
31
31
 
32
32
  Reasonable question. Both have their place. Where this MCP is better:
33
33
 
34
- - **Full 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.
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
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.
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.
@@ -54,7 +54,7 @@ Issues and PRs are triaged. File one if something is off — [github.com/YawLabs
54
54
 
55
55
  **1. Set your API key**
56
56
 
57
- Get an API key from [Tailscale Admin Console > Settings > Keys](https://login.tailscale.com/admin/settings/keys) and add it to your shell profile (`~/.bashrc`, `~/.zshrc`, or Windows system environment variables):
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):
58
58
 
59
59
  ```bash
60
60
  export TAILSCALE_API_KEY="tskey-api-..."
@@ -104,7 +104,7 @@ That's it. Now ask your agent:
104
104
 
105
105
  ## Too many tools? Subset them.
106
106
 
107
- 89 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
+ 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:
108
108
 
109
109
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
110
110
 
@@ -118,8 +118,8 @@ That's it. Now ask your agent:
118
118
  ```
119
119
 
120
120
  - **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
121
- - **`core`** (47 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
- - **`full`** (89 tools, default) — everything. Same as omitting the env var.
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.
123
123
 
124
124
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
125
125
 
@@ -181,12 +181,14 @@ Recommended pattern for mcph users: set `TAILSCALE_PROFILE=core` (or narrower) i
181
181
 
182
182
  **API key (simplest):** Set `TAILSCALE_API_KEY` in your shell or MCP config.
183
183
 
184
- **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://login.tailscale.com/admin/settings/oauth).
184
+ **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).
185
185
 
186
186
  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.
187
187
 
188
188
  **Tailnet:** Uses your default tailnet automatically. Set `TAILSCALE_TAILNET` to specify one explicitly.
189
189
 
190
+ **`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.
191
+
190
192
  ## Reliability and debugging
191
193
 
192
194
  **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.
@@ -206,13 +208,15 @@ The server checks for an API key first, then falls back to OAuth. If neither is
206
208
 
207
209
  **`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.
208
210
 
211
+ **`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.
212
+
209
213
  **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).
210
214
 
211
215
  ## Local CLI integration (opt-in)
212
216
 
213
217
  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.
214
218
 
215
- Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add four read-only diagnostic tools:
219
+ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add six read-only diagnostic tools:
216
220
 
217
221
  | Tool | Equivalent CLI command | Use it for |
218
222
  |---|---|---|
@@ -223,7 +227,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add fo
223
227
 
224
228
  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.
225
229
 
226
- When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (93 tools, local-cli=on)` — the 4 local CLI tools are additive on top of the default 89.
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.
227
231
 
228
232
  ## Resources (4)
229
233
 
@@ -236,7 +240,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
236
240
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
237
241
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
238
242
 
239
- ## Tools (89 + 4 opt-in)
243
+ ## Tools (94 + 6 opt-in)
240
244
 
241
245
  <details>
242
246
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -304,7 +308,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
304
308
  </details>
305
309
 
306
310
  <details>
307
- <summary><strong>Keys / Trust Credentials</strong> (5 tools) — covers auth keys, OAuth clients, and federated identities</summary>
311
+ <summary><strong>Keys / Trust Credentials</strong> (7 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
308
312
 
309
313
  | Tool | Description |
310
314
  |------|-------------|
@@ -313,6 +317,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
313
317
  | `tailscale_create_key` | Create an auth key, OAuth client (`keyType=client`), or federated identity (`keyType=federated`) |
314
318
  | `tailscale_delete_key` | Delete a key |
315
319
  | `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
320
+ | `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
321
+ | `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
316
322
 
317
323
  </details>
318
324
 
@@ -387,6 +393,23 @@ MCP Resources expose read-only data clients can browse without a tool call.
387
393
 
388
394
  </details>
389
395
 
396
+ <details>
397
+ <summary><strong>Organization Tailnets</strong> (3 tools) — API-only tailnets; OAuth authentication required</summary>
398
+
399
+ Create and tear down whole tailnets programmatically — useful for per-agent sandboxes,
400
+ per-tenant isolation, and ephemeral CI environments. Unlike every other group here these
401
+ endpoints live under `/organizations`, authenticate **only** with an OAuth client (the
402
+ `tailnets` scope to create, `all` to then reach the tailnet), and produce tailnets that are
403
+ not managed in the admin console. Set `TAILSCALE_OAUTH_TAILNET` to operate on one.
404
+
405
+ | Tool | Description |
406
+ |------|-------------|
407
+ | `tailscale_list_org_tailnets` | List the organization's tailnets (paginated via `limit` / `cursor`) |
408
+ | `tailscale_create_org_tailnet` | Create an API-only tailnet; returns its OAuth client secret **once** |
409
+ | `tailscale_delete_tailnet` | Delete a tailnet (the configured one, or an explicit `tailnet`) — irreversible; requires `confirmTailnet` to match |
410
+
411
+ </details>
412
+
390
413
  <details>
391
414
  <summary><strong>Log Streaming</strong> (7 tools)</summary>
392
415
 
@@ -440,7 +463,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
440
463
  </details>
441
464
 
442
465
  <details>
443
- <summary><strong>Local CLI</strong> (4 tools, opt-in) — see <a href="#local-cli-integration-opt-in">Local CLI integration</a></summary>
466
+ <summary><strong>Local CLI</strong> (6 tools, opt-in) — see <a href="#local-cli-integration-opt-in">Local CLI integration</a></summary>
444
467
 
445
468
  | Tool | Description |
446
469
  |------|-------------|
@@ -448,6 +471,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
448
471
  | `tailscale_ping` | Latency probe to another tailnet node from this machine |
449
472
  | `tailscale_netcheck` | NAT type, DERP latency map, IPv4/IPv6 support diagnostics |
450
473
  | `tailscale_local_version` | Local `tailscale` binary version |
474
+ | `tailscale_local_whoami` | Which user and device this machine is authenticated as (needs tailscale >= 1.102.1) |
475
+ | `tailscale_local_service_list` | Tailscale Services visible to *this* node (needs tailscale >= 1.102.1) |
451
476
 
452
477
  </details>
453
478
 
@@ -511,7 +536,15 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
511
536
 
512
537
  ## Running on oam.js (optional)
513
538
 
514
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 89 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.
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.
540
+
541
+ **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
+
543
+ ### Sandboxing (opt-in)
544
+
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.
546
+
547
+ 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.
515
548
 
516
549
  ```jsonc
517
550
  {
@@ -22,19 +22,46 @@
22
22
  * For an MCP host config, point straight at oam and skip this file:
23
23
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
24
  *
25
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
+ * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
27
+ * network limited to the control-plane hosts, filesystem denied.
28
+ *
29
+ * Child-process is granted unconditionally because the local-CLI tools shell out
30
+ * to the `tailscale` binary; that is also why PATH stays in the env grant, since
31
+ * resolving the binary needs it.
32
+ *
33
+ * Opt-in, not default, because a denied environment variable is ABSENT from
34
+ * process.env rather than throwing -- an under-granted TAILSCALE_API_KEY reads as
35
+ * "unauthenticated" rather than "denied". The env list is derived from the
36
+ * shipped bundle; keep it in step.
37
+ *
38
+ * MINIMUM OAM VERSION
39
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
40
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
41
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
42
+ * behaved as `'pipe'`. This server shells out to a CLI on its
43
+ * main paths, so those were reachable bugs rather than theoretical ones: an
44
+ * argument containing shell metacharacters was re-split and executed.
45
+ * An older oam is not an error: the launcher falls back to Node and says so on
46
+ * stderr. Pinning the floor here is what makes that fallback automatic.
47
+ *
25
48
  * SELECTION
26
49
  * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
50
  * TAILSCALE_MCP_RUNTIME=node never use oam
28
51
  * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
52
+ * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
29
53
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
54
  */
31
55
 
32
- import { spawn } from "node:child_process";
56
+ import { execFileSync, spawn } from "node:child_process";
33
57
  import { existsSync } from "node:fs";
34
58
  import { constants, homedir } from "node:os";
35
59
  import { delimiter, join } from "node:path";
36
60
  import { fileURLToPath } from "node:url";
37
61
 
62
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
63
+ const OAM_MIN = [0, 9, 0];
64
+
38
65
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
66
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
67
  // in-process fallback must use the file:// URL. spawn() needs a real path.
@@ -83,6 +110,66 @@ function findOam() {
83
110
  return null;
84
111
  }
85
112
 
113
+ /**
114
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
115
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
116
+ */
117
+ function oamVersion(cmd) {
118
+ try {
119
+ const out = execFileSync(cmd, ["--version"], {
120
+ encoding: "utf-8",
121
+ stdio: ["ignore", "pipe", "ignore"],
122
+ });
123
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
124
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
125
+ } catch {
126
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
132
+ function atLeast(v, min) {
133
+ if (!v) return false;
134
+ for (let i = 0; i < min.length; i++) {
135
+ if (v[i] > min[i]) return true;
136
+ if (v[i] < min[i]) return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * The `--permission` grant list, or [] when the sandbox is not requested.
143
+ *
144
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
145
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
146
+ * good failure but only because it is loud -- ordering here is load-bearing.
147
+ *
148
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
149
+ * A denied environment variable is ABSENT from process.env rather than throwing,
150
+ * so the env list below is derived from what the bundle actually reads; trimming
151
+ * it produces silent misbehaviour, not a clear denial.
152
+ */
153
+ function sandboxFlags() {
154
+ if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
155
+
156
+ const hosts = ["api.tailscale.com","login.tailscale.com"];
157
+
158
+ const netFlag = `--allow-net=${hosts.join(",")}`;
159
+
160
+ // Keep alphabetised and in sync with every env var the bundle reads -- a
161
+ // missing entry is ABSENT from process.env rather than an error, so the
162
+ // symptom is silent misbehaviour. TAILSCALE_LOCAL_CLI was missing here, which
163
+ // meant the local-CLI tool group silently failed to register under the
164
+ // sandbox even though --allow-child-process is granted below precisely so
165
+ // those tools can shell out.
166
+ 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"];
167
+
168
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
169
+ flags.push("--allow-child-process");
170
+ return flags;
171
+ }
172
+
86
173
  /** Run the server in THIS process. The zero-overhead fallback. */
87
174
  async function runInProcess() {
88
175
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -120,10 +207,29 @@ if (mode === "node") {
120
207
  process.exit(1);
121
208
  }
122
209
  await runInProcess();
210
+ } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
211
+ // Discovery itself stays stat-only; this is the first subprocess, and it
212
+ // runs only once we have already decided to spawn oam anyway. Measured 26ms
213
+ // median (n=12, windows-arm64), paid once per MCP session.
214
+ const min = OAM_MIN.join(".");
215
+ if (mode === "oam") {
216
+ const { writeSync } = await import("node:fs");
217
+ writeSync(
218
+ 2,
219
+ `tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
220
+ `Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n`,
221
+ );
222
+ process.exit(1);
223
+ }
224
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
225
+ // a silent downgrade is how someone keeps running an oam they meant to
226
+ // update. stderr is safe -- MCP frames travel on stdout.
227
+ process.stderr.write(`tailscale-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
228
+ await runInProcess();
123
229
  } else {
124
230
  // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
125
231
  // --version` and any host-supplied flags survive the hop unchanged.
126
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
232
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
127
233
  // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
128
234
  // stdin/stdout is untouched and the host's stdin-close still reaches the
129
235
  // server's shutdown path.
package/dist/index.js CHANGED
@@ -30993,6 +30993,10 @@ function getAuthConfig() {
30993
30993
  `No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
30994
30994
  );
30995
30995
  }
30996
+ function getOAuthTailnet() {
30997
+ const raw = process.env.TAILSCALE_OAUTH_TAILNET?.trim();
30998
+ return raw ? raw : void 0;
30999
+ }
30996
31000
  async function getOAuthAccessToken(clientId, clientSecret) {
30997
31001
  if (oauthToken && Date.now() < oauthToken.expires_at - 6e4) {
30998
31002
  return oauthToken.access_token;
@@ -31002,7 +31006,9 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31002
31006
  }
31003
31007
  oauthRefreshPromise = (async () => {
31004
31008
  try {
31005
- const res = await fetch("https://api.tailscale.com/api/v2/oauth/token", {
31009
+ const oauthTailnet = getOAuthTailnet();
31010
+ const tokenUrl = oauthTailnet ? `https://api.tailscale.com/api/v2/oauth/token?tailnet=${encodeURIComponent(oauthTailnet)}` : "https://api.tailscale.com/api/v2/oauth/token";
31011
+ const res = await fetch(tokenUrl, {
31006
31012
  method: "POST",
31007
31013
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
31008
31014
  body: new URLSearchParams({
@@ -31014,7 +31020,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31014
31020
  });
31015
31021
  if (!res.ok) {
31016
31022
  const body = await res.text();
31017
- const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://login.tailscale.com/admin/settings/oauth)." : "";
31023
+ const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://console.tailscale.com/admin/settings/oauth)." + (oauthTailnet ? ` Targeting tailnet "${oauthTailnet}" via TAILSCALE_OAUTH_TAILNET -- that requires an OAuth client from the CREATING tailnet with the 'all' scope.` : "") : "";
31018
31024
  throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
31019
31025
  }
31020
31026
  const data = await res.json();
@@ -31080,7 +31086,7 @@ function formatAuthError(status, apiBody) {
31080
31086
  " 2. Set TAILSCALE_API_KEY as a Windows user environment variable (System Properties > Environment Variables)"
31081
31087
  );
31082
31088
  }
31083
- const link = status === 401 ? "Generate a new key at: https://login.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the OAuth client scopes at: https://login.tailscale.com/admin/settings/oauth" : "Adjust the API key permissions at: https://login.tailscale.com/admin/settings/keys";
31089
+ const link = status === 401 ? "Generate a new key at: https://console.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the OAuth client scopes at: https://console.tailscale.com/admin/settings/oauth" : "Adjust the API key permissions at: https://console.tailscale.com/admin/settings/keys";
31084
31090
  lines.push("", link);
31085
31091
  if (apiBody) {
31086
31092
  lines.push("", `API response: ${apiBody}`);
@@ -32605,6 +32611,58 @@ var keyTools = [
32605
32611
  }
32606
32612
  return apiPut(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`, body);
32607
32613
  }
32614
+ },
32615
+ // --- OAuth Apps (device provisioning) ---
32616
+ //
32617
+ // ALPHA upstream. Distinct from the OAuth *clients* handled by
32618
+ // tailscale_create_key above: an OAuth client is a machine credential you
32619
+ // hold, whereas an OAuth App is a three-legged authorization-code app that
32620
+ // lets a THIRD PARTY enroll one device into your tailnet after a user
32621
+ // consents. The only scope it takes today is `auth_keys:create:once`, which
32622
+ // mints exactly one auth key per authorization and returns no refresh token
32623
+ // -- re-authorization is required per device by design.
32624
+ {
32625
+ name: "tailscale_create_oauth_app",
32626
+ description: "Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nThe supported scope is 'auth_keys:create:once' (one auth key per authorization, no refresh token). Distinct from tailscale_create_key with keyType='client', which mints a machine-to-machine OAuth client instead.",
32627
+ annotations: {
32628
+ title: "Create OAuth app",
32629
+ readOnlyHint: false,
32630
+ destructiveHint: false,
32631
+ idempotentHint: false,
32632
+ openWorldHint: true
32633
+ },
32634
+ inputSchema: external_exports.object({
32635
+ name: external_exports.string().min(1).describe("Human-readable name for the OAuth app, shown on the consent screen"),
32636
+ redirectUris: external_exports.array(external_exports.url()).min(1).describe("Allowed redirect URIs for the authorization-code flow (e.g. ['https://example.com/callback'])"),
32637
+ scopes: external_exports.array(external_exports.string()).min(1).describe("Scopes to grant. Currently 'auth_keys:create:once' is the supported value."),
32638
+ allowedNodeAttributes: external_exports.array(external_exports.string()).optional().describe("Optional node attributes the app may request when provisioning a device")
32639
+ }),
32640
+ handler: async (input) => {
32641
+ const body = {
32642
+ name: input.name,
32643
+ redirectUris: input.redirectUris,
32644
+ scopes: input.scopes
32645
+ };
32646
+ if (input.allowedNodeAttributes !== void 0) body.allowedNodeAttributes = input.allowedNodeAttributes;
32647
+ return apiPost(`/tailnet/${getTailnet()}/oauth-apps`, body);
32648
+ }
32649
+ },
32650
+ {
32651
+ name: "tailscale_get_oauth_app",
32652
+ description: "Get an OAuth App's configuration (name, redirect URIs, scopes) by its app ID. Use this to verify an app was registered as intended. The client secret is not returned -- it is only available at creation time.",
32653
+ annotations: {
32654
+ title: "Get OAuth app",
32655
+ readOnlyHint: true,
32656
+ destructiveHint: false,
32657
+ idempotentHint: true,
32658
+ openWorldHint: true
32659
+ },
32660
+ inputSchema: external_exports.object({
32661
+ appId: external_exports.string().min(1).describe("The OAuth app ID returned by tailscale_create_oauth_app")
32662
+ }),
32663
+ handler: async (input) => {
32664
+ return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32665
+ }
32608
32666
  }
32609
32667
  ];
32610
32668
 
@@ -32753,6 +32811,39 @@ var localCliTools = [
32753
32811
  },
32754
32812
  inputSchema: external_exports.object({}),
32755
32813
  handler: async () => runTailscaleCli(["version"])
32814
+ },
32815
+ // The two tools below need a tailscale client >= 1.102.1 (both subcommands
32816
+ // landed in that release). On an older binary the CLI exits non-zero with
32817
+ // "unknown subcommand", which runTailscaleCli surfaces verbatim as the error
32818
+ // -- an accurate, self-explaining failure, so there is no version pre-check
32819
+ // here. Deliberately NOT passing --json: neither subcommand's flag support is
32820
+ // verified across versions, and an unsupported flag would turn a working text
32821
+ // response into a hard failure. Text output is the safe contract.
32822
+ {
32823
+ name: "tailscale_local_whoami",
32824
+ description: "Show which Tailscale user and device this machine is currently authenticated as. Useful for confirming an agent host is enrolled under the identity you expect before trusting its access. Requires tailscale >= 1.102.1; returns the CLI's text output verbatim.",
32825
+ annotations: {
32826
+ title: "Tailscale whoami",
32827
+ readOnlyHint: true,
32828
+ destructiveHint: false,
32829
+ idempotentHint: true,
32830
+ openWorldHint: true
32831
+ },
32832
+ inputSchema: external_exports.object({}),
32833
+ handler: async () => runTailscaleCli(["whoami"])
32834
+ },
32835
+ {
32836
+ name: "tailscale_local_service_list",
32837
+ description: "List the Tailscale Services visible to THIS node. Complements the admin-side service tools (tailscale_list_services etc.), which report what the tailnet has configured -- this reports what this machine can actually see and reach, which is the difference that matters when debugging a service that 'exists' but is unreachable. Requires tailscale >= 1.102.1; returns the CLI's text output verbatim.",
32838
+ annotations: {
32839
+ title: "Tailscale service list (local)",
32840
+ readOnlyHint: true,
32841
+ destructiveHint: false,
32842
+ idempotentHint: true,
32843
+ openWorldHint: true
32844
+ },
32845
+ inputSchema: external_exports.object({}),
32846
+ handler: async () => runTailscaleCli(["service", "list"])
32756
32847
  }
32757
32848
  ];
32758
32849
 
@@ -32964,6 +33055,30 @@ var logStreamingTools = [
32964
33055
  ];
32965
33056
 
32966
33057
  // src/tools/posture.ts
33058
+ var STATIC_POSTURE_PROVIDERS = [
33059
+ "falcon",
33060
+ "fleet",
33061
+ "huntress",
33062
+ "intune",
33063
+ "jamfpro",
33064
+ "kandji",
33065
+ "kolide",
33066
+ "sentinelone"
33067
+ ];
33068
+ function getAllowedPostureProviders() {
33069
+ const raw = process.env.TAILSCALE_EXTRA_POSTURE_PROVIDERS;
33070
+ if (!raw) return new Set(STATIC_POSTURE_PROVIDERS);
33071
+ const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
33072
+ return /* @__PURE__ */ new Set([...STATIC_POSTURE_PROVIDERS, ...extras]);
33073
+ }
33074
+ var postureProviderSchema = external_exports.string().superRefine((value, ctx) => {
33075
+ const allowed = getAllowedPostureProviders();
33076
+ if (allowed.has(value)) return;
33077
+ ctx.addIssue({
33078
+ code: "custom",
33079
+ message: `Unknown posture provider ${JSON.stringify(value)}. Known providers: ${[...allowed].sort().join(", ")}. To allow a provider Tailscale has shipped before this package updates, set TAILSCALE_EXTRA_POSTURE_PROVIDERS=providerA,providerB in your MCP config.`
33080
+ });
33081
+ }).meta({ enum: [...getAllowedPostureProviders()].sort() });
32967
33082
  var postureTools = [
32968
33083
  {
32969
33084
  name: "tailscale_list_posture_integrations",
@@ -33008,9 +33123,11 @@ var postureTools = [
33008
33123
  openWorldHint: true
33009
33124
  },
33010
33125
  inputSchema: external_exports.object({
33011
- provider: external_exports.enum(["falcon", "intune", "jamfpro", "kandji", "kolide", "sentinelone"]).describe("The posture provider"),
33126
+ provider: postureProviderSchema.describe(
33127
+ "The posture provider slug: falcon (CrowdStrike Falcon), fleet, huntress, intune (Microsoft Intune), jamfpro (Jamf Pro), kandji (Iru, formerly Kandji), kolide (1Password XAM, formerly Kolide), sentinelone"
33128
+ ),
33012
33129
  clientId: external_exports.string().optional().describe(
33013
- "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank)"
33130
+ "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Fleet/Huntress/Kandji/Kolide/Sentinel One: leave blank)"
33014
33131
  ),
33015
33132
  clientSecret: external_exports.string().describe(
33016
33133
  "The secret (auth key, token, etc.) used to authenticate with the provider. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
@@ -33403,6 +33520,99 @@ var tailnetTools = [
33403
33520
  }
33404
33521
  ];
33405
33522
 
33523
+ // src/tools/tailnets.ts
33524
+ var organizationSchema = external_exports.string().trim().min(1).optional().describe("Organization ID. Defaults to '-' (the organization owning the calling credentials).");
33525
+ var tailnetsTools = [
33526
+ {
33527
+ name: "tailscale_list_org_tailnets",
33528
+ description: "List the tailnets in your organization, including API-only tailnets created via the API. Paginated: returns at most `limit` results (Tailscale defaults to 100) plus a `cursor`. Pass that cursor back to fetch the next page; an empty cursor in the response means you have reached the end. Requires OAuth authentication.",
33529
+ annotations: {
33530
+ title: "List organization tailnets",
33531
+ readOnlyHint: true,
33532
+ destructiveHint: false,
33533
+ idempotentHint: true,
33534
+ openWorldHint: true
33535
+ },
33536
+ inputSchema: external_exports.object({
33537
+ organization: organizationSchema,
33538
+ // No client-side ceiling: Tailscale's documented maximum for `limit` is
33539
+ // unknown, so an invented cap would either reject values the API accepts
33540
+ // or wave through ones it does not. Let the API be the authority and
33541
+ // surface its 400 verbatim.
33542
+ limit: external_exports.number().int().positive().optional().describe("Max tailnets to return in this page. Omit to use Tailscale's default of 100."),
33543
+ cursor: external_exports.string().optional().describe("Pagination cursor from a previous response. Omit for the first page.")
33544
+ }),
33545
+ handler: async (input) => {
33546
+ const params = new URLSearchParams();
33547
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
33548
+ if (input.cursor !== void 0) params.set("cursor", input.cursor);
33549
+ const qs = params.toString();
33550
+ const org = encPath(input.organization ?? "-");
33551
+ return apiGet(`/organizations/${org}/tailnets${qs ? `?${qs}` : ""}`);
33552
+ }
33553
+ },
33554
+ {
33555
+ name: "tailscale_create_org_tailnet",
33556
+ description: "Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.\n\nSECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nRequires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.",
33557
+ annotations: {
33558
+ title: "Create organization tailnet",
33559
+ readOnlyHint: false,
33560
+ destructiveHint: false,
33561
+ // Each call creates a distinct tailnet; there is no idempotency key.
33562
+ idempotentHint: false,
33563
+ openWorldHint: true
33564
+ },
33565
+ inputSchema: external_exports.object({
33566
+ displayName: external_exports.string().trim().min(1).describe("Human-readable name for the new tailnet"),
33567
+ organization: organizationSchema
33568
+ }),
33569
+ handler: async (input) => {
33570
+ const org = encPath(input.organization ?? "-");
33571
+ return apiPost(`/organizations/${org}/tailnets`, { displayName: input.displayName });
33572
+ }
33573
+ },
33574
+ {
33575
+ name: "tailscale_delete_tailnet",
33576
+ description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33577
+ annotations: {
33578
+ title: "Delete tailnet",
33579
+ readOnlyHint: false,
33580
+ destructiveHint: true,
33581
+ idempotentHint: true,
33582
+ openWorldHint: true
33583
+ },
33584
+ inputSchema: external_exports.object({
33585
+ tailnet: external_exports.string().trim().min(1).optional().describe(
33586
+ "Tailnet to delete (e.g. an id from tailscale_list_org_tailnets). Omit to target the configured tailnet. Requires credentials scoped to reach it."
33587
+ ),
33588
+ confirmTailnet: external_exports.string().trim().min(1).describe(
33589
+ "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A deliberate second look before an irreversible org-wide delete."
33590
+ )
33591
+ }),
33592
+ // `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
33593
+ // " ", which then trims to "" in the handler, goes falsy, and silently falls
33594
+ // back to the CONFIGURED tailnet. The confirm guard still held, so it was
33595
+ // never a wrong-target delete -- but "delete the tailnet I named" quietly
33596
+ // becoming "delete the default one" is the wrong shape for an irreversible
33597
+ // operation. Trimming at the schema turns it into a validation error.
33598
+ handler: async (input) => {
33599
+ const configured = process.env.TAILSCALE_OAUTH_TAILNET?.trim() || getTailnet();
33600
+ const target = input.tailnet?.trim() || configured;
33601
+ if (target === "-") {
33602
+ throw new Error(
33603
+ "Refusing to delete: the tailnet resolves to '-' (the default self-reference), so there is nothing specific to confirm against. Set TAILSCALE_TAILNET (or TAILSCALE_OAUTH_TAILNET) to the tailnet's explicit name or id first."
33604
+ );
33605
+ }
33606
+ if (input.confirmTailnet !== target) {
33607
+ throw new Error(
33608
+ `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the configured tailnet ${JSON.stringify(target)}. Refusing to delete.`
33609
+ );
33610
+ }
33611
+ return apiDelete(`/tailnet/${encPath(target)}`);
33612
+ }
33613
+ }
33614
+ ];
33615
+
33406
33616
  // src/tools/users.ts
33407
33617
  var userTools = [
33408
33618
  {
@@ -33560,7 +33770,7 @@ function getAllowedWebhookEvents() {
33560
33770
  return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
33561
33771
  }
33562
33772
  var endpointUrlSchema = external_exports.url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
33563
- var webhookSubscriptionsSchema = external_exports.array(external_exports.string()).min(1).superRefine((arr, ctx) => {
33773
+ var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
33564
33774
  const allowed = getAllowedWebhookEvents();
33565
33775
  let knownEventsList = null;
33566
33776
  for (let i = 0; i < arr.length; i++) {
@@ -33726,6 +33936,12 @@ function buildToolGroups(env) {
33726
33936
  keys: keyTools,
33727
33937
  users: userTools,
33728
33938
  tailnet: tailnetTools,
33939
+ // Named "org-tailnets", NOT "tailnets": a group called "tailnets" sits one
33940
+ // character from the existing "tailnet" group (settings/contacts), and
33941
+ // TAILSCALE_TOOLS matches names exactly with no near-miss warning. An
33942
+ // operator who typo'd one would silently be handed the other -- and this
33943
+ // group contains an irreversible whole-tailnet delete.
33944
+ "org-tailnets": tailnetsTools,
33729
33945
  webhooks: webhookTools,
33730
33946
  posture: postureTools,
33731
33947
  audit: auditTools,
@@ -33738,6 +33954,13 @@ function buildToolGroups(env) {
33738
33954
  }
33739
33955
  return toolGroups;
33740
33956
  }
33957
+ function formatTailnetMismatchWarning(env) {
33958
+ const oauthTailnet = env.TAILSCALE_OAUTH_TAILNET?.trim();
33959
+ if (!oauthTailnet) return null;
33960
+ const explicit = env.TAILSCALE_TAILNET?.trim();
33961
+ if (!explicit || explicit === "-" || explicit === oauthTailnet) return null;
33962
+ return `TAILSCALE_OAUTH_TAILNET="${oauthTailnet}" but TAILSCALE_TAILNET="${explicit}". The OAuth token will be scoped to the former while tool requests are addressed to the latter, so every tailnet-scoped tool will fail with HTTP 403. Either unset TAILSCALE_TAILNET (or set it to "-") to follow the token, or set both to the same tailnet.`;
33963
+ }
33741
33964
  function formatBannerFilterSuffix(inputs) {
33742
33965
  const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
33743
33966
  const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
@@ -33824,7 +34047,7 @@ async function tailnetDnsResource(uri) {
33824
34047
  }
33825
34048
 
33826
34049
  // src/index.ts
33827
- var version2 = true ? "0.15.0" : resolveVersionFallback();
34050
+ var version2 = true ? "0.17.0" : resolveVersionFallback();
33828
34051
  var subcommand = process.argv[2];
33829
34052
  var cliSubcommandHandled = false;
33830
34053
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -33875,6 +34098,10 @@ if (!cliSubcommandHandled) {
33875
34098
  `@yawlabs/tailscale-mcp: internal inconsistency -- TAILSCALE_PROFILE="${process.env.TAILSCALE_PROFILE}" references group(s) that are not registered: ${unknownProfileGroups.join(", ")}. Those groups contributed no tools. This is a bug in @yawlabs/tailscale-mcp, not your configuration -- please report it at https://github.com/YawLabs/tailscale-mcp/issues.`
33876
34099
  );
33877
34100
  }
34101
+ const tailnetMismatch = formatTailnetMismatchWarning(process.env);
34102
+ if (tailnetMismatch) {
34103
+ console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
34104
+ }
33878
34105
  if (unknownProfile) {
33879
34106
  console.error(
33880
34107
  `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",