@yawlabs/tailscale-mcp 0.6.5 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +92 -54
  2. package/dist/index.js +84 -61
  3. package/package.json +8 -2
package/README.md CHANGED
@@ -3,30 +3,61 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@yawlabs/tailscale-mcp)](https://www.npmjs.com/package/@yawlabs/tailscale-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
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)
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
- **Manage your Tailscale tailnet from Claude Code, Cursor, and any MCP client.** 99 tools + 4 resources. One env var. Works on first try.
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.
9
9
 
10
- Built and maintained by [YawLabs](https://yaw.sh).
10
+ Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
12
- ## Why this one?
12
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Tailscale&command=npx&args=-y%2C%40yawlabs%2Ftailscale-mcp&env=TAILSCALE_API_KEY&description=Manage%20your%20Tailscale%20tailnet%20-%20devices%2C%20ACLs%2C%20DNS%2C%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Ftailscale-mcp)
13
13
 
14
- Other Tailscale MCP servers were vibe-coded in a weekend and abandoned. This one was built for production use and tested against the real Tailscale API.
14
+ One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
15
15
 
16
- - **Preserves ACL formatting** reads and writes HuJSON (comments, trailing commas, indentation). Others compact your carefully formatted policy into a single line.
17
- - **Safe ACL updates** — uses ETags to prevent overwriting concurrent changes. No silent data loss.
18
- - **Tool annotations** every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients skip confirmation dialogs for safe operations.
19
- - **MCP Resources** — exposes tailnet status, device list, ACL policy, and DNS config as browsable resources.
20
- - **Instant startup** ships as a single self-contained bundle with zero runtime dependencies. `npx` downloads ~150 KB and starts immediately no 5-minute `node_modules` installs.
21
- - **Zero restarts** the server always starts, even with missing credentials. Auth errors surface as clear tool-call errors, not silent crashes that force you to restart your AI assistant.
22
- - **One env var setup** — no config files, no setup wizards, no multi-step flows.
23
- - **Every tool verified** — no placeholder endpoints that 404. If it's in the tool list, it works.
16
+ ## What's the point if the API already exists?
17
+
18
+ You could `curl` the Tailscale API. The point isn't replacing `curl` it's letting an agent compose multi-endpoint workflows in one turn without writing a script:
19
+
20
+ - **"Which devices haven't checked in for 30 days and have key expiry disabled?"** lists devices, filters by `lastSeen`, filters by `keyExpiryDisabled`, returns a table. Three endpoints, one question.
21
+ - **"Someone broke DNS at 2am who changed what in the last 24 hours?"** pulls the audit log, filters by DNS-related actors and endpoints, reads each change's before/after, summarizes in English.
22
+ - **"Draft an ACL change that lets `tag:mobile` reach `tag:dashboard` but not `tag:db`, preserving my comments"** — reads the current HuJSON, proposes a minimal diff, validates it against the API, returns the diff for you to apply.
23
+ - **"Show me the OIDC workload identity for our GitHub Actions and confirm its allowed subjects still match `repo:Acme/*`"** — fetches the workload identity, parses the JWT claim patterns, tells you whether the claim still matches your repo naming.
24
+ - **"Rotate every auth key older than 90 days and print the new ones"** — iterates, creates new keys with matching tags, revokes the old ones.
25
+
26
+ A curl can do each step. The agent composes them. That's where the lift is, and that's what the tool surface is designed for — every read endpoint is first-class so the agent can synthesize, and every write endpoint is tagged `destructiveHint` or `idempotentHint` so your MCP client can gate mutations the way you configured it.
27
+
28
+ If all you need is one endpoint in a CI job, use `curl` — we even have a [CLI subcommand](#gitops-deploy-acls-from-ci) for the common ACL-from-git case. The MCP is for the interactive, exploratory, "I don't know what I need yet" work.
29
+
30
+ ## Why MCP vs. a skill or the `tailscale` CLI?
31
+
32
+ Reasonable question. Both have their place. Where this MCP is better:
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, workload identity, OAuth clients, posture — live in the v2 HTTP API. You'd be shelling out to `curl` anyway.
35
+ - **Typed tool surface, not string parsing.** Every tool has a Zod-validated input schema and a structured response. No brittle `tailscale status --json | jq` pipelines that break when the schema evolves.
36
+ - **Cross-client, no user rewriting.** A Claude Code skill 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
+ - **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.
39
+
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
+
42
+ ## Trust signals
43
+
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
+
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.
47
+ - **3 CI workflows** on GitHub Actions:
48
+ - [`ci.yml`](.github/workflows/ci.yml) — lint + typecheck + build + unit tests on every push and PR.
49
+ - [`integration.yml`](.github/workflows/integration.yml) — runs the full tool surface against a real tailnet.
50
+ - [`release.yml`](.github/workflows/release.yml) — publishes to npm from a signed tag.
51
+ - **Dependabot alerts** surface on this repo and get fixed, not ignored.
52
+ - **Every tool verified against the live API.** If it's in the tool list, it calls a real endpoint that exists in the current v2 API. No placeholder 404 tools.
53
+
54
+ Issues and PRs are triaged. File one if something is off — [github.com/YawLabs/tailscale-mcp/issues](https://github.com/YawLabs/tailscale-mcp/issues).
24
55
 
25
56
  ## Quick start
26
57
 
27
58
  **1. Set your API key**
28
59
 
29
- 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 system environment variables):
60
+ 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):
30
61
 
