@yawlabs/tailscale-mcp 0.10.9 → 0.11.1
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 +32 -3
- package/dist/index.js +158 -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)
|
|
7
7
|
|
|
8
|
-
**Ask your agent questions about your tailnet and have it act on the answers.** 89 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ 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.** 89 admin-API tools + 4 optional local-CLI diagnostics + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ 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
|
|
|
@@ -137,7 +137,7 @@ That's it. Now ask your agent:
|
|
|
137
137
|
|
|
138
138
|
Comma-separated group names. Overrides `TAILSCALE_PROFILE` when both are set — use this when the presets aren't quite right.
|
|
139
139
|
|
|
140
|
-
Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`.
|
|
140
|
+
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).
|
|
141
141
|
|
|
142
142
|
### Option 3: `TAILSCALE_READONLY` (drop mutations)
|
|
143
143
|
|
|
@@ -199,6 +199,23 @@ The server checks for an API key first, then falls back to OAuth. If neither is
|
|
|
199
199
|
|
|
200
200
|
**Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
|
|
201
201
|
|
|
202
|
+
## Local CLI integration (opt-in)
|
|
203
|
+
|
|
204
|
+
Most tools talk to the Tailscale v2 admin API — they describe **the tailnet**. Sometimes you want to ask about **this machine's** view: is it actually connected? What DERP region is it on? How far is `my-laptop` from here? Those answers come from the local `tailscale` binary, not the admin API.
|
|
205
|
+
|
|
206
|
+
Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add four read-only diagnostic tools:
|
|
207
|
+
|
|
208
|
+
| Tool | Equivalent CLI command | Use it for |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `tailscale_local_status` | `tailscale status --json` | This machine's connection state + peers it can see |
|
|
211
|
+
| `tailscale_ping` | `tailscale ping <target>` | Latency probe to another tailnet node (direct vs DERP-relayed) |
|
|
212
|
+
| `tailscale_netcheck` | `tailscale netcheck --format=json` | NAT type, DERP latency map, IPv4/IPv6 support |
|
|
213
|
+
| `tailscale_local_version` | `tailscale version` | Which client version is actually running |
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.10.9 ready (93 tools, local-cli=on)`.
|
|
218
|
+
|
|
202
219
|
## Resources (4)
|
|
203
220
|
|
|
204
221
|
MCP Resources expose read-only data clients can browse without a tool call.
|
|
@@ -210,7 +227,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
210
227
|
| ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
|
|
211
228
|
| DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
|
|
212
229
|
|
|
213
|
-
## Tools (89)
|
|
230
|
+
## Tools (89 + 4 opt-in)
|
|
214
231
|
|
|
215
232
|
<details>
|
|
216
233
|
<summary><strong>Status</strong> (1 tool)</summary>
|
|
@@ -413,6 +430,18 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
413
430
|
|
|
414
431
|
</details>
|
|
415
432
|
|
|
433
|
+
<details>
|
|
434
|
+
<summary><strong>Local CLI</strong> (4 tools, opt-in) — see <a href="#local-cli-integration-opt-in">Local CLI integration</a></summary>
|
|
435
|
+
|
|
436
|
+
| Tool | Description |
|
|
437
|
+
|------|-------------|
|
|
438
|
+
| `tailscale_local_status` | This machine's view of the tailnet (own connection state, peers, DERP region) |
|
|
439
|
+
| `tailscale_ping` | Latency probe to another tailnet node from this machine |
|
|
440
|
+
| `tailscale_netcheck` | NAT type, DERP latency map, IPv4/IPv6 support diagnostics |
|
|
441
|
+
| `tailscale_local_version` | Local `tailscale` binary version |
|
|
442
|
+
|
|
443
|
+
</details>
|
|
444
|
+
|
|
416
445
|
## GitOps: deploy ACLs from CI
|
|
417
446
|
|
|
418
447
|
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:
|
package/dist/index.js
CHANGED
|
@@ -3303,8 +3303,8 @@ var require_utils = __commonJS({
|
|
|
3303
3303
|
var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
|
|
3304
3304
|
var HOST_DELIM_RE = /[@/?#:]/g;
|
|
3305
3305
|
var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
|
|
3306
|
-
function reescapeHostDelimiters(host,
|
|
3307
|
-
const re =
|
|
3306
|
+
function reescapeHostDelimiters(host, isIP2) {
|
|
3307
|
+
const re = isIP2 ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
|
|
3308
3308
|
re.lastIndex = 0;
|
|
3309
3309
|
return host.replace(re, (ch) => HOST_DELIMS[ch]);
|
|
3310
3310
|
}
|
|
@@ -3786,7 +3786,7 @@ var require_fast_uri = __commonJS({
|
|
|
3786
3786
|
fragment: void 0
|
|
3787
3787
|
};
|
|
3788
3788
|
let malformedAuthorityOrPort = false;
|
|
3789
|
-
let
|
|
3789
|
+
let isIP2 = false;
|
|
3790
3790
|
if (options.reference === "suffix") {
|
|
3791
3791
|
if (options.scheme) {
|
|
3792
3792
|
uri = options.scheme + ":" + uri;
|
|
@@ -3816,9 +3816,9 @@ var require_fast_uri = __commonJS({
|
|
|
3816
3816
|
if (ipv4result === false) {
|
|
3817
3817
|
const ipv6result = normalizeIPv6(parsed.host);
|
|
3818
3818
|
parsed.host = ipv6result.host.toLowerCase();
|
|
3819
|
-
|
|
3819
|
+
isIP2 = ipv6result.isIPV6;
|
|
3820
3820
|
} else {
|
|
3821
|
-
|
|
3821
|
+
isIP2 = true;
|
|
3822
3822
|
}
|
|
3823
3823
|
}
|
|
3824
3824
|
if (parsed.scheme === void 0 && parsed.userinfo === void 0 && parsed.host === void 0 && parsed.port === void 0 && parsed.query === void 0 && !parsed.path) {
|
|
@@ -3835,7 +3835,7 @@ var require_fast_uri = __commonJS({
|
|
|
3835
3835
|
}
|
|
3836
3836
|
const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme);
|
|
3837
3837
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
|
|
3838
|
-
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) &&
|
|
3838
|
+
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP2 === false && nonSimpleDomain(parsed.host)) {
|
|
3839
3839
|
try {
|
|
3840
3840
|
parsed.host = URL.domainToASCII(parsed.host.toLowerCase());
|
|
3841
3841
|
} catch (e) {
|
|
@@ -3849,7 +3849,7 @@ var require_fast_uri = __commonJS({
|
|
|
3849
3849
|
parsed.scheme = unescape(parsed.scheme);
|
|
3850
3850
|
}
|
|
3851
3851
|
if (parsed.host !== void 0) {
|
|
3852
|
-
parsed.host = reescapeHostDelimiters(unescape(parsed.host),
|
|
3852
|
+
parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP2);
|
|
3853
3853
|
}
|
|
3854
3854
|
}
|
|
3855
3855
|
if (parsed.path) {
|
|
@@ -31318,6 +31318,9 @@ function filterTools(groups, options) {
|
|
|
31318
31318
|
}
|
|
31319
31319
|
|
|
31320
31320
|
// src/server-wiring.ts
|
|
31321
|
+
function isLocalCliEnabled(env) {
|
|
31322
|
+
return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
|
|
31323
|
+
}
|
|
31321
31324
|
function wrapToolHandler(tool) {
|
|
31322
31325
|
return async (input) => {
|
|
31323
31326
|
try {
|
|
@@ -32565,6 +32568,147 @@ var keyTools = [
|
|
|
32565
32568
|
}
|
|
32566
32569
|
];
|
|
32567
32570
|
|
|
32571
|
+
// src/tools/local-cli.ts
|
|
32572
|
+
import * as net2 from "node:net";
|
|
32573
|
+
|
|
32574
|
+
// src/local-cli.ts
|
|
32575
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
32576
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
32577
|
+
var MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
32578
|
+
var execFileImpl = execFileCb;
|
|
32579
|
+
function getBinaryPath() {
|
|
32580
|
+
return process.env.TAILSCALE_BINARY || "tailscale";
|
|
32581
|
+
}
|
|
32582
|
+
async function runTailscaleCli(args, options = {}) {
|
|
32583
|
+
const binary = getBinaryPath();
|
|
32584
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
32585
|
+
return new Promise((resolve) => {
|
|
32586
|
+
execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
|
|
32587
|
+
const stdoutStr = stdout == null ? "" : String(stdout);
|
|
32588
|
+
const stderrStr = stderr == null ? "" : String(stderr);
|
|
32589
|
+
if (err) {
|
|
32590
|
+
const errno = err;
|
|
32591
|
+
if (errno.code === "ENOENT") {
|
|
32592
|
+
resolve({
|
|
32593
|
+
ok: false,
|
|
32594
|
+
error: `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`
|
|
32595
|
+
});
|
|
32596
|
+
return;
|
|
32597
|
+
}
|
|
32598
|
+
if (errno.killed) {
|
|
32599
|
+
resolve({
|
|
32600
|
+
ok: false,
|
|
32601
|
+
error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
|
|
32602
|
+
});
|
|
32603
|
+
return;
|
|
32604
|
+
}
|
|
32605
|
+
const exitCode = typeof errno.code === "number" ? errno.code : void 0;
|
|
32606
|
+
resolve({
|
|
32607
|
+
ok: false,
|
|
32608
|
+
error: stderrStr.trim() || err.message,
|
|
32609
|
+
exitCode
|
|
32610
|
+
});
|
|
32611
|
+
return;
|
|
32612
|
+
}
|
|
32613
|
+
if (options.parseJson) {
|
|
32614
|
+
try {
|
|
32615
|
+
const data = JSON.parse(stdoutStr);
|
|
32616
|
+
resolve({ ok: true, data, exitCode: 0 });
|
|
32617
|
+
} catch (parseErr) {
|
|
32618
|
+
resolve({
|
|
32619
|
+
ok: false,
|
|
32620
|
+
error: `Failed to parse JSON output from '${binary} ${args.join(" ")}': ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
32621
|
+
rawBody: stdoutStr,
|
|
32622
|
+
exitCode: 0
|
|
32623
|
+
});
|
|
32624
|
+
}
|
|
32625
|
+
return;
|
|
32626
|
+
}
|
|
32627
|
+
resolve({ ok: true, rawBody: stdoutStr, exitCode: 0 });
|
|
32628
|
+
});
|
|
32629
|
+
});
|
|
32630
|
+
}
|
|
32631
|
+
|
|
32632
|
+
// src/tools/local-cli.ts
|
|
32633
|
+
var HOSTNAME_LABEL = /^[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?$/;
|
|
32634
|
+
function isValidPingTarget(s) {
|
|
32635
|
+
if (s.length === 0 || s.length > 253) return false;
|
|
32636
|
+
if (net2.isIP(s)) return true;
|
|
32637
|
+
const labels = s.split(".");
|
|
32638
|
+
return labels.every((label) => label.length >= 1 && label.length <= 63 && HOSTNAME_LABEL.test(label));
|
|
32639
|
+
}
|
|
32640
|
+
var localCliTools = [
|
|
32641
|
+
{
|
|
32642
|
+
name: "tailscale_local_status",
|
|
32643
|
+
description: "Get this machine's view of its tailnet -- own connection state, peers it can see, DERP region, MagicDNS suffix, etc. Shells out to the local `tailscale` binary; distinct from `tailscale_status`, which queries the admin API for tailnet-wide info. Requires the tailscale CLI installed locally and TAILSCALE_LOCAL_CLI=1.",
|
|
32644
|
+
annotations: {
|
|
32645
|
+
title: "Local tailscale status",
|
|
32646
|
+
readOnlyHint: true,
|
|
32647
|
+
destructiveHint: false,
|
|
32648
|
+
idempotentHint: true,
|
|
32649
|
+
openWorldHint: true
|
|
32650
|
+
},
|
|
32651
|
+
inputSchema: external_exports.object({}),
|
|
32652
|
+
handler: async () => runTailscaleCli(["status", "--json"], { parseJson: true })
|
|
32653
|
+
},
|
|
32654
|
+
{
|
|
32655
|
+
name: "tailscale_ping",
|
|
32656
|
+
description: "Probe latency to another tailnet node from this machine. Useful for connectivity debugging -- shows whether the path is direct or DERP-relayed, plus RTT. Returns the CLI's text output verbatim.",
|
|
32657
|
+
annotations: {
|
|
32658
|
+
title: "Tailscale ping",
|
|
32659
|
+
readOnlyHint: true,
|
|
32660
|
+
destructiveHint: false,
|
|
32661
|
+
idempotentHint: true,
|
|
32662
|
+
openWorldHint: true
|
|
32663
|
+
},
|
|
32664
|
+
inputSchema: external_exports.object({
|
|
32665
|
+
target: external_exports.string().describe(
|
|
32666
|
+
"Target hostname, IP, or MagicDNS name (e.g. 'my-laptop', '100.64.0.1', 'my-laptop.tail-scale.ts.net')"
|
|
32667
|
+
),
|
|
32668
|
+
count: external_exports.number().int().positive().max(20).optional().describe(
|
|
32669
|
+
"Number of ping attempts (default 1, max 20). Higher counts give a better latency picture but block the tool call for longer."
|
|
32670
|
+
)
|
|
32671
|
+
}),
|
|
32672
|
+
handler: async (input) => {
|
|
32673
|
+
if (!isValidPingTarget(input.target)) {
|
|
32674
|
+
throw new Error(
|
|
32675
|
+
`Invalid ping target ${JSON.stringify(input.target)}: must be a hostname, IP, or MagicDNS name (letters, digits, dots, hyphens, underscores; max 253 chars).`
|
|
32676
|
+
);
|
|
32677
|
+
}
|
|
32678
|
+
const args = ["ping"];
|
|
32679
|
+
if (input.count !== void 0) args.push("-c", String(input.count));
|
|
32680
|
+
args.push(input.target);
|
|
32681
|
+
return runTailscaleCli(args);
|
|
32682
|
+
}
|
|
32683
|
+
},
|
|
32684
|
+
{
|
|
32685
|
+
name: "tailscale_netcheck",
|
|
32686
|
+
description: "Run Tailscale's network connectivity diagnostics from this machine: NAT type, DERP region latency map, IPv4/IPv6 support, UPnP/PMP/PCP status. Equivalent to `tailscale netcheck --format=json`. Useful when an agent reports flaky connectivity and you want to know whether to point fingers at the NAT, the upstream, or DERP.",
|
|
32687
|
+
annotations: {
|
|
32688
|
+
title: "Tailscale netcheck",
|
|
32689
|
+
readOnlyHint: true,
|
|
32690
|
+
destructiveHint: false,
|
|
32691
|
+
idempotentHint: true,
|
|
32692
|
+
openWorldHint: true
|
|
32693
|
+
},
|
|
32694
|
+
inputSchema: external_exports.object({}),
|
|
32695
|
+
handler: async () => runTailscaleCli(["netcheck", "--format=json"], { parseJson: true })
|
|
32696
|
+
},
|
|
32697
|
+
{
|
|
32698
|
+
name: "tailscale_local_version",
|
|
32699
|
+
description: "Get the version of the local `tailscale` binary. Different from the control plane / admin API version. Use this when filing a bug to report the client version actually in use.",
|
|
32700
|
+
annotations: {
|
|
32701
|
+
title: "Tailscale CLI version",
|
|
32702
|
+
readOnlyHint: true,
|
|
32703
|
+
destructiveHint: false,
|
|
32704
|
+
idempotentHint: true,
|
|
32705
|
+
openWorldHint: true
|
|
32706
|
+
},
|
|
32707
|
+
inputSchema: external_exports.object({}),
|
|
32708
|
+
handler: async () => runTailscaleCli(["version"])
|
|
32709
|
+
}
|
|
32710
|
+
];
|
|
32711
|
+
|
|
32568
32712
|
// src/tools/log-streaming.ts
|
|
32569
32713
|
var logStreamingTools = [
|
|
32570
32714
|
{
|
|
@@ -33467,7 +33611,7 @@ var webhookTools = [
|
|
|
33467
33611
|
];
|
|
33468
33612
|
|
|
33469
33613
|
// src/index.ts
|
|
33470
|
-
var version2 = true ? "0.
|
|
33614
|
+
var version2 = true ? "0.11.1" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
33471
33615
|
var subcommand = process.argv[2];
|
|
33472
33616
|
if (subcommand === "deploy-acl") {
|
|
33473
33617
|
const filePath = process.argv[3];
|
|
@@ -33499,6 +33643,10 @@ var toolGroups = {
|
|
|
33499
33643
|
services: serviceTools,
|
|
33500
33644
|
"log-streaming": logStreamingTools
|
|
33501
33645
|
};
|
|
33646
|
+
var localCliEnabled = isLocalCliEnabled(process.env);
|
|
33647
|
+
if (localCliEnabled) {
|
|
33648
|
+
toolGroups["local-cli"] = localCliTools;
|
|
33649
|
+
}
|
|
33502
33650
|
var {
|
|
33503
33651
|
tools: allTools,
|
|
33504
33652
|
unknownGroups,
|
|
@@ -33560,7 +33708,8 @@ var profileApplied = process.env.TAILSCALE_PROFILE && !unknownProfile ? process.
|
|
|
33560
33708
|
var filterSuffix = [
|
|
33561
33709
|
profileApplied ? `profile=${profileApplied}` : null,
|
|
33562
33710
|
process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
|
|
33563
|
-
readonlyMode ? "readonly" : null
|
|
33711
|
+
readonlyMode ? "readonly" : null,
|
|
33712
|
+
localCliEnabled ? "local-cli=on" : null
|
|
33564
33713
|
].filter(Boolean).join(", ");
|
|
33565
33714
|
console.error(
|
|
33566
33715
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|