@yawlabs/tailscale-mcp 0.8.1 → 0.8.3

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.
Files changed (3) hide show
  1. package/README.md +55 -19
  2. package/dist/index.js +46 -9
  3. package/package.json +1 -1
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
  [![CI](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml) [![Integration](https://github.com/YawLabs/tailscale-mcp/actions/workflows/integration.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/integration.yml)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 99 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 727 tests and a nightly integration run against a real tailnet.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 99 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 735 tests and a nightly integration run against a real tailnet.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -35,7 +35,7 @@ Reasonable question. Both have their place. Where this MCP is better:
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 is tied to 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.
38
- - **Real tests.** 727 unit tests + an integration suite hitting a live tailnet on every tag. A skill is usually 50 lines of README without tests, and if the vendor changes output format nothing catches it.
38
+ - **Real tests.** 727 unit tests + an integration suite hitting a live tailnet on every tag. 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
 
@@ -43,7 +43,7 @@ If you already have a skill that covers your 10% of Tailscale workflows, great
43
43
 
44
44
  Fair critique from Reddit: a week-old repo claiming "actively maintained" with no visible tests is worth exactly zero trust. Here's what's actually verifiable:
45
45
 
46
- - **727 tests** (178 suites, `node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
46
+ - **735 tests** (179 suites, `node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
47
  - **3 CI workflows** on GitHub Actions:
48
48
  - [`ci.yml`](.github/workflows/ci.yml) — lint + typecheck + build + unit tests on every push and PR.
49
49
  - [`integration.yml`](.github/workflows/integration.yml) — runs the full tool surface against a real tailnet.
@@ -107,34 +107,70 @@ That's it. Now ask your agent:
107
107
 
108
108
  ## Too many tools? Subset them.
109
109
 
110
- 99 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes:
110
+ 99 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:
111
+
112
+ ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
111
113
 
112
114
  ```json
113
115
  {
114
- "mcpServers": {
115
- "tailscale": {
116
- "command": "npx",
117
- "args": ["-y", "@yawlabs/tailscale-mcp"],
118
- "env": {
119
- "TAILSCALE_API_KEY": "tskey-api-...",
120
- "TAILSCALE_TOOLS": "devices,acl,dns,audit",
121
- "TAILSCALE_READONLY": "1"
122
- }
123
- }
116
+ "env": {
117
+ "TAILSCALE_API_KEY": "tskey-api-...",
118
+ "TAILSCALE_PROFILE": "core"
119
+ }
120
+ }
121
+ ```
122
+
123
+ - **`minimal`** (19 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
124
+ - **`core`** (46 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
125
+ - **`full`** (99 tools, default) — everything. Same as omitting the env var.
126
+
127
+ ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
128
+
129
+ ```json
130
+ {
131
+ "env": {
132
+ "TAILSCALE_API_KEY": "tskey-api-...",
133
+ "TAILSCALE_TOOLS": "devices,acl,dns,audit"
124
134
  }
125
135
  }
126
136
  ```
127
137
 
128
- - **`TAILSCALE_TOOLS`** comma-separated list of tool groups to expose. Omit for all groups.
129
- - **`TAILSCALE_READONLY`** — set to `1` or `true` to drop every mutating tool (only tools with `readOnlyHint: true` remain). Combine with `TAILSCALE_TOOLS` for maximum minimalism.
138
+ Comma-separated group names. Overrides `TAILSCALE_PROFILE` when both are set use this when the presets aren't quite right.
130
139
 
131
140
  Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `network-lock`, `posture`, `audit`, `invites`, `services`, `log-streaming`, `workload-identity`, `oauth-clients`.
132
141
 
133
- The server logs the active filter to stderr on startup so you can confirm what got loaded:
142
+ ### Option 3: `TAILSCALE_READONLY` (drop mutations)
134
143
 
144
+ ```json
145
+ {
146
+ "env": {
147
+ "TAILSCALE_API_KEY": "tskey-api-...",
148
+ "TAILSCALE_PROFILE": "core",
149
+ "TAILSCALE_READONLY": "1"
150
+ }
151
+ }
135
152
  ```
136
- @yawlabs/tailscale-mcp v0.8.0 ready (21 tools, groups=devices,acl,dns,audit, readonly)
153
+
154
+ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks with `TAILSCALE_PROFILE` or `TAILSCALE_TOOLS` as an intersection — combine for maximum minimalism.
155
+
156
+ ### Confirming what loaded
157
+
158
+ The server logs the active filter to stderr on startup:
159
+
137
160
  ```
161
+ @yawlabs/tailscale-mcp v0.8.2 ready (21 tools, profile=core, readonly)
162
+ ```
163
+
164
+ If you don't set any filter, startup prints a tip pointing you at the profiles.
165
+
166
+ ## Using with mcp.hosting / mcph
167
+
168
+ If you run this server through [mcp.hosting](https://mcp.hosting) (via the `@yawlabs/mcph` local agent), the two filtering layers compose cleanly:
169
+
170
+ 1. **Server-side** — `TAILSCALE_PROFILE` / `TAILSCALE_TOOLS` / `TAILSCALE_READONLY` reduce the tool surface *before* mcph sees it. The unloaded tools aren't registered at all.
171
+ 2. **Client-side** — mcph's `mcp_connect_activate({ tools: [...] })` filters further for what appears in `tools/list`. Tools not in that list stay reachable via `mcp_connect_dispatch`, so you don't lose capability.
172
+
173
+ Recommended pattern for mcph users: set `TAILSCALE_PROFILE=core` (or narrower) in your mcp.hosting server config, then let mcph handle per-conversation activation on top. The server stays lean by default, and `mcp_connect_dispatch` covers the long-tail tools for ad-hoc needs.
138
174
 
139
175
  ## Authentication
140
176
 
@@ -426,7 +462,7 @@ npm install
426
462
  npm run lint # Biome check
427
463
  npm run lint:fix # Auto-fix
428
464
  npm run build # tsc + esbuild bundle
429
- npm test # node --test (727 tests)
465
+ npm test # node --test (735 tests)
430
466
  ```
431
467
 
432
468
  For integration testing against your own tailnet: set `TAILSCALE_API_KEY` and run `node dist/index.js`.
package/dist/index.js CHANGED
@@ -30320,13 +30320,30 @@ async function deployAcl(filePath) {
30320
30320
  }
30321
30321
 
30322
30322
  // src/filter.ts
30323
+ var PROFILES = {
30324
+ minimal: ["status", "devices", "audit"],
30325
+ core: ["status", "devices", "acl", "dns", "keys", "users", "audit"],
30326
+ full: []
30327
+ // empty = all groups
30328
+ };
30323
30329
  function filterTools(groups, options) {
30324
- const enabledGroups = options.tools ? new Set(
30325
- options.tools.split(",").map((s) => s.trim()).filter(Boolean)
30326
- ) : null;
30327
- const readonly2 = options.readonly === "1" || options.readonly === "true";
30328
30330
  const validNames = new Set(Object.keys(groups));
30331
+ let profileGroups;
30332
+ let unknownProfile2;
30333
+ if (options.profile) {
30334
+ const profileKey = options.profile.trim().toLowerCase();
30335
+ if (profileKey in PROFILES) {
30336
+ const preset = PROFILES[profileKey];
30337
+ profileGroups = preset.length > 0 ? [...preset] : void 0;
30338
+ } else {
30339
+ unknownProfile2 = profileKey;
30340
+ }
30341
+ }
30342
+ const explicitTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
30343
+ const effectiveGroups = explicitTools ?? profileGroups ?? null;
30344
+ const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
30329
30345
  const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
30346
+ const readonly2 = options.readonly === "1" || options.readonly === "true";
30330
30347
  const out = [];
30331
30348
  for (const [name, tools] of Object.entries(groups)) {
30332
30349
  if (enabledGroups && !enabledGroups.has(name)) continue;
@@ -30335,7 +30352,10 @@ function filterTools(groups, options) {
30335
30352
  out.push(t);
30336
30353
  }
30337
30354
  }
30338
- return { tools: out, unknownGroups: unknownGroups2 };
30355
+ const result = { tools: out, unknownGroups: unknownGroups2 };
30356
+ if (unknownProfile2) result.unknownProfile = unknownProfile2;
30357
+ if (profileGroups && !explicitTools) result.profileGroups = profileGroups;
30358
+ return result;
30339
30359
  }
30340
30360
 
30341
30361
  // src/tools/acl.ts
@@ -30415,7 +30435,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
30415
30435
  acceptRaw: true,
30416
30436
  accept: "application/hujson"
30417
30437
  });
30418
- if (res.ok && (!res.rawBody || !res.rawBody.trim())) {
30438
+ if (res.ok && !res.rawBody?.trim()) {
30419
30439
  return { ...res, rawBody: "ACL policy is valid." };
30420
30440
  }
30421
30441
  return res;
@@ -32459,7 +32479,7 @@ var workloadIdentityTools = [
32459
32479
  ];
32460
32480
 
32461
32481
  // src/index.ts
32462
- var version2 = true ? "0.8.1" : (await null).createRequire(import.meta.url)("../package.json").version;
32482
+ var version2 = true ? "0.8.3" : (await null).createRequire(import.meta.url)("../package.json").version;
32463
32483
  var subcommand = process.argv[2];
32464
32484
  if (subcommand === "deploy-acl") {
32465
32485
  const filePath = process.argv[3];
@@ -32494,9 +32514,14 @@ var toolGroups = {
32494
32514
  "workload-identity": workloadIdentityTools,
32495
32515
  "oauth-clients": oauthClientTools
32496
32516
  };
32497
- var { tools: allTools, unknownGroups } = filterTools(toolGroups, {
32517
+ var {
32518
+ tools: allTools,
32519
+ unknownGroups,
32520
+ unknownProfile
32521
+ } = filterTools(toolGroups, {
32498
32522
  tools: process.env.TAILSCALE_TOOLS,
32499
- readonly: process.env.TAILSCALE_READONLY
32523
+ readonly: process.env.TAILSCALE_READONLY,
32524
+ profile: process.env.TAILSCALE_PROFILE
32500
32525
  });
32501
32526
  if (unknownGroups.length > 0) {
32502
32527
  const validNames = Object.keys(toolGroups);
@@ -32504,6 +32529,11 @@ if (unknownGroups.length > 0) {
32504
32529
  `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}`
32505
32530
  );
32506
32531
  }
32532
+ if (unknownProfile) {
32533
+ console.error(
32534
+ `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
32535
+ );
32536
+ }
32507
32537
  var server = new McpServer({
32508
32538
  name: "@yawlabs/tailscale-mcp",
32509
32539
  version: version2
@@ -32606,11 +32636,18 @@ server.resource(
32606
32636
  var transport = new StdioServerTransport();
32607
32637
  await server.connect(transport);
32608
32638
  var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
32639
+ var profileApplied = process.env.TAILSCALE_PROFILE && !unknownProfile ? process.env.TAILSCALE_PROFILE : null;
32609
32640
  var filterSuffix = [
32641
+ profileApplied ? `profile=${profileApplied}` : null,
32610
32642
  process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
32611
32643
  readonlyMode ? "readonly" : null
32612
32644
  ].filter(Boolean).join(", ");
32613
32645
  console.error(
32614
32646
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
32615
32647
  );
32648
+ if (!filterSuffix) {
32649
+ console.error(
32650
+ "@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (46 tools) or =minimal (19) to load a smaller tool surface. See README."
32651
+ );
32652
+ }
32616
32653
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",