@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.
Files changed (3) hide show
  1. package/README.md +32 -3
  2. package/dist/index.js +158 -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)
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, isIP) {
3307
- const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_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 isIP = false;
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
- isIP = ipv6result.isIPV6;
3819
+ isIP2 = ipv6result.isIPV6;
3820
3820
  } else {
3821
- isIP = true;
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) && isIP === false && nonSimpleDomain(parsed.host)) {
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), isIP);
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.10.9" : (await null).createRequire(import.meta.url)("../package.json").version;
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}` : ""})`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.10.9",
3
+ "version": "0.11.1",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",