@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 +28 -1
- package/bin/tailscale-mcp.mjs +169 -0
- package/dist/index.js +181 -156
- package/package.json +6 -2
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.
|
|
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(
|
|
31155
|
-
return base + Math.floor(Math.random() *
|
|
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)) :
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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": {
|