@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.
- package/README.md +55 -19
- package/dist/index.js +46 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/tailscale-mcp/stargazers)
|
|
6
6
|
[](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml) [](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
|
|
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.
|
|
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
|
-
- **
|
|
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
|
-
"
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 &&
|
|
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.
|
|
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 {
|
|
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
|