@yawlabs/tailscale-mcp 0.22.0 → 0.23.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 +5 -5
- package/bin/tailscale-mcp.mjs +1 -1
- package/dist/index.js +41 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ Profile names are case-insensitive and ignore surrounding whitespace (`Core` sel
|
|
|
152
152
|
|
|
153
153
|
Comma-separated group names. Overrides `TAILSCALE_PROFILE` when both are set — use this when the presets aren't quite right.
|
|
154
154
|
|
|
155
|
-
Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`. The `local-cli` group is also available, but only when `TAILSCALE_LOCAL_CLI=1` is set — see [Local CLI integration](#local-cli-integration-opt-in).
|
|
155
|
+
Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `org-tailnets`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`. The `local-cli` group is also available, but only when `TAILSCALE_LOCAL_CLI=1` is set — see [Local CLI integration](#local-cli-integration-opt-in).
|
|
156
156
|
|
|
157
157
|
### Option 3: `TAILSCALE_READONLY` (drop mutations)
|
|
158
158
|
|
|
@@ -172,13 +172,13 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
|
|
|
172
172
|
The server logs the active filter to stderr on startup:
|
|
173
173
|
|
|
174
174
|
```
|
|
175
|
-
@yawlabs/tailscale-mcp v0.
|
|
175
|
+
@yawlabs/tailscale-mcp v0.22.0 ready (20 tools, profile=minimal, readonly)
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
When both `TAILSCALE_PROFILE` and `TAILSCALE_TOOLS` are set, `TAILSCALE_TOOLS` wins. The banner marks the profile as overridden so the precedence is obvious at a glance — no need to guess which filter actually applied:
|
|
179
179
|
|
|
180
180
|
```
|
|
181
|
-
@yawlabs/tailscale-mcp v0.
|
|
181
|
+
@yawlabs/tailscale-mcp v0.22.0 ready (22 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
The "(overridden)" marker only fires for substantive profiles (`minimal` / `core`); `profile=full` is a no-op preset, so it's shown without the marker when `TAILSCALE_TOOLS` is also set.
|
|
@@ -286,7 +286,7 @@ Names are case-sensitive, matching `TAILSCALE_TOOLS`.
|
|
|
286
286
|
The startup banner shows what applied:
|
|
287
287
|
|
|
288
288
|
```
|
|
289
|
-
@yawlabs/tailscale-mcp v0.
|
|
289
|
+
@yawlabs/tailscale-mcp v0.22.0 ready (59 tools, write=devices,keys)
|
|
290
290
|
```
|
|
291
291
|
|
|
292
292
|
## Requiring approval on irreversible tools
|
|
@@ -417,7 +417,7 @@ Requirements: the `tailscale` binary has to be findable. It's looked up on `PATH
|
|
|
417
417
|
|
|
418
418
|
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.
|
|
419
419
|
|
|
420
|
-
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.
|
|
420
|
+
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.22.0 ready (103 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 97.
|
|
421
421
|
|
|
422
422
|
## Resources (4)
|
|
423
423
|
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -401,7 +401,7 @@ function sandboxFlags() {
|
|
|
401
401
|
// TEMP), TMP its POSIX-style twin, APPDATA and LOCALAPPDATA where Windows
|
|
402
402
|
// programs keep per-user state, and HOME the POSIX home directory. Granting a
|
|
403
403
|
// name that is not set is harmless: it stays absent. WSL_DISTRO_NAME is the
|
|
404
|
-
// server's own: local-cli.ts reads it to recognise WSL when the CLI is
|
|
404
|
+
// server's own: local-cli-runner.ts reads it to recognise WSL when the CLI is
|
|
405
405
|
// missing, and its fallback, /proc/version, is a file read the sandbox denies.
|
|
406
406
|
const env = [
|
|
407
407
|
"APPDATA",
|
package/dist/index.js
CHANGED
|
@@ -33263,7 +33263,7 @@ var userTools = [
|
|
|
33263
33263
|
openWorldHint: true
|
|
33264
33264
|
},
|
|
33265
33265
|
inputSchema: external_exports.object({
|
|
33266
|
-
userId: external_exports.string().describe("The user ID")
|
|
33266
|
+
userId: external_exports.string().trim().min(1).describe("The user ID")
|
|
33267
33267
|
}),
|
|
33268
33268
|
handler: async (input) => {
|
|
33269
33269
|
return apiGet(`/users/${encPath(input.userId)}`);
|
|
@@ -33280,7 +33280,7 @@ var userTools = [
|
|
|
33280
33280
|
openWorldHint: true
|
|
33281
33281
|
},
|
|
33282
33282
|
inputSchema: external_exports.object({
|
|
33283
|
-
userId: external_exports.string().describe("The user ID to approve")
|
|
33283
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to approve")
|
|
33284
33284
|
}),
|
|
33285
33285
|
handler: async (input) => {
|
|
33286
33286
|
return apiPost(`/users/${encPath(input.userId)}/approve`);
|
|
@@ -33297,7 +33297,10 @@ var userTools = [
|
|
|
33297
33297
|
openWorldHint: true
|
|
33298
33298
|
},
|
|
33299
33299
|
inputSchema: external_exports.object({
|
|
33300
|
-
|
|
33300
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
|
|
33301
|
+
// to "%20" and suspend returns a 404 that reads like the user is already
|
|
33302
|
+
// gone when it is not (rationale: tailnets.ts delete_tailnet).
|
|
33303
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to suspend")
|
|
33301
33304
|
}),
|
|
33302
33305
|
handler: async (input) => {
|
|
33303
33306
|
return apiPost(`/users/${encPath(input.userId)}/suspend`);
|
|
@@ -33314,7 +33317,7 @@ var userTools = [
|
|
|
33314
33317
|
openWorldHint: true
|
|
33315
33318
|
},
|
|
33316
33319
|
inputSchema: external_exports.object({
|
|
33317
|
-
userId: external_exports.string().describe("The user ID to restore")
|
|
33320
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to restore")
|
|
33318
33321
|
}),
|
|
33319
33322
|
handler: async (input) => {
|
|
33320
33323
|
return apiPost(`/users/${encPath(input.userId)}/restore`);
|
|
@@ -33331,7 +33334,7 @@ var userTools = [
|
|
|
33331
33334
|
openWorldHint: true
|
|
33332
33335
|
},
|
|
33333
33336
|
inputSchema: external_exports.object({
|
|
33334
|
-
userId: external_exports.string().describe("The user ID"),
|
|
33337
|
+
userId: external_exports.string().trim().min(1).describe("The user ID"),
|
|
33335
33338
|
role: external_exports.enum(USER_ROLES).describe("The new role to assign")
|
|
33336
33339
|
}),
|
|
33337
33340
|
handler: async (input) => {
|
|
@@ -33374,7 +33377,7 @@ var inviteTools = [
|
|
|
33374
33377
|
openWorldHint: true
|
|
33375
33378
|
},
|
|
33376
33379
|
inputSchema: external_exports.object({
|
|
33377
|
-
deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
|
|
33380
|
+
deviceId: external_exports.string().trim().min(1).describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
|
|
33378
33381
|
}),
|
|
33379
33382
|
handler: async (input) => {
|
|
33380
33383
|
return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
|
|
@@ -33391,7 +33394,7 @@ var inviteTools = [
|
|
|
33391
33394
|
openWorldHint: true
|
|
33392
33395
|
},
|
|
33393
33396
|
inputSchema: external_exports.object({
|
|
33394
|
-
deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
|
|
33397
|
+
deviceId: external_exports.string().trim().min(1).describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
|
|
33395
33398
|
multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
33396
33399
|
allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
|
|
33397
33400
|
email: external_exports.email().optional().describe("Email address to send the invite to")
|
|
@@ -33418,7 +33421,7 @@ var inviteTools = [
|
|
|
33418
33421
|
openWorldHint: true
|
|
33419
33422
|
},
|
|
33420
33423
|
inputSchema: external_exports.object({
|
|
33421
|
-
inviteId: external_exports.string().describe("The device invite ID")
|
|
33424
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID")
|
|
33422
33425
|
}),
|
|
33423
33426
|
handler: async (input) => {
|
|
33424
33427
|
return apiGet(`/device-invites/${encPath(input.inviteId)}`);
|
|
@@ -33435,7 +33438,10 @@ var inviteTools = [
|
|
|
33435
33438
|
openWorldHint: true
|
|
33436
33439
|
},
|
|
33437
33440
|
inputSchema: external_exports.object({
|
|
33438
|
-
inviteId: external_exports.string().describe("The device invite ID to delete")
|
|
33441
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID to delete")
|
|
33442
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
|
|
33443
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33444
|
+
// invite is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33439
33445
|
}),
|
|
33440
33446
|
handler: async (input) => {
|
|
33441
33447
|
return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
|
|
@@ -33509,7 +33515,7 @@ var inviteTools = [
|
|
|
33509
33515
|
openWorldHint: true
|
|
33510
33516
|
},
|
|
33511
33517
|
inputSchema: external_exports.object({
|
|
33512
|
-
inviteId: external_exports.string().describe("The user invite ID")
|
|
33518
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID")
|
|
33513
33519
|
}),
|
|
33514
33520
|
handler: async (input) => {
|
|
33515
33521
|
return apiGet(`/user-invites/${encPath(input.inviteId)}`);
|
|
@@ -33526,7 +33532,10 @@ var inviteTools = [
|
|
|
33526
33532
|
openWorldHint: true
|
|
33527
33533
|
},
|
|
33528
33534
|
inputSchema: external_exports.object({
|
|
33529
|
-
inviteId: external_exports.string().describe("The user invite ID to delete")
|
|
33535
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID to delete")
|
|
33536
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
|
|
33537
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33538
|
+
// invite is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33530
33539
|
}),
|
|
33531
33540
|
handler: async (input) => {
|
|
33532
33541
|
return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
|
|
@@ -33544,7 +33553,7 @@ var inviteTools = [
|
|
|
33544
33553
|
openWorldHint: true
|
|
33545
33554
|
},
|
|
33546
33555
|
inputSchema: external_exports.object({
|
|
33547
|
-
inviteId: external_exports.string().describe("The device invite ID to resend")
|
|
33556
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID to resend")
|
|
33548
33557
|
}),
|
|
33549
33558
|
handler: async (input) => {
|
|
33550
33559
|
return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
|
|
@@ -33562,7 +33571,7 @@ var inviteTools = [
|
|
|
33562
33571
|
openWorldHint: true
|
|
33563
33572
|
},
|
|
33564
33573
|
inputSchema: external_exports.object({
|
|
33565
|
-
inviteId: external_exports.string().describe("The user invite ID to resend")
|
|
33574
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID to resend")
|
|
33566
33575
|
}),
|
|
33567
33576
|
handler: async (input) => {
|
|
33568
33577
|
return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
|
|
@@ -33863,7 +33872,7 @@ var keyTools = [
|
|
|
33863
33872
|
// src/tools/local-cli.ts
|
|
33864
33873
|
import * as net2 from "node:net";
|
|
33865
33874
|
|
|
33866
|
-
// src/local-cli.ts
|
|
33875
|
+
// src/local-cli-runner.ts
|
|
33867
33876
|
import { execFile as execFileCb } from "node:child_process";
|
|
33868
33877
|
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
33869
33878
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -33959,6 +33968,7 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33959
33968
|
if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
33960
33969
|
resolve({
|
|
33961
33970
|
ok: false,
|
|
33971
|
+
kind: "output-limit",
|
|
33962
33972
|
error: `'${binary} ${args.join(" ")}' exceeded the ${MAX_BUFFER_BYTES / 1024 / 1024} MB output limit and was aborted -- no output was captured. This usually means a very large tailnet; narrow the query if the command supports it.`
|
|
33963
33973
|
});
|
|
33964
33974
|
return;
|
|
@@ -33966,6 +33976,7 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33966
33976
|
if (errno.killed) {
|
|
33967
33977
|
resolve({
|
|
33968
33978
|
ok: false,
|
|
33979
|
+
kind: "timeout",
|
|
33969
33980
|
error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
|
|
33970
33981
|
});
|
|
33971
33982
|
return;
|
|
@@ -34029,10 +34040,10 @@ var localCliTools = [
|
|
|
34029
34040
|
if (input.peers === false) args.push("--peers=false");
|
|
34030
34041
|
if (input.activeOnly) args.push("--active");
|
|
34031
34042
|
const result = await runTailscaleCli(args, { parseJson: true });
|
|
34032
|
-
if (!result.ok && result.
|
|
34043
|
+
if (!result.ok && result.kind === "output-limit") {
|
|
34033
34044
|
return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
|
|
34034
34045
|
}
|
|
34035
|
-
if (!result.ok && result.
|
|
34046
|
+
if (!result.ok && result.kind === "timeout") {
|
|
34036
34047
|
return {
|
|
34037
34048
|
...result,
|
|
34038
34049
|
error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
|
|
@@ -34416,7 +34427,7 @@ var postureTools = [
|
|
|
34416
34427
|
openWorldHint: true
|
|
34417
34428
|
},
|
|
34418
34429
|
inputSchema: external_exports.object({
|
|
34419
|
-
integrationId: external_exports.string().describe("The posture integration ID")
|
|
34430
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID")
|
|
34420
34431
|
}),
|
|
34421
34432
|
handler: async (input) => {
|
|
34422
34433
|
return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
|
|
@@ -34471,7 +34482,7 @@ var postureTools = [
|
|
|
34471
34482
|
openWorldHint: true
|
|
34472
34483
|
},
|
|
34473
34484
|
inputSchema: external_exports.object({
|
|
34474
|
-
integrationId: external_exports.string().describe("The posture integration ID to update"),
|
|
34485
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to update"),
|
|
34475
34486
|
clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
|
|
34476
34487
|
clientSecret: external_exports.string().optional().describe(
|
|
34477
34488
|
"Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
@@ -34502,7 +34513,10 @@ var postureTools = [
|
|
|
34502
34513
|
openWorldHint: true
|
|
34503
34514
|
},
|
|
34504
34515
|
inputSchema: external_exports.object({
|
|
34505
|
-
integrationId: external_exports.string().describe("The posture integration ID to delete")
|
|
34516
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to delete")
|
|
34517
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only integrationId
|
|
34518
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
34519
|
+
// like the integration is already gone (rationale: tailnets.ts delete_tailnet).
|
|
34506
34520
|
}),
|
|
34507
34521
|
handler: async (input) => {
|
|
34508
34522
|
return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
|
|
@@ -34694,7 +34708,13 @@ var statusTools = [
|
|
|
34694
34708
|
handler: async () => {
|
|
34695
34709
|
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
34696
34710
|
if (!devicesRes.ok && !settingsRes.ok) {
|
|
34697
|
-
|
|
34711
|
+
if (settingsRes.error === devicesRes.error && settingsRes.status === devicesRes.status) {
|
|
34712
|
+
return devicesRes;
|
|
34713
|
+
}
|
|
34714
|
+
return {
|
|
34715
|
+
...devicesRes,
|
|
34716
|
+
error: `${devicesRes.error || `HTTP ${devicesRes.status}`} (the settings fetch also failed: ${settingsRes.error || `HTTP ${settingsRes.status}`})`
|
|
34717
|
+
};
|
|
34698
34718
|
}
|
|
34699
34719
|
const data = composeTailnetStatusData(devicesRes, settingsRes, {
|
|
34700
34720
|
connected: true,
|
|
@@ -35482,7 +35502,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
|
|
|
35482
35502
|
);
|
|
35483
35503
|
process.exit(1);
|
|
35484
35504
|
}
|
|
35485
|
-
var version2 = true ? "0.
|
|
35505
|
+
var version2 = true ? "0.23.0" : resolveVersionFallback();
|
|
35486
35506
|
var subcommand = process.argv[2];
|
|
35487
35507
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
35488
35508
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/tailscale-mcp",
|
|
5
5
|
"description": "Tailscale MCP server: admin-API tools for devices, ACLs, DNS, auth keys, users, and audit logs, plus a CLI to validate and deploy ACLs.",
|
|
6
6
|
"license": "MIT",
|