31
62
  ```bash
32
63
  export TAILSCALE_API_KEY="tskey-api-..."
@@ -60,49 +91,64 @@ Windows:
60
91
  }
61
92
  ```
62
93
 
63
- > **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround and is what MCP clients expect. This file is safe to commit — it contains no secrets.
94
+ > **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround.
64
95
 
65
96
  **3. Restart and approve**
66
97
 
67
98
  Restart Claude Code (or your MCP client) and approve the Tailscale MCP server when prompted.
68
99
 
69
- That's it. Now ask your AI assistant:
100
+ That's it. Now ask your agent:
70
101
 
71
- > "List my Tailscale devices"
102
+ > "List my Tailscale devices that haven't been seen in the last 7 days"
103
+ >
104
+ > "Summarize every ACL change in the audit log from yesterday"
105
+ >
106
+ > "Draft an ACL rule that lets `tag:ci` reach `tag:registry` on port 5000 only"
72
107
 
73
- ```
74
- ┌────────────┬─────────┬────────────────┬──────────────────────┐
75
- │ Hostname │ OS │ Tailscale IP │ Last Seen │
76
- ���────────────┼─────────┼────────────────┼──────────────────────┤
77
- │ web-prod │ Linux │ 100.x.x.1 │ 2026-04-03 21:09 UTC │
78
- ├────────────┼─────────┼���───────────────┼──────────────────────┤
79
- │ db-staging │ Linux │ 100.x.x.2 │ 2026-04-03 21:09 UTC ��
80
- ���────────────┼──────���──┼────────────────┼──────────────────────┤
81
- │ dev-laptop │ macOS │ 100.x.x.3 │ 2026-04-03 21:09 UTC │
82
- └────────────┴─────────┴──���─────────────┴──────────────────────┘
108
+ ## Too many tools? Subset them.
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:
111
+
112
+ ```json
113
+ {
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
+ }
124
+ }
125
+ }
83
126
  ```
84
127
 
85
- > "Show me the ACL policy"
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.
86
130
 
87
- Returns your full policy with formatting, comments, and structure intact plus an ETag for safe updates.
131
+ Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `network-lock`, `posture`, `audit`, `invites`, `services`, `log-streaming`, `workload-identity`, `oauth-clients`.
88
132
 
89
- > "Who changed the DNS settings yesterday?"
133
+ The server logs the active filter to stderr on startup so you can confirm what got loaded:
90
134
 
91
- Pulls the audit log so you can see exactly who did what and when.
135
+ ```
136
+ @yawlabs/tailscale-mcp v0.8.0 ready (21 tools, groups=devices,acl,dns,audit, readonly)
137
+ ```
92
138
 
93
139
  ## Authentication
94
140
 
95
- **API key (recommended):** Set `TAILSCALE_API_KEY` in your shell profile. Simplest option, works immediately. You can also pass it inline via the `"env"` field in your MCP config if you prefer a self-contained setup.
141
+ **API key (simplest):** Set `TAILSCALE_API_KEY` in your shell or MCP config.
96
142
 
97
143
  **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).
98
144
 
99
- 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.
145
+ 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.
100
146
 
101
147
  **Tailnet:** Uses your default tailnet automatically. Set `TAILSCALE_TAILNET` to specify one explicitly.
102
148
 
103
149
  ## Resources (4)
104
150
 
105
- MCP Resources expose read-only data that clients can browse without tool calls.
151
+ MCP Resources expose read-only data clients can browse without a tool call.
106
152
 
107
153
  | Resource | URI | Description |
108
154
  |----------|-----|-------------|
@@ -350,48 +396,40 @@ MCP Resources expose read-only data that clients can browse without tool calls.
350
396
 
351
397
  ## GitOps: deploy ACLs from CI
352
398
 
353
- The recommended workflow for ACL management is to keep your policy in git and deploy it automatically on merge. This gives you code review, history, and no accidental overwrites from stale browser tabs.
354
-
355
- The `deploy-acl` CLI subcommand handles everything — ETag fetching, validation, and deployment — in a single command:
399
+ For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent use the built-in CLI:
356
400
 
357
401
  ```bash
