@yawlabs/tailscale-mcp 0.13.3 → 0.15.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 CHANGED
@@ -202,6 +202,8 @@ The server checks for an API key first, then falls back to OAuth. If neither is
202
202
 
203
203
  **`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including 429 retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the 429 immediately instead of holding the line. Tune lower if your MCP client has a tighter outer timeout. 429s on non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
204
204
 
205
+ **`TAILSCALE_RETRY_BASE_DELAY_MS=N`** — base delay for the exponential backoff between retries; attempt `N` waits `base * 2^N` (capped at 30s, plus jitter). Default `1000` (1s), so a fully-exhausted retry chain spends roughly 1s + 2s + 4s sleeping. Pairs with `TAILSCALE_REQUEST_BUDGET_MS`: lowering the budget on its own doesn't get you more retries, it just makes the default backoff exhaust the budget sooner and give up. Shrink both if you want "retry hard, fail fast". A server-supplied `Retry-After` header always wins over this value.
206
+
205
207
  **`TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB`** — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate `subscriptions` against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. Please also [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) so the static list catches up.
206
208
 
207
209
  **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).
@@ -221,7 +223,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add fo
221
223
 
222
224
  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.
223
225
 
224
- When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.10.9 ready (89 tools, local-cli=on)`.
226
+ When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (93 tools, local-cli=on)` — the 4 local CLI tools are additive on top of the default 89.
225
227
 
226
228
  ## Resources (4)
227
229
 
@@ -507,6 +509,31 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
507
509
  - Node.js 20+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
508
510
  - A Tailscale API key or OAuth client credentials
509
511
 
512
+ ## Running on oam.js (optional)
513
+
514
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 89 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
515
+
516
+ ```jsonc
517
+ {
518
+ "mcpServers": {
519
+ "tailscale": {
520
+ "command": "oam",
521
+ "args": ["run", "/path/to/tailscale-mcp/dist/index.js"],
522
+ "env": { "TAILSCALE_API_KEY": "tskey-api-..." }
523
+ }
524
+ }
525
+ }
526
+ ```
527
+
528
+ **Node stays the default, deliberately.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Preferring oam automatically would mean either a launcher that probes for it on every start — a cost paid by everyone, including the majority who do not have oam — or making oam a hard requirement. Neither is worth it to reach a runtime that is not faster here. Measure on your own hardware before concluding anything; if oam wins on yours, the config above is all you need.
529
+
530
+ Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
531
+
532
+ - **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 4015ms against 7680ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default.
533
+ - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of Node SEA. Measured ~57.7 MB against ~73.6 MB for the Node SEA carrier *before* its blob is injected. Writes to the same `bin/<platform>-<arch>/` path as `npm run build:binary`, so the release staging script consumes either unchanged — run one or the other. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it.
534
+
535
+ The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal. Note that any `oam` invocation writes a bytecode cache to `oam/` in the working directory — already in `.gitignore`.
536
+
510
537
  ## Contributing
511
538
 
512
539
  Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the PR workflow and AI-agent guidelines. Please [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) to discuss before a PR for anything beyond a typo fix.
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/tailscale-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ *
9
+ * WHY THE FALLBACK COSTS NOTHING
10
+ * npm has already started Node to run this launcher, so falling back is a
11
+ * plain `import()` of the server into THIS process: no extra spawn, no extra
12
+ * startup, byte-identical to invoking dist/index.js directly. Discovery is
13
+ * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
14
+ *
15
+ * WHAT THE OAM PATH COSTS
16
+ * Reaching oam through an npm `bin` means Node boots first and oam boots
17
+ * second, so the launcher is slower than either runtime alone. Measured on
18
+ * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
19
+ * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
20
+ * launcher is the slowest path -- it exists for `npx` convenience.
21
+ *
22
+ * For an MCP host config, point straight at oam and skip this file:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
+ *
25
+ * SELECTION
26
+ * TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
+ * TAILSCALE_MCP_RUNTIME=node never use oam
28
+ * TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
29
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync } from "node:fs";
34
+ import { constants, homedir } from "node:os";
35
+ import { delimiter, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
41
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
42
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
43
+ const isWin = process.platform === "win32";
44
+ const exe = isWin ? "oam.exe" : "oam";
45
+
46
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
47
+ function findOam() {
48
+ // 1. Explicit override wins and is never second-guessed.
49
+ const override = process.env.OAM_BIN;
50
+ if (override) return existsSync(override) ? override : null;
51
+
52
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
53
+ // usually has oam/target/release on PATH, and a build directory is the
54
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
55
+ // binary underneath running processes, and the dev build is not the
56
+ // release the user installed. Preferring the installed copy makes the
57
+ // default path "what a normal user has", and OAM_BIN remains the way to
58
+ // point deliberately at a dev build.
59
+ //
60
+ // Both forms are checked on Windows: the installer defaults to
61
+ // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
62
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
63
+ // install.
64
+ const installed = [join(homedir(), ".oam", "bin", exe)];
65
+ if (isWin) {
66
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
67
+ }
68
+ for (const candidate of installed) {
69
+ if (existsSync(candidate)) return candidate;
70
+ }
71
+
72
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
73
+ // would cost a subprocess on every launch just to decide whether to spawn.
74
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
75
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
76
+ if (!dir) continue;
77
+ for (const ext of isWin ? pathExt : [""]) {
78
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
79
+ if (existsSync(candidate)) return candidate;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ /** Run the server in THIS process. The zero-overhead fallback. */
87
+ async function runInProcess() {
88
+ // A server may gate its bootstrap on being the process ENTRY POINT --
89
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
90
+ // test file can import the module for unit tests without connecting a stdio
91
+ // transport. aws-mcp does exactly this. Importing the server here would leave
92
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
93
+ // server would load but never serve: the MCP handshake just hangs.
94
+ //
95
+ // Point argv[1] at the server first, so the in-process path is
96
+ // indistinguishable from having executed the file directly. The spawn path
97
+ // needs no equivalent -- there argv[1] is already the server.
98
+ process.argv[1] = SERVER_ENTRY;
99
+ await import(SERVER_URL.href);
100
+ }
101
+
102
+ const mode = (process.env.TAILSCALE_MCP_RUNTIME ?? "auto").toLowerCase();
103
+
104
+ if (mode === "node") {
105
+ await runInProcess();
106
+ } else {
107
+ const oam = findOam();
108
+
109
+ if (!oam) {
110
+ if (mode === "oam") {
111
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
112
+ // because stderr is async for TTYs/pipes on Windows and process.exit
113
+ // truncates pending writes.
114
+ const { writeSync } = await import("node:fs");
115
+ writeSync(
116
+ 2,
117
+ "tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no oam binary was found.\n" +
118
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
119
+ );
120
+ process.exit(1);
121
+ }
122
+ await runInProcess();
123
+ } else {
124
+ // `--` separates oam's own flags from the script's argv, so `tailscale-mcp
125
+ // --version` and any host-supplied flags survive the hop unchanged.
126
+ const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
127
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
128
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
129
+ // server's shutdown path.
130
+ stdio: "inherit",
131
+ env: process.env,
132
+ windowsHide: true,
133
+ });
134
+
135
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
136
+ // wrong arch, permission), fall back rather than failing the whole server.
137
+ // `spawned` prevents falling back AFTER the child started, which would
138
+ // double-start the server on the same stdio.
139
+ let spawned = false;
140
+ child.on("spawn", () => {
141
+ spawned = true;
142
+ });
143
+ child.on("error", (err) => {
144
+ if (spawned) return;
145
+ if (mode === "oam") {
146
+ process.stderr.write(`tailscale-mcp: failed to launch oam (${err.message})\n`);
147
+ process.exit(1);
148
+ }
149
+ void runInProcess();
150
+ });
151
+
152
+ // Forward termination so the server's own shutdown path runs in the child
153
+ // rather than the child being orphaned. No-op on Windows, harmless to add.
154
+ for (const sig of ["SIGINT", "SIGTERM"]) {
155
+ process.on(sig, () => {
156
+ if (!child.killed) child.kill(sig);
157
+ });
158
+ }
159
+
160
+ child.on("exit", (code, signal) => {
161
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
162
+ // conventional shell exit status rather than a bare 0.
163
+ if (signal) {
164
+ process.exit(128 + (constants.signals[signal] ?? 15));
165
+ }
166
+ process.exit(code ?? 0);
167
+ });
168
+ }
169
+ }
package/dist/index.js CHANGED
@@ -30964,6 +30964,7 @@ var REQUEST_TIMEOUT_MS = 3e4;
30964
30964
  var MAX_429_RETRIES = 3;
