@yawlabs/tailscale-mcp 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![Release](https://img.shields.io/badge/release-local-blue)](./release.sh)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 94 admin-API tools + 6 optional local-CLI diagnostics + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 96 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
 
@@ -104,7 +104,7 @@ That's it. Now ask your agent:
104
104
 
105
105
  ## Too many tools? Subset them.
106
106
 
107
- 94 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
107
+ 96 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`** (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.
121
+ - **`core`** (51 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
122
+ - **`full`** (96 tools, default) — everything. Same as omitting the env var.
123
123
 
124
124
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
125
125
 
@@ -227,7 +227,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add si
227
227
 
228
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.
229
229
 
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.
230
+ When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (102 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 96.
231
231
 
232
232
  ## Resources (4)
233
233
 
@@ -240,7 +240,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
240
240
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
241
241
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
242
242
 
243
- ## Tools (94 + 6 opt-in)
243
+ ## Tools (96 + 6 opt-in)
244
244
 
245
245
  <details>
246
246
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -308,7 +308,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
308
308
  </details>
309
309
 
310
310
  <details>
311
- <summary><strong>Keys / Trust Credentials</strong> (7 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
311
+ <summary><strong>Keys / Trust Credentials</strong> (9 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
312
312
 
313
313
  | Tool | Description |
314
314
  |------|-------------|
@@ -319,6 +319,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
319
319
  | `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
320
320
  | `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
321
321
  | `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
322
+ | `tailscale_list_oauth_apps` | List every OAuth App registered in the tailnet |
323
+ | `tailscale_delete_oauth_app` | Delete an OAuth App, revoking its ability to provision devices |
322
324
 
323
325
  </details>
324
326
 
@@ -536,13 +538,13 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
536
538
 
537
539
  ## Running on oam.js (optional)
538
540
 
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.
541
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 96 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
542
 
541
543
  **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
544
 
543
545
  ### Sandboxing (opt-in)
544
546
 
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.
547
+ Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` -- the only host the bundle contacts, including the OAuth token exchange -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
546
548
 
547
549
  It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `TAILSCALE_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
548
550
 
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
26
  * `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
27
- * network limited to the control-plane hosts, filesystem denied.
27
+ * network limited to the one host the bundle actually calls
28
+ * (api.tailscale.com), filesystem denied.
28
29
  *
29
30
  * Child-process is granted unconditionally because the local-CLI tools shell out
30
31
  * to the `tailscale` binary; that is also why PATH stays in the env grant, since
@@ -49,6 +50,7 @@
49
50
  * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
50
51
  * TAILSCALE_MCP_RUNTIME=node never use oam
51
52
  * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
53
+ * anything else warns on stderr, then behaves as auto
52
54
  * TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
53
55
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
54
56
  */
@@ -156,7 +158,16 @@ function atLeast(v, min) {
156
158
  function sandboxFlags() {
157
159
  if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
158
160
 
159
- const hosts = ["api.tailscale.com","login.tailscale.com"];
161
+ // ONE host, deliberately. Every outbound request the bundle makes targets
162
+ // api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
163
+ // allow-list that refuses to send credentials anywhere else. login.tailscale.com
164
+ // was granted here as well until an audit found no code path that contacts it
165
+ // -- console.tailscale.com appears in error TEXT, never as a request target.
166
+ // An unused grant is the one kind of over-permission nothing ever surfaces:
167
+ // removing it cannot break a call that was never made, and keeping it widens
168
+ // the sandbox for no behaviour. launcher.test.ts pins this list exactly so a
169
+ // future host lands as a reviewed diff rather than a quiet widening.
170
+ const hosts = ["api.tailscale.com"];
160
171
 
161
172
  const netFlag = `--allow-net=${hosts.join(",")}`;
162
173
 
@@ -166,7 +177,25 @@ function sandboxFlags() {
166
177
  // meant the local-CLI tool group silently failed to register under the
167
178
  // sandbox even though --allow-child-process is granted below precisely so
168
179
  // those tools can shell out.
169
- const env = ["PATH","TAILSCALE_API_KEY","TAILSCALE_BINARY","TAILSCALE_DEBUG","TAILSCALE_EXTRA_POSTURE_PROVIDERS","TAILSCALE_EXTRA_WEBHOOK_EVENTS","TAILSCALE_LOCAL_CLI","TAILSCALE_MAX_CONCURRENT","TAILSCALE_OAUTH_CLIENT_ID","TAILSCALE_OAUTH_CLIENT_SECRET","TAILSCALE_OAUTH_TAILNET","TAILSCALE_PROFILE","TAILSCALE_READONLY","TAILSCALE_REQUEST_BUDGET_MS","TAILSCALE_RETRY_BASE_DELAY_MS","TAILSCALE_TAILNET","TAILSCALE_TOOLS"];
180
+ const env = [
181
+ "PATH",
182
+ "TAILSCALE_API_KEY",
183
+ "TAILSCALE_BINARY",
184
+ "TAILSCALE_DEBUG",
185
+ "TAILSCALE_EXTRA_POSTURE_PROVIDERS",
186
+ "TAILSCALE_EXTRA_WEBHOOK_EVENTS",
187
+ "TAILSCALE_LOCAL_CLI",
188
+ "TAILSCALE_MAX_CONCURRENT",
189
+ "TAILSCALE_OAUTH_CLIENT_ID",
190
+ "TAILSCALE_OAUTH_CLIENT_SECRET",
191
+ "TAILSCALE_OAUTH_TAILNET",
192
+ "TAILSCALE_PROFILE",
193
+ "TAILSCALE_READONLY",
194
+ "TAILSCALE_REQUEST_BUDGET_MS",
195
+ "TAILSCALE_RETRY_BASE_DELAY_MS",
196
+ "TAILSCALE_TAILNET",
197
+ "TAILSCALE_TOOLS",
198
+ ];
170
199
 
171
200
  const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
172
201
  flags.push("--allow-child-process");
@@ -231,7 +260,23 @@ async function runInProcess() {
231
260
  await import(SERVER_URL.href);
232
261
  }
233
262
 
234
- const mode = (process.env.TAILSCALE_MCP_RUNTIME ?? "auto").toLowerCase();
263
+ // Every value below is compared against `mode` after lowercasing, so an
264
+ // unrecognized one matched nothing and fell through to the auto branch --
265
+ // `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
266
+ // which is the opposite of what was asked for. Same handling as index.ts gives
267
+ // an unknown subcommand: auto is still the right landing place, it just stops
268
+ // being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
269
+ // wrapper script is how it usually gets there.
270
+ const RUNTIMES = ["auto", "node", "oam"];
271
+ const requested = process.env.TAILSCALE_MCP_RUNTIME;
272
+ const mode = (requested ?? "auto").toLowerCase();
273
+ if (requested && !RUNTIMES.includes(mode)) {
274
+ // Echo what was SET, not the lowercased form, so the typo is recognisable in
275
+ // the host's log next to the config line that produced it.
276
+ await errSync(
277
+ `tailscale-mcp: unrecognized TAILSCALE_MCP_RUNTIME "${requested}" -- known values: ${RUNTIMES.join(", ")}. Using auto.\n`,
278
+ );
279
+ }
235
280
 
236
281
  if (mode === "node") {
237
282
  await runInProcess();
@@ -259,7 +304,8 @@ if (mode === "node") {
259
304
  const { writeSync } = await import("node:fs");
260
305
  writeSync(
261
306
  2,
262
- "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
307
+ "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
308
+ shimNote +
263
309
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
264
310
  );
265
311
  process.exit(1);
@@ -280,7 +326,7 @@ if (mode === "node") {
280
326
  ? `${oam} is oam ${found.join(".")}, older than ${min}`
281
327
  : `${oam} could not be run, or did not report a version this launcher understands`;
282
328
  const remedy = found
283
- ? "Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n"
329
+ ? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
284
330
  : "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
285
331
  if (mode === "oam") {
286
332
  await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
@@ -337,7 +383,6 @@ if (mode === "node") {
337
383
  }
338
384
 
339
385
  if (child) {
340
-
341
386
  // If oam cannot be executed at all (deleted between the stat and the spawn,
342
387
  // wrong arch, permission), fall back rather than failing the whole server.
343
388
  // `spawned` prevents falling back AFTER the child started, which would
package/dist/index.js CHANGED
@@ -31035,6 +31035,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
31035
31035
  })();
31036
31036
  return oauthRefreshPromise;
31037
31037
  }
31038
+ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
31039
+ if (!oauthToken) return;
31040
+ if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
31041
+ oauthToken = null;
31042
+ }
31038
31043
  async function getAuthHeader() {
31039
31044
  const config2 = getAuthConfig();
31040
31045
  if (config2.kind === "apiKey") {
@@ -31130,14 +31135,17 @@ function getRetryBaseDelayMs() {
31130
31135
  }
31131
31136
  async function withConcurrencyLimit(fn) {
31132
31137
  const limit = getConcurrencyLimit();
31133
- if (limit === 0) return fn();
31138
+ if (limit === 0) return fn(0);
31139
+ let queuedForMs = 0;
31134
31140
  if (inFlight >= limit) {
31141
+ const queueStartedAt = Date.now();
31135
31142
  await new Promise((resolve) => concurrencyQueue.push(resolve));
31143
+ queuedForMs = Date.now() - queueStartedAt;
31136
31144
  } else {
31137
31145
  inFlight++;
31138
31146
  }
31139
31147
  try {
31140
- return await fn();
31148
+ return await fn(queuedForMs);
31141
31149
  } finally {
31142
31150
  const next = concurrencyQueue.shift();
31143
31151
  if (next) {
@@ -31188,6 +31196,15 @@ function describeTransportError(err, method, attemptTimeoutMs) {
31188
31196
  }
31189
31197
  return `${method} request failed: ${String(err)}`;
31190
31198
  }
31199
+ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
31200
+ if (lastTransportError) {
31201
+ return `${lastTransportError}; request budget of ${budgetMs}ms exhausted before next attempt could begin.`;
31202
+ }
31203
+ if (queuedForMs > 0) {
31204
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin: ${queuedForMs}ms of it was spent waiting for a free slot under TAILSCALE_MAX_CONCURRENT, so no request was ever sent. Raise TAILSCALE_MAX_CONCURRENT or TAILSCALE_REQUEST_BUDGET_MS -- this is queueing, not a network fault.`;
31205
+ }
31206
+ return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
31207
+ }
31191
31208
  async function apiRequest(method, path, body, options) {
31192
31209
  const headers = {};
31193
31210
  if (options?.accept) {
@@ -31217,7 +31234,7 @@ async function apiRequest(method, path, body, options) {
31217
31234
  debugLog(`${method} ${url2}`);
31218
31235
  const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
31219
31236
  const requestBudgetMs = getRequestBudgetMs();
31220
- return withConcurrencyLimit(async () => {
31237
+ return withConcurrencyLimit(async (queuedForMs) => {
31221
31238
  headers.Authorization = await getAuthHeader();
31222
31239
  let res;
31223
31240
  let lastTransportError;
@@ -31227,7 +31244,7 @@ async function apiRequest(method, path, body, options) {
31227
31244
  return {
31228
31245
  ok: false,
31229
31246
  status: 0,
31230
- error: lastTransportError ? `${lastTransportError}; request budget of ${requestBudgetMs}ms exhausted before next attempt could begin.` : `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
31247
+ error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
31231
31248
  };
31232
31249
  }
31233
31250
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
@@ -31268,6 +31285,9 @@ async function apiRequest(method, path, body, options) {
31268
31285
  const etag = response.headers.get("etag") || void 0;
31269
31286
  const elapsed = Date.now() - startedAt;
31270
31287
  debugLog(` <- ${response.status} (${elapsed}ms)`);
31288
+ if (response.status === 401) {
31289
+ invalidateOAuthTokenOnUnauthorized(headers.Authorization);
31290
+ }
31271
31291
  try {
31272
31292
  if (options?.acceptRaw) {
31273
31293
  const rawBody = await response.text();
@@ -31442,6 +31462,18 @@ function filterTools(groups, options) {
31442
31462
  }
31443
31463
 
31444
31464
  // src/tools/acl.ts
31465
+ var ETAG_FOOTER_MARKER = "// ETag: ";
31466
+ function stripEtagFooter(body) {
31467
+ const lines = body.split("\n");
31468
+ let cut = lines.length;
31469
+ for (let i = lines.length - 1; i >= 0; i--) {
31470
+ const line = lines[i].trim();
31471
+ if (line === "") continue;
31472
+ if (!line.startsWith("//")) break;
31473
+ if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
31474
+ }
31475
+ return lines.slice(0, cut).join("\n");
31476
+ }
31445
31477
  var aclTools = [
31446
31478
  {
31447
31479
  name: "tailscale_get_acl",
@@ -31462,12 +31494,12 @@ var aclTools = [
31462
31494
  if (res.ok && res.etag) {
31463
31495
  const footer = [
31464
31496
  "",
31465
- `// ETag: ${res.etag}`,
31497
+ `${ETAG_FOOTER_MARKER}${res.etag}`,
31466
31498
  "// Pass this ETag to tailscale_update_acl when updating the policy.",
31467
31499
  "// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
31468
31500
  ""
31469
31501
  ].join("\n");
31470
- return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
31502
+ return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
31471
31503
  }
31472
31504
  return res;
31473
31505
  }
@@ -31478,7 +31510,10 @@ var aclTools = [
31478
31510
  annotations: {
31479
31511
  title: "Update ACL policy",
31480
31512
  readOnlyHint: false,
31481
- destructiveHint: false,
31513
+ // Overwrites the whole policy file in one call, and a bad push can lock
31514
+ // every device out of the tailnet -- the widest blast radius of any write
31515
+ // here, so clients must gate it rather than auto-approve it.
31516
+ destructiveHint: true,
31482
31517
  idempotentHint: true,
31483
31518
  openWorldHint: true
31484
31519
  },
@@ -31486,8 +31521,16 @@ var aclTools = [
31486
31521
  policy: external_exports.string().describe(
31487
31522
  "The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
31488
31523
  ),
31489
- etag: external_exports.string().describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
31524
+ etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
31490
31525
  }),
31526
+ // `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
31527
+ // `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
31528
+ // omitted entirely -- the write then overwrites a concurrent admin edit instead
31529
+ // of coming back 412, on the widest-blast-radius write in the package, with
31530
+ // no diagnostic anywhere. `.trim()` is load-bearing for the same reason it is
31531
+ // on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
31532
+ // header goes out carrying a precondition that cannot match any real ETag --
31533
+ // a confusing 412 instead of a local validation error naming the field.
31491
31534
  handler: async (input) => {
31492
31535
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31493
31536
  rawBody: input.policy,
@@ -31805,7 +31848,9 @@ var deviceTools = [
31805
31848
  annotations: {
31806
31849
  title: "Set device routes",
31807
31850
  readOnlyHint: false,
31808
- destructiveHint: false,
31851
+ // Replace-all: the routes array is the new enabled set, so `[]` silently
31852
+ // withdraws every subnet the device currently routes.
31853
+ destructiveHint: true,
31809
31854
  idempotentHint: true,
31810
31855
  openWorldHint: true
31811
31856
  },
@@ -31890,7 +31935,9 @@ var deviceTools = [
31890
31935
  annotations: {
31891
31936
  title: "Set device tags",
31892
31937
  readOnlyHint: false,
31893
- destructiveHint: false,
31938
+ // Replace-all: `[]` strips every ACL tag, which can drop the device out of
31939
+ // the policy rules that grant it access.
31940
+ destructiveHint: true,
31894
31941
  idempotentHint: true,
31895
31942
  openWorldHint: true
31896
31943
  },
@@ -31969,7 +32016,7 @@ var deviceTools = [
31969
32016
  const failed = {};
31970
32017
  for (const { deviceId, res } of results) {
31971
32018
  if (res.ok) succeeded.push(deviceId);
31972
- else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
32019
+ else failed[deviceId] = { status: res.status, error: res.error || `HTTP ${res.status}` };
31973
32020
  }
31974
32021
  const failedCount = Object.keys(failed).length;
31975
32022
  if (failedCount === unique.length) {
@@ -32053,7 +32100,9 @@ var dnsTools = [
32053
32100
  annotations: {
32054
32101
  title: "Set nameservers",
32055
32102
  readOnlyHint: false,
32056
- destructiveHint: false,
32103
+ // Replace-all: `[]` clears tailnet DNS resolution rather than leaving the
32104
+ // current nameservers in place.
32105
+ destructiveHint: true,
32057
32106
  idempotentHint: true,
32058
32107
  openWorldHint: true
32059
32108
  },
@@ -32085,7 +32134,8 @@ var dnsTools = [
32085
32134
  annotations: {
32086
32135
  title: "Set DNS search paths",
32087
32136
  readOnlyHint: false,
32088
- destructiveHint: false,
32137
+ // Replace-all: `[]` clears every configured search domain.
32138
+ destructiveHint: true,
32089
32139
  idempotentHint: true,
32090
32140
  openWorldHint: true
32091
32141
  },
@@ -32119,7 +32169,9 @@ var dnsTools = [
32119
32169
  annotations: {
32120
32170
  title: "Set split DNS",
32121
32171
  readOnlyHint: false,
32122
- destructiveHint: false,
32172
+ // Replace-all (PUT): every domain absent from the map is dropped, so `{}`
32173
+ // clears the whole split DNS config. The PATCH sibling below merges instead.
32174
+ destructiveHint: true,
32123
32175
  idempotentHint: true,
32124
32176
  openWorldHint: true
32125
32177
  },
@@ -32206,7 +32258,9 @@ var dnsTools = [
32206
32258
  annotations: {
32207
32259
  title: "Set DNS configuration (unified)",
32208
32260
  readOnlyHint: false,
32209
- destructiveHint: false,
32261
+ // Replace-all across every DNS setting at once -- a superset of the wipe
32262
+ // the individual setters above can do.
32263
+ destructiveHint: true,
32210
32264
  idempotentHint: true,
32211
32265
  openWorldHint: true
32212
32266
  },
@@ -32663,6 +32717,48 @@ var keyTools = [
32663
32717
  handler: async (input) => {
32664
32718
  return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32665
32719
  }
32720
+ },
32721
+ {
32722
+ name: "tailscale_list_oauth_apps",
32723
+ description: "List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an `oauthApps` array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.",
32724
+ annotations: {
32725
+ title: "List OAuth apps",
32726
+ readOnlyHint: true,
32727
+ destructiveHint: false,
32728
+ idempotentHint: true,
32729
+ openWorldHint: true
32730
+ },
32731
+ // No limit/cursor: the live endpoint takes no parameters and returns the
32732
+ // whole collection. Advertising pagination the API ignores would let a
32733
+ // caller believe it had paged through the list when it had not.
32734
+ inputSchema: external_exports.object({}),
32735
+ handler: async () => {
32736
+ return apiGet(`/tailnet/${getTailnet()}/oauth-apps`);
32737
+ }
32738
+ },
32739
+ {
32740
+ name: "tailscale_delete_oauth_app",
32741
+ description: "Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.",
32742
+ annotations: {
32743
+ title: "Delete OAuth app",
32744
+ readOnlyHint: false,
32745
+ destructiveHint: true,
32746
+ idempotentHint: true,
32747
+ openWorldHint: true
32748
+ },
32749
+ inputSchema: external_exports.object({
32750
+ // `.trim().min(1)` rather than the bare `.min(1)` on
32751
+ // tailscale_get_oauth_app's appId, for the reason tailnets.ts spells out
32752
+ // on tailscale_delete_tailnet: a bare min(1) accepts " ", which encPath
32753
+ // then sends as the literal segment "%20". On a read that costs a wasted
32754
+ // round-trip; on an irreversible revoke it returns a 404 that reads like
32755
+ // the app is already gone. Trimming at the schema makes it a validation
32756
+ // error instead.
32757
+ appId: external_exports.string().trim().min(1).describe("The OAuth app ID to delete (see tailscale_list_oauth_apps)")
32758
+ }),
32759
+ handler: async (input) => {
32760
+ return apiDelete(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
32761
+ }
32666
32762
  }
32667
32763
  ];
32668
32764
 
@@ -32866,8 +32962,8 @@ var logStreamingTools = [
32866
32962
  apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
32867
32963
  ]);
32868
32964
  const errors = {};
32869
- if (!configuration.ok) errors.configuration = configuration.error ?? `HTTP ${configuration.status}`;
32870
- if (!network.ok) errors.network = network.error ?? `HTTP ${network.status}`;
32965
+ if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
32966
+ if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
32871
32967
  if (!configuration.ok && !network.ok) {
32872
32968
  return {
32873
32969
  ok: false,
@@ -33353,8 +33449,8 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
33353
33449
  settings: settingsRes.ok ? settingsRes.data : null
33354
33450
  };
33355
33451
  const errors = {};
33356
- if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
33357
- if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
33452
+ if (!devicesRes.ok) errors.devices = devicesRes.error || `HTTP ${devicesRes.status}`;
33453
+ if (!settingsRes.ok) errors.settings = settingsRes.error || `HTTP ${settingsRes.status}`;
33358
33454
  if (Object.keys(errors).length > 0) data.errors = errors;
33359
33455
  return data;
33360
33456
  }
@@ -33486,7 +33582,7 @@ var tailnetTools = [
33486
33582
  const failed = {};
33487
33583
  for (const { contactType, res } of results) {
33488
33584
  if (res.ok) applied[contactType] = res.data;
33489
- else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
33585
+ else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
33490
33586
  }
33491
33587
  const hasFailed = Object.keys(failed).length > 0;
33492
33588
  const hasApplied = Object.keys(applied).length > 0;
@@ -33573,7 +33669,7 @@ var tailnetsTools = [
33573
33669
  },
33574
33670
  {
33575
33671
  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.",
33672
+ 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. That check is a typo guard, not an authorization gate: when you also pass `tailnet` you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-`tailnet` path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
33577
33673
  annotations: {
33578
33674
  title: "Delete tailnet",
33579
33675
  readOnlyHint: false,
@@ -33586,7 +33682,7 @@ var tailnetsTools = [
33586
33682
  "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
33683
  ),
33588
33684
  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."
33685
+ "Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A typo guard, not an authorization gate: on the explicit-`tailnet` path the caller writes both halves of the comparison, so it proves only self-agreement. It is a real second look only when `tailnet` is omitted and the value has to match the operator's environment."
33590
33686
  )
33591
33687
  }),
33592
33688
  // `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
@@ -33604,8 +33700,9 @@ var tailnetsTools = [
33604
33700
  );
33605
33701
  }
33606
33702
  if (input.confirmTailnet !== target) {
33703
+ const source = input.tailnet?.trim() ? "tailnet you named" : "configured tailnet";
33607
33704
  throw new Error(
33608
- `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the configured tailnet ${JSON.stringify(target)}. Refusing to delete.`
33705
+ `confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the ${source} ${JSON.stringify(target)}. Refusing to delete.`
33609
33706
  );
33610
33707
  }
33611
33708
  return apiDelete(`/tailnet/${encPath(target)}`);
@@ -34011,7 +34108,7 @@ async function tailnetStatusResource(uri) {
34011
34108
  }
34012
34109
  async function tailnetDevicesResource(uri) {
34013
34110
  const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
34014
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
34111
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error || `HTTP ${res.status}` }, null, 2);
34015
34112
  return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
34016
34113
  }
34017
34114
  async function tailnetAclResource(uri) {
@@ -34019,7 +34116,7 @@ async function tailnetAclResource(uri) {
34019
34116
  if (res.ok) {
34020
34117
  return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
34021
34118
  }
34022
- const lines = `Error: ${res.error ?? `HTTP ${res.status}`}`.split("\n");
34119
+ const lines = `Error: ${res.error || `HTTP ${res.status}`}`.split("\n");
34023
34120
  const text = `${lines.map((l) => `// ${l}`).join("\n")}
34024
34121
  `;
34025
34122
  return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
@@ -34038,16 +34135,16 @@ async function tailnetDnsResource(uri) {
34038
34135
  preferences: preferences.ok ? preferences.data : null
34039
34136
  };
34040
34137
  const errors = {};
34041
- if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
34042
- if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
34043
- if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
34044
- if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
34138
+ if (!nameservers.ok) errors.nameservers = nameservers.error || `HTTP ${nameservers.status}`;
34139
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error || `HTTP ${searchPaths.status}`;
34140
+ if (!splitDns.ok) errors.splitDns = splitDns.error || `HTTP ${splitDns.status}`;
34141
+ if (!preferences.ok) errors.preferences = preferences.error || `HTTP ${preferences.status}`;
34045
34142
  if (Object.keys(errors).length > 0) data.errors = errors;
34046
34143
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
34047
34144
  }
34048
34145
 
34049
34146
  // src/index.ts
34050
- var version2 = true ? "0.17.1" : resolveVersionFallback();
34147
+ var version2 = true ? "0.18.0" : resolveVersionFallback();
34051
34148
  var subcommand = process.argv[2];
34052
34149
  var cliSubcommandHandled = false;
34053
34150
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -34104,7 +34201,7 @@ if (!cliSubcommandHandled) {
34104
34201
  }
34105
34202
  if (unknownProfile) {
34106
34203
  console.error(
34107
- `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
34204
+ `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
34108
34205
  );
34109
34206
  }
34110
34207
  const server = new McpServer({
@@ -34165,7 +34262,7 @@ if (!cliSubcommandHandled) {
34165
34262
  const coreCount = profileCount(PROFILES.core);
34166
34263
  const minimalCount = profileCount(PROFILES.minimal);
34167
34264
  console.error(
34168
- `@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34265
+ `@yawlabs/tailscale-mcp: tip -- set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
34169
34266
  );
34170
34267
  }
34171
34268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.17.1",
3
+ "version": "0.18.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",
@@ -35,8 +35,9 @@
35
35
  "start": "node dist/index.js",
36
36
  "test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
37
37
  "test:ci": "npm run test",
38
- "lint": "biome check src/",
39
- "lint:fix": "biome check --write src/",
38
+ "test:coverage": "npm run build && node --test-timeout=300000 --experimental-test-coverage --test-coverage-exclude=\"**/*.test.js\" --test-coverage-exclude=\"**/dist/index.js\" --test \"dist/**/*.test.js\"",
39
+ "lint": "biome check src/ bin/ scripts/ build.mjs",
40
+ "lint:fix": "biome check --write src/ bin/ scripts/ build.mjs",
40
41
  "check:oam": "oam check src/index.ts",
41
42
  "build:binary": "node scripts/build-binary.mjs",
42
43
  "build:binary:oam": "node scripts/build-binary-oam.mjs",