@yawlabs/tailscale-mcp 0.13.3 → 0.14.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/dist/index.js +181 -156
- package/package.json +4 -1
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.
|
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.14.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.14.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",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"test:ci": "npm run test",
|
|
37
37
|
"lint": "biome check src/",
|
|
38
38
|
"lint:fix": "biome check --write src/",
|
|
39
|
+
"check:oam": "oam check src/index.ts",
|
|
40
|
+
"build:binary": "node scripts/build-binary.mjs",
|
|
41
|
+
"build:binary:oam": "node scripts/build-binary-oam.mjs",
|
|
39
42
|
"prepublishOnly": "npm run clean && npm run build"
|
|
40
43
|
},
|
|
41
44
|
"overrides": {
|