30965
30965
  var DEFAULT_429_DELAY_MS = 1e3;
30966
30966
  var MAX_429_DELAY_MS = 3e4;
30967
+ var MAX_429_JITTER_MS = 250;
30967
30968
  var MAX_REQUEST_BUDGET_MS = 9e4;
30968
30969
  var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
30969
30970
  var oauthToken = null;
@@ -31115,6 +31116,12 @@ function getRequestBudgetMs() {
31115
31116
  const n = Number(raw);
31116
31117
  return Number.isInteger(n) && n > 0 ? n : MAX_REQUEST_BUDGET_MS;
31117
31118
  }
31119
+ function getRetryBaseDelayMs() {
31120
+ const raw = process.env.TAILSCALE_RETRY_BASE_DELAY_MS;
31121
+ if (!raw) return DEFAULT_429_DELAY_MS;
31122
+ const n = Number(raw);
31123
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_429_DELAY_MS;
31124
+ }
31118
31125
  async function withConcurrencyLimit(fn) {
31119
31126
  const limit = getConcurrencyLimit();
31120
31127
  if (limit === 0) return fn();
@@ -31151,8 +31158,8 @@ function compute429DelayMs(retryAfter, attempt) {
31151
31158
  if (delta > 0) return Math.min(delta, MAX_429_DELAY_MS);
31152
31159
  }
31153
31160
  }