358
402
  npx @yawlabs/tailscale-mcp deploy-acl tailscale/acl.json
359
403
  ```
360
404
 
361
- Works with any CI system just set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars.
405
+ Handles ETag fetching, validation, and deployment in one command. Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars.
362
406
 
363
- **Optional:** Lock the Admin Console to prevent manual edits that drift from git:
407
+ **Optional:** Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
364
408
 
365
- ```
366
409
  > "Set aclsExternallyManagedOn to true and aclsExternalLink to our repo URL"
367
- ```
368
410
 
369
- This shows a read-only banner in the Tailscale Admin Console pointing to your repo. Use the MCP for reads and one-off operations (audit logs, device management, investigations), and let CI handle ACL deployments.
411
+ This shows a read-only banner in the Tailscale Admin Console pointing to your repo. Use the MCP for reads and investigations, and let CI handle the deploy.
370
412
 
371
413
  ## Requirements
372
414
 
373
- - Node.js 18 or higher
415
+ - Node.js 18+
416
+ - A Tailscale API key or OAuth client credentials
374
417
 
375
418
  ## Contributing
376
419
 
377
- Contributions are welcome. Please [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) to discuss what you'd like to change before submitting a PR.
378
-
379
- To develop locally:
420
+ Contributions welcome. Please [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) to discuss before a PR for anything beyond a typo fix.
380
421
 
381
422
  ```bash
382
423
  git clone https://github.com/YawLabs/tailscale-mcp.git
383
424
  cd tailscale-mcp
384
425
  npm install
