@yawlabs/tailscale-mcp 0.10.9 → 0.11.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 +32 -3
  2. package/dist/index.js +153 -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) {
@@ -32565,6 +32565,145 @@ var keyTools = [
32565
32565
  }
32566
32566
  ];
32567
32567
 
32568
+ // src/tools/local-cli.ts
32569
+ import * as net2 from "node:net";
32570
+
32571
+ // src/local-cli.ts
32572
+ import { execFile as execFileCb } from "node:child_process";
32573
+ var DEFAULT_TIMEOUT_MS = 3e4;
32574
+ var MAX_BUFFER_BYTES = 10 * 1024 * 1024;
32575
+ var execFileImpl = execFileCb;
32576
+ function getBinaryPath() {
32577
+ return process.env.TAILSCALE_BINARY || "tailscale";
32578
+ }
32579
+ async function runTailscaleCli(args, options = {}) {
32580
+ const binary = getBinaryPath();
32581
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
32582
+ return new Promise((resolve) => {
32583
+ execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
32584
+ const stdoutStr = stdout == null ? "" : String(stdout);
32585
+ const stderrStr = stderr == null ? "" : String(stderr);
32586
+ if (err) {
32587
+ const errno = err;
32588
+ if (errno.code === "ENOENT") {
32589
+ resolve({
32590
+ ok: false,
32591
+ error: `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`
32592
+ });
32593
+ return;
32594
+ }
32595
+ if (errno.killed) {
32596
+ resolve({
32597
+ ok: false,
32598
+ error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
32599
+ });
32600
+ return;
32601
+ }
32602
+ const exitCode = typeof errno.code === "number" ? errno.code : void 0;
32603
+ resolve({
32604
+ ok: false,
32605
+ error: stderrStr.trim() || err.message,
32606
+ exitCode
32607
+ });
32608
+ return;
32609
+ }
32610
+ if (options.parseJson) {
32611
+ try {
32612
+ const data = JSON.parse(stdoutStr);
32613
+ resolve({ ok: true, data, exitCode: 0 });
32614
+ } catch (parseErr) {
32615
+ resolve({
32616
+ ok: false,
32617
+ error: `Failed to parse JSON output from '${binary} ${args.join(" ")}': ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
32618
+ rawBody: stdoutStr,
32619
+ exitCode: 0
32620
+ });
32621
+ }
32622
+ return;
32623
+ }
32624
+ resolve({ ok: true, rawBody: stdoutStr, exitCode: 0 });
32625
+ });
32626
+ });
32627
+ }
32628
+
32629
+ // src/tools/local-cli.ts
32630
+ function isValidPingTarget(s) {
32631
+ if (s.length === 0 || s.length > 253) return false;
32632
+ if (net2.isIP(s)) return true;
32633
+ return /^[a-zA-Z0-9._-]+$/.test(s);
32634
+ }
32635
+ var localCliTools = [
32636
+ {
32637
+ name: "tailscale_local_status",
32638
+ 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.",
32639
+ annotations: {
32640
+ title: "Local tailscale status",
32641
+ readOnlyHint: true,
32642
+ destructiveHint: false,
32643
+ idempotentHint: true,
32644
+ openWorldHint: true
32645
+ },
32646
+ inputSchema: external_exports.object({}),
32647
+ handler: async () => runTailscaleCli(["status", "--json"], { parseJson: true })
32648
+ },
32649
+ {
32650
+ name: "tailscale_ping",
32651
+ 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.",
32652
+ annotations: {
32653
+ title: "Tailscale ping",
32654
+ readOnlyHint: true,
32655
+ destructiveHint: false,
32656
+ idempotentHint: true,
32657
+ openWorldHint: true
32658
+ },
32659
+ inputSchema: external_exports.object({
32660
+ target: external_exports.string().describe(
32661
+ "Target hostname, IP, or MagicDNS name (e.g. 'my-laptop', '100.64.0.1', 'my-laptop.tail-scale.ts.net')"
32662
+ ),
32663
+ count: external_exports.number().int().positive().max(20).optional().describe(
32664
+ "Number of ping attempts (default 1, max 20). Higher counts give a better latency picture but block the tool call for longer."
32665
+ )
32666
+ }),
32667
+ handler: async (input) => {
32668
+ if (!isValidPingTarget(input.target)) {
32669
+ throw new Error(
32670
+ `Invalid ping target ${JSON.stringify(input.target)}: must be a hostname, IP, or MagicDNS name (letters, digits, dots, hyphens, underscores; max 253 chars).`
32671
+ );
32672
+ }
32673
+ const args = ["ping"];
32674
+ if (input.count !== void 0) args.push("-c", String(input.count));
32675
+ args.push(input.target);
32676
+ return runTailscaleCli(args);
32677
+ }
32678
+ },
32679
+ {
32680
+ name: "tailscale_netcheck",
32681
+ 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.",
32682
+ annotations: {
32683
+ title: "Tailscale netcheck",
32684
+ readOnlyHint: true,
32685
+ destructiveHint: false,
32686
+ idempotentHint: true,
32687
+ openWorldHint: true
32688
+ },
32689
+ inputSchema: external_exports.object({}),
32690
+ handler: async () => runTailscaleCli(["netcheck", "--format=json"], { parseJson: true })
32691
+ },
32692
+ {
32693
+ name: "tailscale_local_version",
32694
+ 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.",
32695
+ annotations: {
32696
+ title: "Tailscale CLI version",
32697
+ readOnlyHint: true,
32698
+ destructiveHint: false,
32699
+ idempotentHint: true,
32700
+ openWorldHint: true
32701
+ },
32702
+ inputSchema: external_exports.object({}),
32703
+ handler: async () => runTailscaleCli(["version"])
32704
+ }
32705
+ ];
32706
+
32568
32707
  // src/tools/log-streaming.ts
32569
32708
  var logStreamingTools = [
32570
32709
  {
@@ -33467,7 +33606,7 @@ var webhookTools = [
33467
33606
  ];
33468
33607
 
33469
33608
  // src/index.ts
33470
- var version2 = true ? "0.10.9" : (await null).createRequire(import.meta.url)("../package.json").version;
33609
+ var version2 = true ? "0.11.0" : (await null).createRequire(import.meta.url)("../package.json").version;
33471
33610
  var subcommand = process.argv[2];
33472
33611
  if (subcommand === "deploy-acl") {
33473
33612
  const filePath = process.argv[3];
@@ -33499,6 +33638,9 @@ var toolGroups = {
33499
33638
  services: serviceTools,
33500
33639
  "log-streaming": logStreamingTools
33501
33640
  };
33641
+ if (process.env.TAILSCALE_LOCAL_CLI === "1" || process.env.TAILSCALE_LOCAL_CLI === "true") {
33642
+ toolGroups["local-cli"] = localCliTools;
33643
+ }
33502
33644
  var {
33503
33645
  tools: allTools,
33504
33646
  unknownGroups,
@@ -33557,10 +33699,12 @@ var transport = new StdioServerTransport();
33557
33699
  await server.connect(transport);
33558
33700
  var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
33559
33701
  var profileApplied = process.env.TAILSCALE_PROFILE && !unknownProfile ? process.env.TAILSCALE_PROFILE : null;
33702
+ var localCliEnabled = process.env.TAILSCALE_LOCAL_CLI === "1" || process.env.TAILSCALE_LOCAL_CLI === "true";
33560
33703
  var filterSuffix = [
33561
33704
  profileApplied ? `profile=${profileApplied}` : null,
33562
33705
  process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
33563
- readonlyMode ? "readonly" : null
33706
+ readonlyMode ? "readonly" : null,
33707
+ localCliEnabled ? "local-cli=on" : null
33564
33708
  ].filter(Boolean).join(", ");
33565
33709
  console.error(
33566
33710
  `@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.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>",