31154
- const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
31155
- return base + Math.floor(Math.random() * 250);
31161
+ const base = Math.min(getRetryBaseDelayMs() * 2 ** attempt, MAX_429_DELAY_MS);
31162
+ return base + Math.floor(Math.random() * Math.min(MAX_429_JITTER_MS, base));
31156
31163
  }
31157
31164
  async function executeFetch(method, url2, headers, body, timeoutMs) {
31158
31165
  return fetch(url2, {
@@ -31188,7 +31195,7 @@ async function apiRequest(method, path, body, options) {
31188
31195
  headers["Content-Type"] = options.contentType || "application/json";
31189
31196
  fetchBody = options.rawBody;
31190
31197
  } else if (body !== void 0) {
31191
- headers["Content-Type"] = "application/json";
31198
+ headers["Content-Type"] = options?.contentType || "application/json";
31192
31199
  fetchBody = JSON.stringify(body);
31193
31200
  }
31194
31201
  const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
@@ -31407,7 +31414,8 @@ function filterTools(groups, options) {
31407
31414
  const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
31408
31415
  const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
31409
31416
  const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
31410
- const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31417
+ const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : [];
31418
+ const unknownProfileGroups = profileGroups && !effectiveExplicitTools ? profileGroups.filter((g) => !validNames.has(g)) : [];
31411
31419
  const readonly2 = parseReadonlyFlag(options.readonly);
31412
31420
  const out = [];
31413
31421
  for (const [name, tools] of Object.entries(groups)) {
@@ -31418,6 +31426,7 @@ function filterTools(groups, options) {
31418
31426
  }
31419
31427
  }
31420
31428
  const result = { tools: out, unknownGroups };
31429
+ if (unknownProfileGroups.length > 0) result.unknownProfileGroups = unknownProfileGroups;
31421
31430
  if (unknownProfile) result.unknownProfile = unknownProfile;
31422
31431
  if (profileGroups && !effectiveExplicitTools) result.profileGroups = profileGroups;
31423
31432
  if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
@@ -31426,138 +31435,6 @@ function filterTools(groups, options) {
31426
31435
  return result;
31427
31436
  }
31428
31437
 
31429
- // src/tools/status.ts
31430
- function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
31431
- const { deviceCount: _deviceCount, settings: _settings, errors: _errors, ...safeExtras } = extras;
31432
- const data = {
31433
- ...safeExtras,
31434
- deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
31435
- settings: settingsRes.ok ? settingsRes.data : null
31436
- };
31437
- const errors = {};
31438
- if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
31439
- if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
31440
- if (Object.keys(errors).length > 0) data.errors = errors;
31441
- return data;
31442
- }
31443
- var statusTools = [
31444
- {
31445
- name: "tailscale_status",
31446
- description: "Check that the Tailscale API connection is working. Returns your tailnet name, device count, and confirms authentication is valid. Use this to verify setup.",
31447
- annotations: {
31448
- title: "Check API status",
31449
- readOnlyHint: true,
31450
- destructiveHint: false,
31451
- idempotentHint: true,
31452
- openWorldHint: true
31453
- },
31454
- inputSchema: external_exports.object({}),
31455
- handler: async () => {
31456
- const [devicesRes, settingsRes] = await Promise.all([
31457
- apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
31458
- apiGet(`/tailnet/${getTailnet()}/settings`)
31459
- ]);
31460
- if (!devicesRes.ok && !settingsRes.ok) {
31461
- return devicesRes;
31462
- }
31463
- const data = composeTailnetStatusData(devicesRes, settingsRes, {
31464
- connected: true,
31465
- tailnet: getTailnet()
31466
- });
31467
- return { ok: true, status: 200, data };
31468
- }
31469
- }
31470
- ];
31471
-
31472
- // src/server-wiring.ts
31473
- function isLocalCliEnabled(env) {
31474
- return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
31475
- }
31476
- function formatBannerFilterSuffix(inputs) {
31477
- const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
31478
- const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
31479
- const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
31480
- return [
31481
- profileLabel,
31482
- groupsLabel,
31483
- inputs.readonlyMode ? "readonly" : null,
31484
- inputs.localCliEnabled ? "local-cli=on" : null
31485
- ].filter(Boolean).join(", ");
31486
- }
31487
- function wrapToolHandler(tool) {
31488
- return async (input) => {
31489
- try {
31490
- const result = await tool.handler(input);
31491
- const response = result;
31492
- if (!response.ok) {
31493
- return {
31494
- content: [
31495
- {
31496
- type: "text",
31497
- text: `Error: ${response.error || "Unknown error"}`
31498
- }
31499
- ],
31500
- isError: true
31501
- };
31502
- }
31503
- const text = response.rawBody ?? JSON.stringify(response.data ?? { success: true }, null, 2);
31504
- return {
31505
- content: [{ type: "text", text }]
31506
- };
31507
- } catch (err) {
31508
- const message = err instanceof Error ? err.message : String(err);
31509
- return {
31510
- content: [{ type: "text", text: `Error: ${message}` }],
31511
- isError: true
31512
- };
31513
- }
31514
- };
31515
- }
31516
- async function tailnetStatusResource(uri) {
31517
- const [devicesRes, settingsRes] = await Promise.all([
31518
- apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
31519
- apiGet(`/tailnet/${getTailnet()}/settings`)
31520
- ]);
31521
- const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
31522
- return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
31523
- }
31524
- async function tailnetDevicesResource(uri) {
31525
- const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
31526
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
31527
- return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
31528
- }
31529
- async function tailnetAclResource(uri) {
31530
- const res = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31531
- if (res.ok) {
31532
- return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
31533
- }
31534
- const lines = `Error: ${res.error ?? `HTTP ${res.status}`}`.split("\n");
31535
- const text = `${lines.map((l) => `// ${l}`).join("\n")}
31536
- `;
31537
- return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
31538
- }
31539
- async function tailnetDnsResource(uri) {
31540
- const [nameservers, searchPaths, splitDns, preferences] = await Promise.all([
31541
- apiGet(`/tailnet/${getTailnet()}/dns/nameservers`),
31542
- apiGet(`/tailnet/${getTailnet()}/dns/searchpaths`),
31543
- apiGet(`/tailnet/${getTailnet()}/dns/split-dns`),
31544
- apiGet(`/tailnet/${getTailnet()}/dns/preferences`)
31545
- ]);
31546
- const data = {
31547
- nameservers: nameservers.ok ? nameservers.data : null,
31548
- searchPaths: searchPaths.ok ? searchPaths.data : null,
31549
- splitDns: splitDns.ok ? splitDns.data : null,
31550
- preferences: preferences.ok ? preferences.data : null
31551
- };
31552
- const errors = {};
31553
- if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
31554
- if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
31555
- if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
31556
- if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
31557
- if (Object.keys(errors).length > 0) data.errors = errors;
31558
- return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
31559
- }
31560
-
31561
31438
  // src/tools/acl.ts
31562
31439
  var aclTools = [
31563
31440
  {
@@ -32758,6 +32635,13 @@ async function runTailscaleCli(args, options = {}) {
32758
32635
  });
32759
32636
  return;
32760
32637
  }
32638
+ if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
32639
+ resolve({
32640
+ ok: false,
32641
+ 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.`
32642
+ });
32643
+ return;
32644
+ }
32761
32645
  if (errno.killed) {
32762
32646
  resolve({
32763
32647
  ok: false,
@@ -33343,6 +33227,49 @@ var serviceTools = [
33343
33227
  }
33344
33228
  ];
33345
33229
 
33230
+ // src/tools/status.ts
33231
+ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
33232
+ const { deviceCount: _deviceCount, settings: _settings, errors: _errors, ...safeExtras } = extras;
33233
+ const data = {
33234
+ ...safeExtras,
33235
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
33236
+ settings: settingsRes.ok ? settingsRes.data : null
33237
+ };
33238
+ const errors = {};
33239
+ if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
33240
+ if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
33241
+ if (Object.keys(errors).length > 0) data.errors = errors;
33242
+ return data;
33243
+ }
33244
+ var statusTools = [
33245
+ {
33246
+ name: "tailscale_status",
33247
+ description: "Check that the Tailscale API connection is working. Returns your tailnet name, device count, and confirms authentication is valid. Use this to verify setup.",
33248
+ annotations: {
33249
+ title: "Check API status",
33250
+ readOnlyHint: true,
33251
+ destructiveHint: false,
33252
+ idempotentHint: true,
33253
+ openWorldHint: true
33254
+ },
33255
+ inputSchema: external_exports.object({}),
33256
+ handler: async () => {
33257
+ const [devicesRes, settingsRes] = await Promise.all([
33258
+ apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
33259
+ apiGet(`/tailnet/${getTailnet()}/settings`)
33260
+ ]);
33261
+ if (!devicesRes.ok && !settingsRes.ok) {
33262
+ return devicesRes;
33263
+ }
33264
+ const data = composeTailnetStatusData(devicesRes, settingsRes, {
33265
+ connected: true,
33266
+ tailnet: getTailnet()
33267
+ });
33268
+ return { ok: true, status: 200, data };
33269
+ }
33270
+ }
33271
+ ];
33272
+
33346
33273
  // src/tools/tailnet.ts
33347
33274
  var tailnetTools = [
33348
33275
  {
@@ -33786,8 +33713,118 @@ var webhookTools = [
33786
33713
  }
33787
33714
  ];
33788
33715
 
33716
+ // src/server-wiring.ts
33717
+ function isLocalCliEnabled(env) {
33718
+ return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
33719
+ }
33720
+ function buildToolGroups(env) {
33721
+ const toolGroups = {
33722
+ status: statusTools,
33723
+ devices: deviceTools,
33724
+ acl: aclTools,
33725
+ dns: dnsTools,
33726
+ keys: keyTools,
33727
+ users: userTools,
33728
+ tailnet: tailnetTools,
33729
+ webhooks: webhookTools,
33730
+ posture: postureTools,
33731
+ audit: auditTools,
33732
+ invites: inviteTools,
33733
+ services: serviceTools,
33734
+ "log-streaming": logStreamingTools
33735
+ };
33736
+ if (isLocalCliEnabled(env)) {
33737
+ toolGroups["local-cli"] = localCliTools;
33738
+ }
33739
+ return toolGroups;
33740
+ }
33741
+ function formatBannerFilterSuffix(inputs) {
33742
+ const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
33743
+ const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
33744
+ const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
33745
+ return [
33746
+ profileLabel,
33747
+ groupsLabel,
33748
+ inputs.readonlyMode ? "readonly" : null,
33749
+ inputs.localCliEnabled ? "local-cli=on" : null
33750
+ ].filter(Boolean).join(", ");
33751
+ }
33752
+ function wrapToolHandler(tool) {
33753
+ return async (input) => {
33754
+ try {
33755
+ const result = await tool.handler(input);
33756
+ const response = result;
33757
+ if (!response.ok) {
33758
+ return {
33759
+ content: [
33760
+ {
33761
+ type: "text",
33762
+ text: `Error: ${response.error || "Unknown error"}`
33763
+ }
33764
+ ],
33765
+ isError: true
33766
+ };
33767
+ }
33768
+ const text = response.rawBody ?? JSON.stringify(response.data ?? { success: true }, null, 2);
33769
+ return {
33770
+ content: [{ type: "text", text }]
33771
+ };
33772
+ } catch (err) {
33773
+ const message = err instanceof Error ? err.message : String(err);
33774
+ return {
33775
+ content: [{ type: "text", text: `Error: ${message}` }],
33776
+ isError: true
33777
+ };
33778
+ }
33779
+ };
33780
+ }
33781
+ async function tailnetStatusResource(uri) {
33782
+ const [devicesRes, settingsRes] = await Promise.all([
33783
+ apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
33784
+ apiGet(`/tailnet/${getTailnet()}/settings`)
33785
+ ]);
33786
+ const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
33787
+ return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
33788
+ }
33789
+ async function tailnetDevicesResource(uri) {
33790
+ const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
33791
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
33792
+ return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
33793
+ }
33794
+ async function tailnetAclResource(uri) {
33795
+ const res = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
33796
+ if (res.ok) {
33797
+ return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
33798
+ }
33799
+ const lines = `Error: ${res.error ?? `HTTP ${res.status}`}`.split("\n");
33800
+ const text = `${lines.map((l) => `// ${l}`).join("\n")}
33801
+ `;
33802
+ return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
33803
+ }
33804
+ async function tailnetDnsResource(uri) {
33805
+ const [nameservers, searchPaths, splitDns, preferences] = await Promise.all([
33806
+ apiGet(`/tailnet/${getTailnet()}/dns/nameservers`),
33807
+ apiGet(`/tailnet/${getTailnet()}/dns/searchpaths`),
33808
+ apiGet(`/tailnet/${getTailnet()}/dns/split-dns`),
33809
+ apiGet(`/tailnet/${getTailnet()}/dns/preferences`)
33810
+ ]);
33811
+ const data = {
33812
+ nameservers: nameservers.ok ? nameservers.data : null,
33813
+ searchPaths: searchPaths.ok ? searchPaths.data : null,
33814
+ splitDns: splitDns.ok ? splitDns.data : null,
33815
+ preferences: preferences.ok ? preferences.data : null
33816
+ };
33817
+ const errors = {};
33818
+ if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
33819
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
33820
+ if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
33821
+ if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
33822
+ if (Object.keys(errors).length > 0) data.errors = errors;
33823
+ return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
33824
+ }
33825
+
33789
33826
  // src/index.ts
33790
- var version2 = true ? "0.13.3" : resolveVersionFallback();
33827
+ var version2 = true ? "0.15.0" : resolveVersionFallback();
33791
33828
  var subcommand = process.argv[2];
33792
33829
  var cliSubcommandHandled = false;
33793
33830
  if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
@@ -33811,28 +33848,12 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
33811
33848
  );
33812
33849
  }
33813
33850
  if (!cliSubcommandHandled) {
33814
- const toolGroups = {
33815
- status: statusTools,
33816
- devices: deviceTools,
33817
- acl: aclTools,
33818
- dns: dnsTools,
33819
- keys: keyTools,
33820
- users: userTools,
33821
- tailnet: tailnetTools,
33822
- webhooks: webhookTools,
33823
- posture: postureTools,
33824
- audit: auditTools,
33825
- invites: inviteTools,
33826
- services: serviceTools,
33827
- "log-streaming": logStreamingTools
33828
- };
33829
33851
  const localCliEnabled = isLocalCliEnabled(process.env);
33830
- if (localCliEnabled) {
33831
- toolGroups["local-cli"] = localCliTools;
33832
- }
33852
+ const toolGroups = buildToolGroups(process.env);
33833
33853
  const {
33834
33854
  tools: allTools,
33835
33855
  unknownGroups,
33856
+ unknownProfileGroups,
33836
33857
  unknownProfile,
33837
33858
  explicitTools,
33838
33859
  profileWouldFilter,
@@ -33849,6 +33870,11 @@ if (!cliSubcommandHandled) {
33849
33870
  `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
33850
33871
  );
33851
33872
  }
33873
+ if (unknownProfileGroups && unknownProfileGroups.length > 0) {
33874
+ console.error(
33875
+ `@yawlabs/tailscale-mcp: internal inconsistency -- TAILSCALE_PROFILE="${process.env.TAILSCALE_PROFILE}" references group(s) that are not registered: ${unknownProfileGroups.join(", ")}. Those groups contributed no tools. This is a bug in @yawlabs/tailscale-mcp, not your configuration -- please report it at https://github.com/YawLabs/tailscale-mcp/issues.`
33876
+ );
33877
+ }
33852
33878
  if (unknownProfile) {
33853
33879
  console.error(
33854
33880
  `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
@@ -33916,4 +33942,3 @@ if (!cliSubcommandHandled) {
33916
33942
  );
33917
33943
  }
33918
33944
  }
33919
- //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.13.3",
3
+ "version": "0.15.0",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",
@@ -20,9 +20,10 @@
20
20
  "type": "module",
21
21
  "main": "dist/index.js",
22
22
  "bin": {
23
- "tailscale-mcp": "dist/index.js"
23
+ "tailscale-mcp": "bin/tailscale-mcp.mjs"
24
24
  },
25
25
  "files": [
26
+ "bin/tailscale-mcp.mjs",
26
27
  "dist/index.js",
27
28
  "LICENSE",
28
29
  "README.md"
@@ -36,6 +37,9 @@
36
37
  "test:ci": "npm run test",
37
38
  "lint": "biome check src/",
38
39
  "lint:fix": "biome check --write src/",
40
+ "check:oam": "oam check src/index.ts",
41
+ "build:binary": "node scripts/build-binary.mjs",
42
+ "build:binary:oam": "node scripts/build-binary-oam.mjs",
39
43
  "prepublishOnly": "npm run clean && npm run build"
40
44
  },
41
45
  "overrides": {