385
- npm run build
386
- npm run lint
387
- npm test
426
+ npm run lint # Biome check
427
+ npm run lint:fix # Auto-fix
428
+ npm run build # tsc + esbuild bundle
429
+ npm test # node --test (727 tests)
388
430
  ```
389
431
 
390
- Test against your own tailnet by setting `TAILSCALE_API_KEY` and running:
391
-
392
- ```bash
393
- node dist/index.js
394
- ```
432
+ For integration testing against your own tailnet: set `TAILSCALE_API_KEY` and run `node dist/index.js`.
395
433
 
396
434
  ## License
397
435
 
package/dist/index.js CHANGED
@@ -21015,21 +21015,23 @@ var BASE_URL = "https://api.tailscale.com/api/v2";
21015
21015
  var REQUEST_TIMEOUT_MS = 3e4;
21016
21016
  var oauthToken = null;
21017
21017
  var oauthRefreshPromise = null;
21018
- function getConfig() {
21018
+ function getAuthConfig() {
21019
21019
  const apiKey = process.env.TAILSCALE_API_KEY;
21020
21020
  const oauthClientId = process.env.TAILSCALE_OAUTH_CLIENT_ID;
21021
21021
  const oauthClientSecret = process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
21022
- const tailnet = process.env.TAILSCALE_TAILNET || "-";
21023
- if (!apiKey && !(oauthClientId && oauthClientSecret)) {
21024
- const hint = process.platform === "win32" ? ' On Windows, env vars set in bash/WSL profiles are not visible to MCP servers launched via cmd. Either add "env": {"TAILSCALE_API_KEY": "tskey-api-..."} to your .mcp.json, or set it as a Windows user environment variable.' : "";
21025
- throw new Error(
21026
- `No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
21027
- );
21022
+ if (apiKey) {
21023
+ if (apiKey.trim() === "") {
21024
+ throw new Error("TAILSCALE_API_KEY is set but empty. Provide a valid API key.");
21025
+ }
21026
+ return { kind: "apiKey", apiKey };
21028
21027
  }
21029
- if (apiKey && apiKey.trim() === "") {
21030
- throw new Error("TAILSCALE_API_KEY is set but empty. Provide a valid API key.");
21028
+ if (oauthClientId && oauthClientSecret) {
21029
+ return { kind: "oauth", clientId: oauthClientId, clientSecret: oauthClientSecret };
21031
21030
  }
21032
- return { apiKey, oauthClientId, oauthClientSecret, tailnet };
21031
+ const hint = process.platform === "win32" ? ' On Windows, env vars set in bash/WSL profiles are not visible to MCP servers launched via cmd. Either add "env": {"TAILSCALE_API_KEY": "tskey-api-..."} to your .mcp.json, or set it as a Windows user environment variable.' : "";
21032
+ throw new Error(
21033
+ `No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
21034
+ );
21033
21035
  }
21034
21036
  async function getOAuthAccessToken(clientId, clientSecret) {
21035
21037
  if (oauthToken && Date.now() < oauthToken.expires_at - 6e4) {
@@ -21067,11 +21069,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
21067
21069
  return oauthRefreshPromise;
21068
21070
  }
21069
21071
  async function getAuthHeader() {
21070
- const config2 = getConfig();
21071
- if (config2.apiKey) {
21072
+ const config2 = getAuthConfig();
21073
+ if (config2.kind === "apiKey") {
21072
21074
  return `Basic ${Buffer.from(`${config2.apiKey}:`).toString("base64")}`;
21073
21075
  }
21074
- const token = await getOAuthAccessToken(config2.oauthClientId, config2.oauthClientSecret);
21076
+ const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret);
21075
21077
  return `Bearer ${token}`;
21076
21078
  }
21077
21079
  function getTailnet() {
@@ -21080,6 +21082,13 @@ function getTailnet() {
21080
21082
  function encPath(segment) {
21081
21083
  return encodeURIComponent(segment);
21082
21084
  }
21085
+ function validateTags(tags) {
21086
+ if (!tags || tags.length === 0) return;
21087
+ const invalid = tags.filter((t) => !t.startsWith("tag:"));
21088
+ if (invalid.length > 0) {
21089
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
21090
+ }
21091
+ }
21083
21092
  function sanitizeDescription(value) {
21084
21093
  return value.replace(/[/_]/g, "-").replace(/[^a-zA-Z0-9 -]/g, "").replace(/ {2,}/g, " ").trim().slice(0, 50);
21085
21094
  }
@@ -21207,6 +21216,25 @@ async function deployAcl(filePath) {
21207
21216
  console.log("ACL deployed successfully");
21208
21217
  }
21209
21218
 
21219
+ // src/filter.ts
21220
+ function filterTools(groups, options) {
21221
+ const enabledGroups = options.tools ? new Set(
21222
+ options.tools.split(",").map((s) => s.trim()).filter(Boolean)
21223
+ ) : null;
21224
+ const readonly2 = options.readonly === "1" || options.readonly === "true";
21225
+ const validNames = new Set(Object.keys(groups));
21226
+ const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
21227
+ const out = [];
21228
+ for (const [name, tools] of Object.entries(groups)) {
21229
+ if (enabledGroups && !enabledGroups.has(name)) continue;
21230
+ for (const t of tools) {
21231
+ if (readonly2 && t.annotations.readOnlyHint !== true) continue;
21232
+ out.push(t);
21233
+ }
21234
+ }
21235
+ return { tools: out, unknownGroups: unknownGroups2 };
21236
+ }
21237
+
21210
21238
  // src/tools/acl.ts
21211
21239
  var aclTools = [
21212
21240
  {
@@ -21622,10 +21650,7 @@ var deviceTools = [
21622
21650
  tags: external_exports.array(external_exports.string()).describe("Full list of ACL tags (e.g. ['tag:server', 'tag:production']). Replaces all existing tags.")
21623
21651
  }),
21624
21652
  handler: async (input) => {
21625
- const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
21626
- if (invalid.length > 0) {
21627
- throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
21628
- }
21653
+ validateTags(input.tags);
21629
21654
  return apiPost(`/device/${encPath(input.deviceId)}/tags`, { tags: input.tags });
21630
21655
  }
21631
21656
  },
@@ -22136,7 +22161,7 @@ var keyTools = [
22136
22161
  },
22137
22162
  {
22138
22163
  name: "tailscale_create_key",
22139
- description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value \u2014 save it immediately, as it cannot be retrieved again.",
22164
+ description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value \u2014 save it immediately, as it cannot be retrieved again.\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
22140
22165
  annotations: {
22141
22166
  title: "Create key",
22142
22167
  readOnlyHint: false,
@@ -22167,12 +22192,7 @@ var keyTools = [
22167
22192
  customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Custom claim mapping rules")
22168
22193
  }),
22169
22194
  handler: async (input) => {
22170
- if (input.tags && input.tags.length > 0) {
22171
- const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22172
- if (invalid.length > 0) {
22173
- throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22174
- }
22175
- }
22195
+ validateTags(input.tags);
22176
22196
  const keyType = input.keyType ?? "auth";
22177
22197
  if (keyType !== "auth") {
22178
22198
  const authOnlyFields = ["reusable", "ephemeral", "preauthorized", "expirySeconds"];
@@ -22252,12 +22272,7 @@ var keyTools = [
22252
22272
  customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Updated custom claim rules")
22253
22273
  }),
22254
22274
  handler: async (input) => {
22255
- if (input.tags && input.tags.length > 0) {
22256
- const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22257
- if (invalid.length > 0) {
22258
- throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22259
- }
22260
- }
22275
+ validateTags(input.tags);
22261
22276
  const body = {};
22262
22277
  if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22263
22278
  if (input.scopes !== void 0) body.scopes = input.scopes;
@@ -22489,12 +22504,7 @@ var oauthClientTools = [
22489
22504
  description: external_exports.string().optional().describe("Description for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)")
22490
22505
  }),
22491
22506
  handler: async (input) => {
22492
- if (input.tags && input.tags.length > 0) {
22493
- const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22494
- if (invalid.length > 0) {
22495
- throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22496
- }
22497
- }
22507
+ validateTags(input.tags);
22498
22508
  const body = { ...input };
22499
22509
  body.name = sanitizeDescription(input.name);
22500
22510
  if (input.description !== void 0) body.description = sanitizeDescription(input.description);
@@ -22719,12 +22729,7 @@ var serviceTools = [
22719
22729
  autoApproveHosts: external_exports.boolean().optional().describe("Whether to auto-approve devices that want to host this service")
22720
22730
  }),
22721
22731
  handler: async (input) => {
22722
- if (input.tags && input.tags.length > 0) {
22723
- const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22724
- if (invalid.length > 0) {
22725
- throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22726
- }
22727
- }
22732
+ validateTags(input.tags);
22728
22733
  const { serviceName, ...body } = input;
22729
22734
  const cleanBody = {};
22730
22735
  for (const [key, value] of Object.entries(body)) {
@@ -23351,7 +23356,7 @@ var workloadIdentityTools = [
23351
23356
  ];
23352
23357
 
23353
23358
  // src/index.ts
23354
- var version2 = true ? "0.6.5" : (await null).createRequire(import.meta.url)("../package.json").version;
23359
+ var version2 = true ? "0.8.0" : (await null).createRequire(import.meta.url)("../package.json").version;
23355
23360
  var subcommand = process.argv[2];
23356
23361
  if (subcommand === "deploy-acl") {
23357
23362
  const filePath = process.argv[3];
@@ -23368,24 +23373,34 @@ if (subcommand === "deploy-acl") {
23368
23373
  console.log(version2);
23369
23374
  process.exit(0);
23370
23375
  }
23371
- var allTools = [
23372
- ...statusTools,
23373
- ...deviceTools,
23374
- ...aclTools,
23375
- ...dnsTools,
23376
- ...keyTools,
23377
- ...userTools,
23378
- ...tailnetTools,
23379
- ...webhookTools,
23380
- ...networkLockTools,
23381
- ...postureTools,
23382
- ...auditTools,
23383
- ...inviteTools,
23384
- ...serviceTools,
23385
- ...logStreamingTools,
23386
- ...workloadIdentityTools,
23387
- ...oauthClientTools
23388
- ];
23376
+ var toolGroups = {
23377
+ status: statusTools,
23378
+ devices: deviceTools,
23379
+ acl: aclTools,
23380
+ dns: dnsTools,
23381
+ keys: keyTools,
23382
+ users: userTools,
23383
+ tailnet: tailnetTools,
23384
+ webhooks: webhookTools,
23385
+ "network-lock": networkLockTools,
23386
+ posture: postureTools,
23387
+ audit: auditTools,
23388
+ invites: inviteTools,
23389
+ services: serviceTools,
23390
+ "log-streaming": logStreamingTools,
23391
+ "workload-identity": workloadIdentityTools,
23392
+ "oauth-clients": oauthClientTools
23393
+ };
23394
+ var { tools: allTools, unknownGroups } = filterTools(toolGroups, {
23395
+ tools: process.env.TAILSCALE_TOOLS,
23396
+ readonly: process.env.TAILSCALE_READONLY
23397
+ });
23398
+ if (unknownGroups.length > 0) {
23399
+ const validNames = Object.keys(toolGroups);
23400
+ console.error(
23401
+ `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}`
23402
+ );
23403
+ }
23389
23404
  var server = new McpServer({
23390
23405
  name: "@yawlabs/tailscale-mcp",
23391
23406
  version: version2
@@ -23487,4 +23502,12 @@ server.resource(
23487
23502
  );
23488
23503
  var transport = new StdioServerTransport();
23489
23504
  await server.connect(transport);
23505
+ var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
23506
+ var filterSuffix = [
23507
+ process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
23508
+ readonlyMode ? "readonly" : null
23509
+ ].filter(Boolean).join(", ");
23510
+ console.error(
23511
+ `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
23512
+ );
23490
23513
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.6.5",
3
+ "version": "0.8.0",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",
@@ -22,7 +22,9 @@
22
22
  "tailscale-mcp": "dist/index.js"
23
23
  },
24
24
  "files": [
25
- "dist/index.js"
25
+ "dist/index.js",
26
+ "LICENSE",
27
+ "README.md"
26
28
  ],
27
29
  "scripts": {
28
30
  "build": "tsc && node build.mjs",
@@ -35,6 +37,10 @@
35
37
  "prepublishOnly": "npm run build"
36
38
  },
37
39
  "dependencies": {},
40
+ "overrides": {
41
+ "hono": "^4.12.14",
42
+ "@hono/node-server": "^1.19.13"
43
+ },
38
44
  "devDependencies": {
39
45
  "@biomejs/biome": "^1.9.4",
40
46
  "@modelcontextprotocol/sdk": "^1.29.0",