@yawlabs/tailscale-mcp 0.17.1 → 0.19.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 +155 -16
- package/bin/tailscale-mcp.mjs +54 -7
- package/dist/index.js +620 -44
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -31035,6 +31035,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31035
31035
|
})();
|
|
31036
31036
|
return oauthRefreshPromise;
|
|
31037
31037
|
}
|
|
31038
|
+
function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
|
|
31039
|
+
if (!oauthToken) return;
|
|
31040
|
+
if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
|
|
31041
|
+
oauthToken = null;
|
|
31042
|
+
}
|
|
31038
31043
|
async function getAuthHeader() {
|
|
31039
31044
|
const config2 = getAuthConfig();
|
|
31040
31045
|
if (config2.kind === "apiKey") {
|
|
@@ -31130,14 +31135,17 @@ function getRetryBaseDelayMs() {
|
|
|
31130
31135
|
}
|
|
31131
31136
|
async function withConcurrencyLimit(fn) {
|
|
31132
31137
|
const limit = getConcurrencyLimit();
|
|
31133
|
-
if (limit === 0) return fn();
|
|
31138
|
+
if (limit === 0) return fn(0);
|
|
31139
|
+
let queuedForMs = 0;
|
|
31134
31140
|
if (inFlight >= limit) {
|
|
31141
|
+
const queueStartedAt = Date.now();
|
|
31135
31142
|
await new Promise((resolve) => concurrencyQueue.push(resolve));
|
|
31143
|
+
queuedForMs = Date.now() - queueStartedAt;
|
|
31136
31144
|
} else {
|
|
31137
31145
|
inFlight++;
|
|
31138
31146
|
}
|
|
31139
31147
|
try {
|
|
31140
|
-
return await fn();
|
|
31148
|
+
return await fn(queuedForMs);
|
|
31141
31149
|
} finally {
|
|
31142
31150
|
const next = concurrencyQueue.shift();
|
|
31143
31151
|
if (next) {
|
|
@@ -31188,6 +31196,15 @@ function describeTransportError(err, method, attemptTimeoutMs) {
|
|
|
31188
31196
|
}
|
|
31189
31197
|
return `${method} request failed: ${String(err)}`;
|
|
31190
31198
|
}
|
|
31199
|
+
function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
|
|
31200
|
+
if (lastTransportError) {
|
|
31201
|
+
return `${lastTransportError}; request budget of ${budgetMs}ms exhausted before next attempt could begin.`;
|
|
31202
|
+
}
|
|
31203
|
+
if (queuedForMs > 0) {
|
|
31204
|
+
return `Request budget of ${budgetMs}ms exhausted before attempt could begin: ${queuedForMs}ms of it was spent waiting for a free slot under TAILSCALE_MAX_CONCURRENT, so no request was ever sent. Raise TAILSCALE_MAX_CONCURRENT or TAILSCALE_REQUEST_BUDGET_MS -- this is queueing, not a network fault.`;
|
|
31205
|
+
}
|
|
31206
|
+
return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
|
|
31207
|
+
}
|
|
31191
31208
|
async function apiRequest(method, path, body, options) {
|
|
31192
31209
|
const headers = {};
|
|
31193
31210
|
if (options?.accept) {
|
|
@@ -31217,7 +31234,7 @@ async function apiRequest(method, path, body, options) {
|
|
|
31217
31234
|
debugLog(`${method} ${url2}`);
|
|
31218
31235
|
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
31219
31236
|
const requestBudgetMs = getRequestBudgetMs();
|
|
31220
|
-
return withConcurrencyLimit(async () => {
|
|
31237
|
+
return withConcurrencyLimit(async (queuedForMs) => {
|
|
31221
31238
|
headers.Authorization = await getAuthHeader();
|
|
31222
31239
|
let res;
|
|
31223
31240
|
let lastTransportError;
|
|
@@ -31227,7 +31244,7 @@ async function apiRequest(method, path, body, options) {
|
|
|
31227
31244
|
return {
|
|
31228
31245
|
ok: false,
|
|
31229
31246
|
status: 0,
|
|
31230
|
-
error:
|
|
31247
|
+
error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
|
|
31231
31248
|
};
|
|
31232
31249
|
}
|
|
31233
31250
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
@@ -31268,6 +31285,9 @@ async function apiRequest(method, path, body, options) {
|
|
|
31268
31285
|
const etag = response.headers.get("etag") || void 0;
|
|
31269
31286
|
const elapsed = Date.now() - startedAt;
|
|
31270
31287
|
debugLog(` <- ${response.status} (${elapsed}ms)`);
|
|
31288
|
+
if (response.status === 401) {
|
|
31289
|
+
invalidateOAuthTokenOnUnauthorized(headers.Authorization);
|
|
31290
|
+
}
|
|
31271
31291
|
try {
|
|
31272
31292
|
if (options?.acceptRaw) {
|
|
31273
31293
|
const rawBody = await response.text();
|
|
@@ -31396,6 +31416,11 @@ var PROFILES = {
|
|
|
31396
31416
|
full: []
|
|
31397
31417
|
// empty = all groups
|
|
31398
31418
|
};
|
|
31419
|
+
function parseGroupList(value) {
|
|
31420
|
+
if (!value) return null;
|
|
31421
|
+
const parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
31422
|
+
return parsed.length > 0 ? parsed : null;
|
|
31423
|
+
}
|
|
31399
31424
|
function parseReadonlyFlag(value) {
|
|
31400
31425
|
return value === "1" || value === "true";
|
|
31401
31426
|
}
|
|
@@ -31414,8 +31439,7 @@ function filterTools(groups, options) {
|
|
|
31414
31439
|
unknownProfile = profileKey;
|
|
31415
31440
|
}
|
|
31416
31441
|
}
|
|
31417
|
-
const
|
|
31418
|
-
const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
|
|
31442
|
+
const explicitTools = parseGroupList(options.tools);
|
|
31419
31443
|
const explicitToolsAllUnknown = explicitTools?.every((g) => !validNames.has(g)) ?? false;
|
|
31420
31444
|
const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
|
|
31421
31445
|
const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
|
|
@@ -31423,11 +31447,15 @@ function filterTools(groups, options) {
|
|
|
31423
31447
|
const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : [];
|
|
31424
31448
|
const unknownProfileGroups = profileGroups && !effectiveExplicitTools ? profileGroups.filter((g) => !validNames.has(g)) : [];
|
|
31425
31449
|
const readonly2 = parseReadonlyFlag(options.readonly);
|
|
31450
|
+
const requestedWriteGroups = parseGroupList(options.writeGroups);
|
|
31451
|
+
const unknownWriteGroups = requestedWriteGroups ? requestedWriteGroups.filter((g) => !validNames.has(g)) : [];
|
|
31452
|
+
const writeScope = requestedWriteGroups ? new Set(requestedWriteGroups.filter((g) => validNames.has(g))) : null;
|
|
31426
31453
|
const out = [];
|
|
31427
31454
|
for (const [name, tools] of Object.entries(groups)) {
|
|
31428
31455
|
if (enabledGroups && !enabledGroups.has(name)) continue;
|
|
31456
|
+
const writesAllowed = !readonly2 && (writeScope === null || writeScope.has(name));
|
|
31429
31457
|
for (const t of tools) {
|
|
31430
|
-
if (
|
|
31458
|
+
if (t.annotations.readOnlyHint !== true && !writesAllowed) continue;
|
|
31431
31459
|
out.push(t);
|
|
31432
31460
|
}
|
|
31433
31461
|
}
|
|
@@ -31438,10 +31466,71 @@ function filterTools(groups, options) {
|
|
|
31438
31466
|
if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
|
|
31439
31467
|
if (profileWouldFilter) result.profileWouldFilter = true;
|
|
31440
31468
|
if (explicitToolsAllUnknown) result.toolsAllUnknown = true;
|
|
31469
|
+
if (writeScope) {
|
|
31470
|
+
const loaded = [...writeScope].filter((g) => !enabledGroups || enabledGroups.has(g));
|
|
31471
|
+
result.writeGroups = readonly2 ? [] : loaded.sort();
|
|
31472
|
+
const overridden = readonly2 && writeScope.size > 0;
|
|
31473
|
+
if (overridden) result.writeGroupsOverriddenByReadonly = true;
|
|
31474
|
+
const notLoaded = overridden ? [] : [...writeScope].filter((g) => enabledGroups && !enabledGroups.has(g));
|
|
31475
|
+
if (notLoaded.length > 0) result.writeGroupsNotLoaded = notLoaded.sort();
|
|
31476
|
+
}
|
|
31477
|
+
if (unknownWriteGroups.length > 0) result.unknownWriteGroups = unknownWriteGroups;
|
|
31441
31478
|
return result;
|
|
31442
31479
|
}
|
|
31443
31480
|
|
|
31444
31481
|
// src/tools/acl.ts
|
|
31482
|
+
var DEFAULT_PRINCIPAL_CAP = 25;
|
|
31483
|
+
var DIFF_TIME_BUDGET_MS = 6e4;
|
|
31484
|
+
function accessSet(matches) {
|
|
31485
|
+
const out = /* @__PURE__ */ new Set();
|
|
31486
|
+
for (const match of matches) {
|
|
31487
|
+
const via = [...match.via ?? []].sort();
|
|
31488
|
+
const postures = [...match.postures ?? []].sort();
|
|
31489
|
+
const qualifier = `${via.length > 0 ? ` via ${via.join(",")}` : ""}${postures.length > 0 ? ` posture ${postures.join(",")}` : ""}`;
|
|
31490
|
+
for (const port of match.ports ?? []) out.add(`${port}${qualifier}`);
|
|
31491
|
+
}
|
|
31492
|
+
return out;
|
|
31493
|
+
}
|
|
31494
|
+
async function previewAccess(policy, principal) {
|
|
31495
|
+
const params = new URLSearchParams({ type: "user", previewFor: principal });
|
|
31496
|
+
const res = await apiPost(`/tailnet/${getTailnet()}/acl/preview?${params}`, void 0, {
|
|
31497
|
+
rawBody: policy,
|
|
31498
|
+
contentType: "application/hujson",
|
|
31499
|
+
acceptRaw: true,
|
|
31500
|
+
accept: "application/hujson"
|
|
31501
|
+
});
|
|
31502
|
+
if (!res.ok) return { ok: false, error: res.error || `HTTP ${res.status}`, status: res.status };
|
|
31503
|
+
let parsed;
|
|
31504
|
+
try {
|
|
31505
|
+
parsed = JSON.parse(res.rawBody ?? "");
|
|
31506
|
+
} catch {
|
|
31507
|
+
return {
|
|
31508
|
+
ok: false,
|
|
31509
|
+
error: "the preview response was not valid JSON, so its rules could not be compared",
|
|
31510
|
+
status: res.status
|
|
31511
|
+
};
|
|
31512
|
+
}
|
|
31513
|
+
if (!Array.isArray(parsed.matches)) {
|
|
31514
|
+
return {
|
|
31515
|
+
ok: false,
|
|
31516
|
+
error: "the preview response contained no `matches` array, so its rules could not be compared",
|
|
31517
|
+
status: res.status
|
|
31518
|
+
};
|
|
31519
|
+
}
|
|
31520
|
+
return { ok: true, access: accessSet(parsed.matches) };
|
|
31521
|
+
}
|
|
31522
|
+
var ETAG_FOOTER_MARKER = "// ETag: ";
|
|
31523
|
+
function stripEtagFooter(body) {
|
|
31524
|
+
const lines = body.split("\n");
|
|
31525
|
+
let cut = lines.length;
|
|
31526
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
31527
|
+
const line = lines[i].trim();
|
|
31528
|
+
if (line === "") continue;
|
|
31529
|
+
if (!line.startsWith("//")) break;
|
|
31530
|
+
if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
|
|
31531
|
+
}
|
|
31532
|
+
return lines.slice(0, cut).join("\n");
|
|
31533
|
+
}
|
|
31445
31534
|
var aclTools = [
|
|
31446
31535
|
{
|
|
31447
31536
|
name: "tailscale_get_acl",
|
|
@@ -31462,12 +31551,12 @@ var aclTools = [
|
|
|
31462
31551
|
if (res.ok && res.etag) {
|
|
31463
31552
|
const footer = [
|
|
31464
31553
|
"",
|
|
31465
|
-
|
|
31554
|
+
`${ETAG_FOOTER_MARKER}${res.etag}`,
|
|
31466
31555
|
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
31467
31556
|
"// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
|
|
31468
31557
|
""
|
|
31469
31558
|
].join("\n");
|
|
31470
|
-
return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
|
|
31559
|
+
return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
|
|
31471
31560
|
}
|
|
31472
31561
|
return res;
|
|
31473
31562
|
}
|
|
@@ -31478,7 +31567,10 @@ var aclTools = [
|
|
|
31478
31567
|
annotations: {
|
|
31479
31568
|
title: "Update ACL policy",
|
|
31480
31569
|
readOnlyHint: false,
|
|
31481
|
-
|
|
31570
|
+
// Overwrites the whole policy file in one call, and a bad push can lock
|
|
31571
|
+
// every device out of the tailnet -- the widest blast radius of any write
|
|
31572
|
+
// here, so clients must gate it rather than auto-approve it.
|
|
31573
|
+
destructiveHint: true,
|
|
31482
31574
|
idempotentHint: true,
|
|
31483
31575
|
openWorldHint: true
|
|
31484
31576
|
},
|
|
@@ -31486,8 +31578,16 @@ var aclTools = [
|
|
|
31486
31578
|
policy: external_exports.string().describe(
|
|
31487
31579
|
"The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
|
|
31488
31580
|
),
|
|
31489
|
-
etag: external_exports.string().describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
|
|
31581
|
+
etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
|
|
31490
31582
|
}),
|
|
31583
|
+
// `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
|
|
31584
|
+
// `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
|
|
31585
|
+
// omitted entirely -- the write then overwrites a concurrent admin edit instead
|
|
31586
|
+
// of coming back 412, on the widest-blast-radius write in the package, with
|
|
31587
|
+
// no diagnostic anywhere. `.trim()` is load-bearing for the same reason it is
|
|
31588
|
+
// on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
|
|
31589
|
+
// header goes out carrying a precondition that cannot match any real ETag --
|
|
31590
|
+
// a confusing 412 instead of a local validation error naming the field.
|
|
31491
31591
|
handler: async (input) => {
|
|
31492
31592
|
return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31493
31593
|
rawBody: input.policy,
|
|
@@ -31551,6 +31651,158 @@ var aclTools = [
|
|
|
31551
31651
|
accept: "application/hujson"
|
|
31552
31652
|
});
|
|
31553
31653
|
}
|
|
31654
|
+
},
|
|
31655
|
+
{
|
|
31656
|
+
name: "tailscale_diff_acl_access",
|
|
31657
|
+
description: "Answer 'who loses access?' before applying an ACL change. Compares the CURRENT policy against a proposed one and reports, per user, which destinations they gain and lose. Run this before tailscale_update_acl -- validate_acl only checks syntax and the policy's own tests block, so a policy with no tests validates clean while revoking everyone. LIMITS, all reported in the response rather than left to be discovered. It compares USER principals only, so a revocation that runs through a tag or group can show a clean diff; it does not detect a change to a posture DEFINITION, because the comparison keys on posture names; and a narrowed port list shows as a paired loss and gain of the whole entry rather than a clean loss. An empty result is never proof a change is safe. It costs two preview requests per user, so it checks the first 25 by default and stops after 60 seconds regardless; either way it sets `truncated`, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in `failed` and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.",
|
|
31658
|
+
annotations: {
|
|
31659
|
+
title: "Diff ACL access",
|
|
31660
|
+
readOnlyHint: true,
|
|
31661
|
+
destructiveHint: false,
|
|
31662
|
+
idempotentHint: true,
|
|
31663
|
+
openWorldHint: true
|
|
31664
|
+
},
|
|
31665
|
+
inputSchema: external_exports.object({
|
|
31666
|
+
policy: external_exports.string().describe("The proposed ACL policy text to compare against the current live policy"),
|
|
31667
|
+
principals: external_exports.array(external_exports.string().trim().min(1)).optional().describe(
|
|
31668
|
+
"User emails to check. Omit to enumerate the tailnet's users automatically. Pass an explicit list to bound the request count, or to check specific users beyond the cap."
|
|
31669
|
+
),
|
|
31670
|
+
maxPrincipals: external_exports.number().int().positive().optional().describe(
|
|
31671
|
+
`Maximum users to check (default ${DEFAULT_PRINCIPAL_CAP}). Each costs two preview requests. Raising this on a large tailnet can be slow and may hit rate limits.`
|
|
31672
|
+
)
|
|
31673
|
+
}),
|
|
31674
|
+
handler: async (input) => {
|
|
31675
|
+
const startedAt = Date.now();
|
|
31676
|
+
const current = await apiGet(`/tailnet/${getTailnet()}/acl`, {
|
|
31677
|
+
acceptRaw: true,
|
|
31678
|
+
accept: "application/hujson"
|
|
31679
|
+
});
|
|
31680
|
+
if (!current.ok) {
|
|
31681
|
+
return {
|
|
31682
|
+
ok: false,
|
|
31683
|
+
error: `could not read the current ACL to diff against: ${current.error || `HTTP ${current.status}`}`
|
|
31684
|
+
};
|
|
31685
|
+
}
|
|
31686
|
+
const baselinePolicy = current.rawBody ?? "";
|
|
31687
|
+
let principals;
|
|
31688
|
+
let availableTotal;
|
|
31689
|
+
if (input.principals !== void 0) {
|
|
31690
|
+
principals = [...new Set(input.principals)];
|
|
31691
|
+
if (principals.length === 0) {
|
|
31692
|
+
return {
|
|
31693
|
+
ok: false,
|
|
31694
|
+
error: "`principals` was passed as an empty list, so no users were named to compare. Omit the argument entirely to enumerate the tailnet's users, or name at least one email."
|
|
31695
|
+
};
|
|
31696
|
+
}
|
|
31697
|
+
availableTotal = principals.length;
|
|
31698
|
+
} else {
|
|
31699
|
+
const usersRes = await apiGet(`/tailnet/${getTailnet()}/users`);
|
|
31700
|
+
if (!usersRes.ok) {
|
|
31701
|
+
return { ok: false, error: `could not list users to diff: ${usersRes.error || `HTTP ${usersRes.status}`}` };
|
|
31702
|
+
}
|
|
31703
|
+
const emails = (usersRes.data?.users ?? []).map((u) => u.loginName ?? u.email ?? u.name).filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
31704
|
+
principals = [...new Set(emails)];
|
|
31705
|
+
availableTotal = principals.length;
|
|
31706
|
+
if (principals.length === 0) {
|
|
31707
|
+
return {
|
|
31708
|
+
ok: false,
|
|
31709
|
+
error: "no user emails could be read from the tailnet's user list, so there is nothing to compare. Pass `principals` explicitly with the emails to check -- an empty diff here would wrongly suggest the change affects nobody."
|
|
31710
|
+
};
|
|
31711
|
+
}
|
|
31712
|
+
}
|
|
31713
|
+
const cap = input.maxPrincipals ?? DEFAULT_PRINCIPAL_CAP;
|
|
31714
|
+
const checked = principals.slice(0, cap);
|
|
31715
|
+
const changed = [];
|
|
31716
|
+
const unchanged = [];
|
|
31717
|
+
const failed = [];
|
|
31718
|
+
let attempted = 0;
|
|
31719
|
+
let stoppedOnTime = false;
|
|
31720
|
+
for (const principal of checked) {
|
|
31721
|
+
if (Date.now() - startedAt > DIFF_TIME_BUDGET_MS) {
|
|
31722
|
+
stoppedOnTime = true;
|
|
31723
|
+
break;
|
|
31724
|
+
}
|
|
31725
|
+
attempted++;
|
|
31726
|
+
const [before, after] = await Promise.all([
|
|
31727
|
+
previewAccess(baselinePolicy, principal),
|
|
31728
|
+
previewAccess(input.policy, principal)
|
|
31729
|
+
]);
|
|
31730
|
+
if (!before.ok || !after.ok) {
|
|
31731
|
+
const failure = !before.ok ? before : !after.ok ? after : void 0;
|
|
31732
|
+
const which = !before.ok ? "current" : "proposed";
|
|
31733
|
+
failed.push({
|
|
31734
|
+
principal,
|
|
31735
|
+
error: `preview against the ${which} policy failed: ${failure?.error ?? "unknown"}`
|
|
31736
|
+
});
|
|
31737
|
+
if (failure?.status === 401 || failure?.status === 403) {
|
|
31738
|
+
return {
|
|
31739
|
+
ok: false,
|
|
31740
|
+
error: `authentication failed while previewing ${principal}, so the diff was abandoned rather than continued against a credential the API has already refused: ${failure?.error ?? "unknown"}`
|
|
31741
|
+
};
|
|
31742
|
+
}
|
|
31743
|
+
continue;
|
|
31744
|
+
}
|
|
31745
|
+
const lost = [...before.access].filter((a) => !after.access.has(a)).sort();
|
|
31746
|
+
const gained = [...after.access].filter((a) => !before.access.has(a)).sort();
|
|
31747
|
+
if (lost.length === 0 && gained.length === 0) unchanged.push(principal);
|
|
31748
|
+
else changed.push({ principal, lost, gained });
|
|
31749
|
+
}
|
|
31750
|
+
const compared = attempted - failed.length;
|
|
31751
|
+
if (compared === 0) {
|
|
31752
|
+
return {
|
|
31753
|
+
ok: false,
|
|
31754
|
+
error: stoppedOnTime ? `no users could be compared: the ${DIFF_TIME_BUDGET_MS / 1e3}s budget for this call was spent before any comparison finished. Narrow the run with \`principals\`, or lower \`maxPrincipals\`.` : `no users could be compared: all ${attempted} preview attempts failed. First failure: ${failed[0]?.error ?? "unknown"}`
|
|
31755
|
+
};
|
|
31756
|
+
}
|
|
31757
|
+
const losing = changed.filter((c) => c.lost.length > 0).length;
|
|
31758
|
+
const gaining = changed.filter((c) => c.gained.length > 0).length;
|
|
31759
|
+
const notChecked = principals.length - attempted;
|
|
31760
|
+
const truncated = notChecked > 0;
|
|
31761
|
+
const summary = [
|
|
31762
|
+
// Failures lead when present, so the headline cannot read as an
|
|
31763
|
+
// all-clear over a partially-compared run.
|
|
31764
|
+
failed.length > 0 ? `${failed.length} of ${attempted} users could not be checked` : null,
|
|
31765
|
+
`${losing} of ${compared} users compared lose access`,
|
|
31766
|
+
`${gaining} gain access`,
|
|
31767
|
+
`${unchanged.length} unchanged`,
|
|
31768
|
+
// Names WHICH limit stopped the run: "cap 25" tells the caller to raise
|
|
31769
|
+
// maxPrincipals, and the time budget tells them the opposite -- that
|
|
31770
|
+
// raising it would make things worse, and the run needs narrowing.
|
|
31771
|
+
truncated ? `${notChecked} not checked (${stoppedOnTime ? `${DIFF_TIME_BUDGET_MS / 1e3}s time budget` : `cap ${cap}`})` : null
|
|
31772
|
+
].filter(Boolean).join(", ");
|
|
31773
|
+
return {
|
|
31774
|
+
ok: true,
|
|
31775
|
+
data: {
|
|
31776
|
+
tailnet: getTailnet(),
|
|
31777
|
+
summary,
|
|
31778
|
+
// Three numbers, because two of them were being conflated. `Compared`
|
|
31779
|
+
// is the only one that describes work actually done.
|
|
31780
|
+
principalsCompared: compared,
|
|
31781
|
+
principalsFailed: failed.length,
|
|
31782
|
+
principalsAvailable: availableTotal,
|
|
31783
|
+
truncated,
|
|
31784
|
+
// Distinguishes the two truncation causes for a machine reader, which
|
|
31785
|
+
// the summary string does only in prose. They call for opposite
|
|
31786
|
+
// responses: a cap stop means raise maxPrincipals, a time stop means
|
|
31787
|
+
// narrow the run.
|
|
31788
|
+
stoppedOnTimeBudget: stoppedOnTime,
|
|
31789
|
+
// Restated in the payload, not just the tool description: whoever
|
|
31790
|
+
// reads this output is deciding whether to apply the change, and may
|
|
31791
|
+
// never have read the description. Each clause names a way this diff
|
|
31792
|
+
// can come back empty while real access changed.
|
|
31793
|
+
scope: [
|
|
31794
|
+
"User principals only.",
|
|
31795
|
+
"Not compared: access granted via tags or groups;",
|
|
31796
|
+
"changes to a posture DEFINITION (the diff keys on posture names, so redefining `posture:corp` more strictly leaves every key identical);",
|
|
31797
|
+
"and a destination whose port list is narrowed appears as a paired loss and gain of the whole entry (`tag:prod:22,80` -> `tag:prod:22`) rather than a clean loss, so `gained` is not a literal list of newly-reachable destinations.",
|
|
31798
|
+
"An empty diff is not proof the change is safe."
|
|
31799
|
+
].join(" "),
|
|
31800
|
+
changed,
|
|
31801
|
+
unchanged,
|
|
31802
|
+
failed
|
|
31803
|
+
}
|
|
31804
|
+
};
|
|
31805
|
+
}
|
|
31554
31806
|
}
|
|
31555
31807
|
];
|
|
31556
31808
|
|
|
@@ -31805,7 +32057,9 @@ var deviceTools = [
|
|
|
31805
32057
|
annotations: {
|
|
31806
32058
|
title: "Set device routes",
|
|
31807
32059
|
readOnlyHint: false,
|
|
31808
|
-
|
|
32060
|
+
// Replace-all: the routes array is the new enabled set, so `[]` silently
|
|
32061
|
+
// withdraws every subnet the device currently routes.
|
|
32062
|
+
destructiveHint: true,
|
|
31809
32063
|
idempotentHint: true,
|
|
31810
32064
|
openWorldHint: true
|
|
31811
32065
|
},
|
|
@@ -31890,7 +32144,9 @@ var deviceTools = [
|
|
|
31890
32144
|
annotations: {
|
|
31891
32145
|
title: "Set device tags",
|
|
31892
32146
|
readOnlyHint: false,
|
|
31893
|
-
|
|
32147
|
+
// Replace-all: `[]` strips every ACL tag, which can drop the device out of
|
|
32148
|
+
// the policy rules that grant it access.
|
|
32149
|
+
destructiveHint: true,
|
|
31894
32150
|
idempotentHint: true,
|
|
31895
32151
|
openWorldHint: true
|
|
31896
32152
|
},
|
|
@@ -31969,7 +32225,7 @@ var deviceTools = [
|
|
|
31969
32225
|
const failed = {};
|
|
31970
32226
|
for (const { deviceId, res } of results) {
|
|
31971
32227
|
if (res.ok) succeeded.push(deviceId);
|
|
31972
|
-
else failed[deviceId] = { status: res.status, error: res.error
|
|
32228
|
+
else failed[deviceId] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
31973
32229
|
}
|
|
31974
32230
|
const failedCount = Object.keys(failed).length;
|
|
31975
32231
|
if (failedCount === unique.length) {
|
|
@@ -32053,7 +32309,9 @@ var dnsTools = [
|
|
|
32053
32309
|
annotations: {
|
|
32054
32310
|
title: "Set nameservers",
|
|
32055
32311
|
readOnlyHint: false,
|
|
32056
|
-
|
|
32312
|
+
// Replace-all: `[]` clears tailnet DNS resolution rather than leaving the
|
|
32313
|
+
// current nameservers in place.
|
|
32314
|
+
destructiveHint: true,
|
|
32057
32315
|
idempotentHint: true,
|
|
32058
32316
|
openWorldHint: true
|
|
32059
32317
|
},
|
|
@@ -32085,7 +32343,8 @@ var dnsTools = [
|
|
|
32085
32343
|
annotations: {
|
|
32086
32344
|
title: "Set DNS search paths",
|
|
32087
32345
|
readOnlyHint: false,
|
|
32088
|
-
|
|
32346
|
+
// Replace-all: `[]` clears every configured search domain.
|
|
32347
|
+
destructiveHint: true,
|
|
32089
32348
|
idempotentHint: true,
|
|
32090
32349
|
openWorldHint: true
|
|
32091
32350
|
},
|
|
@@ -32119,7 +32378,9 @@ var dnsTools = [
|
|
|
32119
32378
|
annotations: {
|
|
32120
32379
|
title: "Set split DNS",
|
|
32121
32380
|
readOnlyHint: false,
|
|
32122
|
-
|
|
32381
|
+
// Replace-all (PUT): every domain absent from the map is dropped, so `{}`
|
|
32382
|
+
// clears the whole split DNS config. The PATCH sibling below merges instead.
|
|
32383
|
+
destructiveHint: true,
|
|
32123
32384
|
idempotentHint: true,
|
|
32124
32385
|
openWorldHint: true
|
|
32125
32386
|
},
|
|
@@ -32206,7 +32467,9 @@ var dnsTools = [
|
|
|
32206
32467
|
annotations: {
|
|
32207
32468
|
title: "Set DNS configuration (unified)",
|
|
32208
32469
|
readOnlyHint: false,
|
|
32209
|
-
|
|
32470
|
+
// Replace-all across every DNS setting at once -- a superset of the wipe
|
|
32471
|
+
// the individual setters above can do.
|
|
32472
|
+
destructiveHint: true,
|
|
32210
32473
|
idempotentHint: true,
|
|
32211
32474
|
openWorldHint: true
|
|
32212
32475
|
},
|
|
@@ -32663,6 +32926,48 @@ var keyTools = [
|
|
|
32663
32926
|
handler: async (input) => {
|
|
32664
32927
|
return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
|
|
32665
32928
|
}
|
|
32929
|
+
},
|
|
32930
|
+
{
|
|
32931
|
+
name: "tailscale_list_oauth_apps",
|
|
32932
|
+
description: "List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an `oauthApps` array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.",
|
|
32933
|
+
annotations: {
|
|
32934
|
+
title: "List OAuth apps",
|
|
32935
|
+
readOnlyHint: true,
|
|
32936
|
+
destructiveHint: false,
|
|
32937
|
+
idempotentHint: true,
|
|
32938
|
+
openWorldHint: true
|
|
32939
|
+
},
|
|
32940
|
+
// No limit/cursor: the live endpoint takes no parameters and returns the
|
|
32941
|
+
// whole collection. Advertising pagination the API ignores would let a
|
|
32942
|
+
// caller believe it had paged through the list when it had not.
|
|
32943
|
+
inputSchema: external_exports.object({}),
|
|
32944
|
+
handler: async () => {
|
|
32945
|
+
return apiGet(`/tailnet/${getTailnet()}/oauth-apps`);
|
|
32946
|
+
}
|
|
32947
|
+
},
|
|
32948
|
+
{
|
|
32949
|
+
name: "tailscale_delete_oauth_app",
|
|
32950
|
+
description: "Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.",
|
|
32951
|
+
annotations: {
|
|
32952
|
+
title: "Delete OAuth app",
|
|
32953
|
+
readOnlyHint: false,
|
|
32954
|
+
destructiveHint: true,
|
|
32955
|
+
idempotentHint: true,
|
|
32956
|
+
openWorldHint: true
|
|
32957
|
+
},
|
|
32958
|
+
inputSchema: external_exports.object({
|
|
32959
|
+
// `.trim().min(1)` rather than the bare `.min(1)` on
|
|
32960
|
+
// tailscale_get_oauth_app's appId, for the reason tailnets.ts spells out
|
|
32961
|
+
// on tailscale_delete_tailnet: a bare min(1) accepts " ", which encPath
|
|
32962
|
+
// then sends as the literal segment "%20". On a read that costs a wasted
|
|
32963
|
+
// round-trip; on an irreversible revoke it returns a 404 that reads like
|
|
32964
|
+
// the app is already gone. Trimming at the schema makes it a validation
|
|
32965
|
+
// error instead.
|
|
32966
|
+
appId: external_exports.string().trim().min(1).describe("The OAuth app ID to delete (see tailscale_list_oauth_apps)")
|
|
32967
|
+
}),
|
|
32968
|
+
handler: async (input) => {
|
|
32969
|
+
return apiDelete(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
|
|
32970
|
+
}
|
|
32666
32971
|
}
|
|
32667
32972
|
];
|
|
32668
32973
|
|
|
@@ -32866,8 +33171,8 @@ var logStreamingTools = [
|
|
|
32866
33171
|
apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
|
|
32867
33172
|
]);
|
|
32868
33173
|
const errors = {};
|
|
32869
|
-
if (!configuration.ok) errors.configuration = configuration.error
|
|
32870
|
-
if (!network.ok) errors.network = network.error
|
|
33174
|
+
if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
|
|
33175
|
+
if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
|
|
32871
33176
|
if (!configuration.ok && !network.ok) {
|
|
32872
33177
|
return {
|
|
32873
33178
|
ok: false,
|
|
@@ -33353,8 +33658,8 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
|
|
|
33353
33658
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
33354
33659
|
};
|
|
33355
33660
|
const errors = {};
|
|
33356
|
-
if (!devicesRes.ok) errors.devices = devicesRes.error
|
|
33357
|
-
if (!settingsRes.ok) errors.settings = settingsRes.error
|
|
33661
|
+
if (!devicesRes.ok) errors.devices = devicesRes.error || `HTTP ${devicesRes.status}`;
|
|
33662
|
+
if (!settingsRes.ok) errors.settings = settingsRes.error || `HTTP ${settingsRes.status}`;
|
|
33358
33663
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
33359
33664
|
return data;
|
|
33360
33665
|
}
|
|
@@ -33486,7 +33791,7 @@ var tailnetTools = [
|
|
|
33486
33791
|
const failed = {};
|
|
33487
33792
|
for (const { contactType, res } of results) {
|
|
33488
33793
|
if (res.ok) applied[contactType] = res.data;
|
|
33489
|
-
else failed[contactType] = { status: res.status, error: res.error
|
|
33794
|
+
else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
33490
33795
|
}
|
|
33491
33796
|
const hasFailed = Object.keys(failed).length > 0;
|
|
33492
33797
|
const hasApplied = Object.keys(applied).length > 0;
|
|
@@ -33573,7 +33878,7 @@ var tailnetsTools = [
|
|
|
33573
33878
|
},
|
|
33574
33879
|
{
|
|
33575
33880
|
name: "tailscale_delete_tailnet",
|
|
33576
|
-
description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
|
|
33881
|
+
description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. That check is a typo guard, not an authorization gate: when you also pass `tailnet` you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-`tailnet` path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
|
|
33577
33882
|
annotations: {
|
|
33578
33883
|
title: "Delete tailnet",
|
|
33579
33884
|
readOnlyHint: false,
|
|
@@ -33586,7 +33891,7 @@ var tailnetsTools = [
|
|
|
33586
33891
|
"Tailnet to delete (e.g. an id from tailscale_list_org_tailnets). Omit to target the configured tailnet. Requires credentials scoped to reach it."
|
|
33587
33892
|
),
|
|
33588
33893
|
confirmTailnet: external_exports.string().trim().min(1).describe(
|
|
33589
|
-
"Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A
|
|
33894
|
+
"Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A typo guard, not an authorization gate: on the explicit-`tailnet` path the caller writes both halves of the comparison, so it proves only self-agreement. It is a real second look only when `tailnet` is omitted and the value has to match the operator's environment."
|
|
33590
33895
|
)
|
|
33591
33896
|
}),
|
|
33592
33897
|
// `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
|
|
@@ -33604,8 +33909,9 @@ var tailnetsTools = [
|
|
|
33604
33909
|
);
|
|
33605
33910
|
}
|
|
33606
33911
|
if (input.confirmTailnet !== target) {
|
|
33912
|
+
const source = input.tailnet?.trim() ? "tailnet you named" : "configured tailnet";
|
|
33607
33913
|
throw new Error(
|
|
33608
|
-
`confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the
|
|
33914
|
+
`confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the ${source} ${JSON.stringify(target)}. Refusing to delete.`
|
|
33609
33915
|
);
|
|
33610
33916
|
}
|
|
33611
33917
|
return apiDelete(`/tailnet/${encPath(target)}`);
|
|
@@ -33927,6 +34233,42 @@ var webhookTools = [
|
|
|
33927
34233
|
function isLocalCliEnabled(env) {
|
|
33928
34234
|
return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
|
|
33929
34235
|
}
|
|
34236
|
+
function isRequireApprovalEnabled(env) {
|
|
34237
|
+
return env.TAILSCALE_REQUIRE_APPROVAL === "1" || env.TAILSCALE_REQUIRE_APPROVAL === "true";
|
|
34238
|
+
}
|
|
34239
|
+
var FORCED_APPROVAL_TOOLS = [
|
|
34240
|
+
"tailscale_delete_device",
|
|
34241
|
+
"tailscale_delete_key",
|
|
34242
|
+
"tailscale_delete_log_stream_config",
|
|
34243
|
+
"tailscale_delete_oauth_app",
|
|
34244
|
+
"tailscale_delete_posture_integration",
|
|
34245
|
+
"tailscale_delete_tailnet",
|
|
34246
|
+
"tailscale_delete_user",
|
|
34247
|
+
"tailscale_delete_webhook",
|
|
34248
|
+
"tailscale_update_acl"
|
|
34249
|
+
];
|
|
34250
|
+
var MAX_RESULT_SIZE_CHARS = 5e5;
|
|
34251
|
+
var LARGE_RESULT_TOOLS = [
|
|
34252
|
+
// Scales with BOTH the number of principals checked and each one's grant
|
|
34253
|
+
// count, so for a given tailnet its payload is strictly larger than
|
|
34254
|
+
// list_users below.
|
|
34255
|
+
"tailscale_diff_acl_access",
|
|
34256
|
+
"tailscale_get_acl",
|
|
34257
|
+
"tailscale_get_audit_log",
|
|
34258
|
+
"tailscale_get_network_flow_logs",
|
|
34259
|
+
"tailscale_list_devices",
|
|
34260
|
+
"tailscale_list_users"
|
|
34261
|
+
];
|
|
34262
|
+
function buildToolMeta(toolName, options) {
|
|
34263
|
+
const meta3 = {};
|
|
34264
|
+
if (options.requireApproval && FORCED_APPROVAL_TOOLS.includes(toolName)) {
|
|
34265
|
+
meta3["anthropic/requiresUserInteraction"] = true;
|
|
34266
|
+
}
|
|
34267
|
+
if (LARGE_RESULT_TOOLS.includes(toolName)) {
|
|
34268
|
+
meta3["anthropic/maxResultSizeChars"] = MAX_RESULT_SIZE_CHARS;
|
|
34269
|
+
}
|
|
34270
|
+
return Object.keys(meta3).length > 0 ? meta3 : void 0;
|
|
34271
|
+
}
|
|
33930
34272
|
function buildToolGroups(env) {
|
|
33931
34273
|
const toolGroups = {
|
|
33932
34274
|
status: statusTools,
|
|
@@ -33968,7 +34310,11 @@ function formatBannerFilterSuffix(inputs) {
|
|
|
33968
34310
|
return [
|
|
33969
34311
|
profileLabel,
|
|
33970
34312
|
groupsLabel,
|
|
33971
|
-
inputs.readonlyMode ? "readonly" : null,
|
|
34313
|
+
inputs.readonlyMode ? inputs.writeGroupsOverriddenByReadonly ? "readonly (TAILSCALE_WRITE_GROUPS ignored)" : "readonly" : null,
|
|
34314
|
+
// `write=none` (configured, granted nothing) is a different state from no segment
|
|
34315
|
+
// at all (knob unset, every write served), and an operator debugging "why can it
|
|
34316
|
+
// not write" needs to tell them apart at a glance.
|
|
34317
|
+
inputs.writeGroups ? inputs.writeGroups.length > 0 ? `write=${inputs.writeGroups.join(",")}` : "write=none" : null,
|
|
33972
34318
|
inputs.localCliEnabled ? "local-cli=on" : null
|
|
33973
34319
|
].filter(Boolean).join(", ");
|
|
33974
34320
|
}
|
|
@@ -34011,7 +34357,7 @@ async function tailnetStatusResource(uri) {
|
|
|
34011
34357
|
}
|
|
34012
34358
|
async function tailnetDevicesResource(uri) {
|
|
34013
34359
|
const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
|
|
34014
|
-
const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error
|
|
34360
|
+
const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error || `HTTP ${res.status}` }, null, 2);
|
|
34015
34361
|
return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
|
|
34016
34362
|
}
|
|
34017
34363
|
async function tailnetAclResource(uri) {
|
|
@@ -34019,7 +34365,7 @@ async function tailnetAclResource(uri) {
|
|
|
34019
34365
|
if (res.ok) {
|
|
34020
34366
|
return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
|
|
34021
34367
|
}
|
|
34022
|
-
const lines = `Error: ${res.error
|
|
34368
|
+
const lines = `Error: ${res.error || `HTTP ${res.status}`}`.split("\n");
|
|
34023
34369
|
const text = `${lines.map((l) => `// ${l}`).join("\n")}
|
|
34024
34370
|
`;
|
|
34025
34371
|
return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
|
|
@@ -34038,16 +34384,161 @@ async function tailnetDnsResource(uri) {
|
|
|
34038
34384
|
preferences: preferences.ok ? preferences.data : null
|
|
34039
34385
|
};
|
|
34040
34386
|
const errors = {};
|
|
34041
|
-
if (!nameservers.ok) errors.nameservers = nameservers.error
|
|
34042
|
-
if (!searchPaths.ok) errors.searchPaths = searchPaths.error
|
|
34043
|
-
if (!splitDns.ok) errors.splitDns = splitDns.error
|
|
34044
|
-
if (!preferences.ok) errors.preferences = preferences.error
|
|
34387
|
+
if (!nameservers.ok) errors.nameservers = nameservers.error || `HTTP ${nameservers.status}`;
|
|
34388
|
+
if (!searchPaths.ok) errors.searchPaths = searchPaths.error || `HTTP ${searchPaths.status}`;
|
|
34389
|
+
if (!splitDns.ok) errors.splitDns = splitDns.error || `HTTP ${splitDns.status}`;
|
|
34390
|
+
if (!preferences.ok) errors.preferences = preferences.error || `HTTP ${preferences.status}`;
|
|
34045
34391
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
34046
34392
|
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
|
|
34047
34393
|
}
|
|
34048
34394
|
|
|
34395
|
+
// src/tools/meta.ts
|
|
34396
|
+
function explainNotLoaded(group, state) {
|
|
34397
|
+
if (group === "local-cli" && !state.localCliEnabled) {
|
|
34398
|
+
return {
|
|
34399
|
+
reason: "the local-CLI group is opt-in and is not enabled in this process",
|
|
34400
|
+
toEnable: "set TAILSCALE_LOCAL_CLI=1"
|
|
34401
|
+
};
|
|
34402
|
+
}
|
|
34403
|
+
if (state.toolsEnv) {
|
|
34404
|
+
return {
|
|
34405
|
+
reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
|
|
34406
|
+
toEnable: `add "${group}" to TAILSCALE_TOOLS`
|
|
34407
|
+
};
|
|
34408
|
+
}
|
|
34409
|
+
if (state.profileEnv) {
|
|
34410
|
+
return {
|
|
34411
|
+
reason: `TAILSCALE_PROFILE="${state.profileEnv}" does not include this group`,
|
|
34412
|
+
toEnable: `set TAILSCALE_PROFILE=full, or list the groups you want in TAILSCALE_TOOLS`
|
|
34413
|
+
};
|
|
34414
|
+
}
|
|
34415
|
+
return {
|
|
34416
|
+
reason: "this group did not register, and no load filter is configured to explain why",
|
|
34417
|
+
toEnable: "no env change is known to enable it -- please report this at https://github.com/YawLabs/tailscale-mcp/issues"
|
|
34418
|
+
};
|
|
34419
|
+
}
|
|
34420
|
+
function explainWritesWithheld(group, state) {
|
|
34421
|
+
if (state.readonlyMode) {
|
|
34422
|
+
return {
|
|
34423
|
+
reason: "TAILSCALE_READONLY is enabled, so no group serves writes",
|
|
34424
|
+
toEnable: "unset TAILSCALE_READONLY"
|
|
34425
|
+
};
|
|
34426
|
+
}
|
|
34427
|
+
const current = state.writeGroupsEnv?.trim();
|
|
34428
|
+
return {
|
|
34429
|
+
reason: current ? `TAILSCALE_WRITE_GROUPS is set to "${current}", which does not grant writes here` : "writes are withheld in this group",
|
|
34430
|
+
toEnable: current ? `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${current},${group}")` : `add "${group}" to TAILSCALE_WRITE_GROUPS`
|
|
34431
|
+
};
|
|
34432
|
+
}
|
|
34433
|
+
function buildGroupReports(state) {
|
|
34434
|
+
const reports = [];
|
|
34435
|
+
for (const [group, tools] of Object.entries(state.fullRegistry)) {
|
|
34436
|
+
const available = tools.filter((t) => state.registeredNames.has(t.name));
|
|
34437
|
+
const writes = tools.filter((t) => t.annotations.readOnlyHint !== true);
|
|
34438
|
+
const writesAvailable = writes.filter((t) => state.registeredNames.has(t.name));
|
|
34439
|
+
const base = {
|
|
34440
|
+
group,
|
|
34441
|
+
tools: tools.length,
|
|
34442
|
+
available: available.length,
|
|
34443
|
+
writes: writes.length,
|
|
34444
|
+
writesAvailable: writesAvailable.length
|
|
34445
|
+
};
|
|
34446
|
+
if (available.length === 0) {
|
|
34447
|
+
reports.push({ ...base, status: "unavailable", ...explainNotLoaded(group, state) });
|
|
34448
|
+
continue;
|
|
34449
|
+
}
|
|
34450
|
+
if (writes.length > 0 && writesAvailable.length === 0) {
|
|
34451
|
+
reports.push({ ...base, status: "read-only", ...explainWritesWithheld(group, state) });
|
|
34452
|
+
continue;
|
|
34453
|
+
}
|
|
34454
|
+
reports.push({ ...base, status: "full" });
|
|
34455
|
+
}
|
|
34456
|
+
return reports;
|
|
34457
|
+
}
|
|
34458
|
+
function explainTool(toolName, state) {
|
|
34459
|
+
const query = toolName.trim();
|
|
34460
|
+
for (const [group, tools] of Object.entries(state.fullRegistry)) {
|
|
34461
|
+
const tool = tools.find((t) => t.name === query);
|
|
34462
|
+
if (!tool) continue;
|
|
34463
|
+
const isWrite = tool.annotations.readOnlyHint !== true;
|
|
34464
|
+
if (state.registeredNames.has(query)) {
|
|
34465
|
+
return {
|
|
34466
|
+
tool: query,
|
|
34467
|
+
available: true,
|
|
34468
|
+
group,
|
|
34469
|
+
kind: isWrite ? "write" : "read",
|
|
34470
|
+
reason: "this tool is registered and callable right now"
|
|
34471
|
+
};
|
|
34472
|
+
}
|
|
34473
|
+
const groupHasAny = tools.some((t) => state.registeredNames.has(t.name));
|
|
34474
|
+
const explain = groupHasAny ? explainWritesWithheld(group, state) : explainNotLoaded(group, state);
|
|
34475
|
+
return {
|
|
34476
|
+
tool: query,
|
|
34477
|
+
available: false,
|
|
34478
|
+
group,
|
|
34479
|
+
kind: isWrite ? "write" : "read",
|
|
34480
|
+
...explain
|
|
34481
|
+
};
|
|
34482
|
+
}
|
|
34483
|
+
return {
|
|
34484
|
+
tool: query,
|
|
34485
|
+
available: false,
|
|
34486
|
+
// The case an agent most needs separated from the others: no configuration
|
|
34487
|
+
// change will produce this tool, so retrying or asking the operator is wasted.
|
|
34488
|
+
// Only here is "find another way" the right conclusion.
|
|
34489
|
+
reason: "no tool by that name exists in this server, under any configuration. Check the spelling, or call this tool with no arguments to see what is available."
|
|
34490
|
+
};
|
|
34491
|
+
}
|
|
34492
|
+
function buildMetaTools(state) {
|
|
34493
|
+
return [
|
|
34494
|
+
{
|
|
34495
|
+
name: "tailscale_tool_groups",
|
|
34496
|
+
description: "Explain which of this server's tools are available and why. Call this FIRST when a Tailscale tool you expected is missing, instead of assuming the capability does not exist -- tools can be withheld by configuration, and the fix is usually one environment variable. Pass `toolName` to ask about one specific tool (e.g. 'tailscale_delete_device'): the answer distinguishes 'no such tool exists' -- where you should find another approach -- from 'it exists but its group is not loaded' and 'it exists and loaded but writes are withheld there', both of which the operator can enable and neither of which you should work around. With no arguments it lists every group with its availability and, where something is withheld, the exact environment change that would restore it. Always available regardless of filters.",
|
|
34497
|
+
annotations: {
|
|
34498
|
+
title: "Explain available tools",
|
|
34499
|
+
readOnlyHint: true,
|
|
34500
|
+
destructiveHint: false,
|
|
34501
|
+
idempotentHint: true,
|
|
34502
|
+
// The only tool here that touches no network: it reports this process's own
|
|
34503
|
+
// configuration, so it cannot fail on credentials, scope or connectivity.
|
|
34504
|
+
openWorldHint: false
|
|
34505
|
+
},
|
|
34506
|
+
inputSchema: external_exports.object({
|
|
34507
|
+
toolName: external_exports.string().trim().min(1).optional().describe("A specific tool to ask about, e.g. 'tailscale_delete_device'. Omit to list every group.")
|
|
34508
|
+
}),
|
|
34509
|
+
handler: async (input) => {
|
|
34510
|
+
if (input.toolName) {
|
|
34511
|
+
return { ok: true, data: explainTool(input.toolName, state) };
|
|
34512
|
+
}
|
|
34513
|
+
const groups = buildGroupReports(state);
|
|
34514
|
+
const withheld = groups.filter((g) => g.status !== "full");
|
|
34515
|
+
const totalTools = groups.reduce((n, g) => n + g.tools, 0);
|
|
34516
|
+
const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
|
|
34517
|
+
const activeFilters = [
|
|
34518
|
+
state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
|
|
34519
|
+
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
|
|
34520
|
+
state.readonlyMode ? "TAILSCALE_READONLY=1" : null,
|
|
34521
|
+
state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
|
|
34522
|
+
state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
|
|
34523
|
+
].filter(Boolean);
|
|
34524
|
+
return {
|
|
34525
|
+
ok: true,
|
|
34526
|
+
data: {
|
|
34527
|
+
summary: withheld.length === 0 ? `All ${totalAvailable} tools are available; nothing is withheld by configuration.` : `${totalAvailable} of ${totalTools} tools are available. ${withheld.length} group(s) are limited by configuration -- see \`groups\` for the exact environment change for each.`,
|
|
34528
|
+
activeFilters: activeFilters.length > 0 ? activeFilters : ["none -- no filters are configured"],
|
|
34529
|
+
groups,
|
|
34530
|
+
// Addressed to the model, because it is the model that has to decide
|
|
34531
|
+
// what to do next when a tool is absent.
|
|
34532
|
+
guidance: "A tool listed as unavailable here EXISTS -- it is withheld by this server's configuration, not missing from the API. Do not work around it by using a different tool to achieve the same effect; report the `toEnable` value to the human instead, since only they can change it."
|
|
34533
|
+
}
|
|
34534
|
+
};
|
|
34535
|
+
}
|
|
34536
|
+
}
|
|
34537
|
+
];
|
|
34538
|
+
}
|
|
34539
|
+
|
|
34049
34540
|
// src/index.ts
|
|
34050
|
-
var version2 = true ? "0.
|
|
34541
|
+
var version2 = true ? "0.19.0" : resolveVersionFallback();
|
|
34051
34542
|
var subcommand = process.argv[2];
|
|
34052
34543
|
var cliSubcommandHandled = false;
|
|
34053
34544
|
if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
@@ -34080,11 +34571,16 @@ if (!cliSubcommandHandled) {
|
|
|
34080
34571
|
unknownProfile,
|
|
34081
34572
|
explicitTools,
|
|
34082
34573
|
profileWouldFilter,
|
|
34083
|
-
toolsAllUnknown
|
|
34574
|
+
toolsAllUnknown,
|
|
34575
|
+
writeGroups,
|
|
34576
|
+
unknownWriteGroups,
|
|
34577
|
+
writeGroupsNotLoaded,
|
|
34578
|
+
writeGroupsOverriddenByReadonly
|
|
34084
34579
|
} = filterTools(toolGroups, {
|
|
34085
34580
|
tools: process.env.TAILSCALE_TOOLS,
|
|
34086
34581
|
readonly: process.env.TAILSCALE_READONLY,
|
|
34087
|
-
profile: process.env.TAILSCALE_PROFILE
|
|
34582
|
+
profile: process.env.TAILSCALE_PROFILE,
|
|
34583
|
+
writeGroups: process.env.TAILSCALE_WRITE_GROUPS
|
|
34088
34584
|
});
|
|
34089
34585
|
if (unknownGroups.length > 0) {
|
|
34090
34586
|
const validNames = Object.keys(toolGroups);
|
|
@@ -34093,6 +34589,30 @@ if (!cliSubcommandHandled) {
|
|
|
34093
34589
|
`@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
|
|
34094
34590
|
);
|
|
34095
34591
|
}
|
|
34592
|
+
if (unknownWriteGroups && unknownWriteGroups.length > 0) {
|
|
34593
|
+
const validNames = Object.keys(toolGroups);
|
|
34594
|
+
const everyPossibleGroup = Object.keys(buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }));
|
|
34595
|
+
const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
|
|
34596
|
+
const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
|
|
34597
|
+
if (notEnabled.length > 0) {
|
|
34598
|
+
console.error(
|
|
34599
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS names group(s) that exist but are not enabled in this process: ${notEnabled.join(", ")}. Set TAILSCALE_LOCAL_CLI=1 to register the local-cli group. Not a typo -- the grant simply had nothing to apply to.`
|
|
34600
|
+
);
|
|
34601
|
+
}
|
|
34602
|
+
if (realTypos.length > 0) {
|
|
34603
|
+
const sentinels = /* @__PURE__ */ new Set(["none", "off", "false", "0", "all", "*"]);
|
|
34604
|
+
const guessed = realTypos.filter((g) => sentinels.has(g.toLowerCase()));
|
|
34605
|
+
const hint = guessed.some((g) => ["all", "*"].includes(g.toLowerCase())) ? ' Note: "all" is not a group name -- leave TAILSCALE_WRITE_GROUPS unset to allow writes in every loaded group.' : guessed.length > 0 ? " Note: to disable writes entirely, TAILSCALE_READONLY=1 is the shipped spelling." : "";
|
|
34606
|
+
console.error(
|
|
34607
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS includes unknown group(s): ${realTypos.join(", ")}. Valid groups: ${validNames.join(", ")}. Those names granted no write access; tools outside the granted groups are served read-only.${hint}`
|
|
34608
|
+
);
|
|
34609
|
+
}
|
|
34610
|
+
}
|
|
34611
|
+
if (writeGroupsNotLoaded && writeGroupsNotLoaded.length > 0) {
|
|
34612
|
+
console.error(
|
|
34613
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_WRITE_GROUPS names group(s) that your TAILSCALE_TOOLS / TAILSCALE_PROFILE filter does not load: ${writeGroupsNotLoaded.join(", ")}. Those grants had no effect.`
|
|
34614
|
+
);
|
|
34615
|
+
}
|
|
34096
34616
|
if (unknownProfileGroups && unknownProfileGroups.length > 0) {
|
|
34097
34617
|
console.error(
|
|
34098
34618
|
`@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.`
|
|
@@ -34104,35 +34624,79 @@ if (!cliSubcommandHandled) {
|
|
|
34104
34624
|
}
|
|
34105
34625
|
if (unknownProfile) {
|
|
34106
34626
|
console.error(
|
|
34107
|
-
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles:
|
|
34627
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
|
|
34108
34628
|
);
|
|
34109
34629
|
}
|
|
34110
34630
|
const server = new McpServer({
|
|
34111
34631
|
name: "@yawlabs/tailscale-mcp",
|
|
34112
34632
|
version: version2
|
|
34113
34633
|
});
|
|
34634
|
+
const requireApproval = isRequireApprovalEnabled(process.env);
|
|
34635
|
+
const metaTools = buildMetaTools({
|
|
34636
|
+
// The FULL registry, with opt-ins forced on, so the catalog can report on a group
|
|
34637
|
+
// that is currently disabled -- which is exactly the group an agent needs
|
|
34638
|
+
// explained. Reporting only what loaded would make local-cli invisible rather
|
|
34639
|
+
// than explained.
|
|
34640
|
+
fullRegistry: buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }),
|
|
34641
|
+
// Ground truth for availability: what this server actually serves. Deliberately
|
|
34642
|
+
// not a re-derivation of the filter logic, so the catalog cannot disagree with
|
|
34643
|
+
// the server about what exists.
|
|
34644
|
+
registeredNames: new Set(allTools.map((t) => t.name)),
|
|
34645
|
+
toolsEnv: process.env.TAILSCALE_TOOLS,
|
|
34646
|
+
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
34647
|
+
writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
|
|
34648
|
+
readonlyMode: parseReadonlyFlag(process.env.TAILSCALE_READONLY),
|
|
34649
|
+
localCliEnabled
|
|
34650
|
+
});
|
|
34651
|
+
for (const tool of metaTools) {
|
|
34652
|
+
server.registerTool(
|
|
34653
|
+
tool.name,
|
|
34654
|
+
{
|
|
34655
|
+
title: tool.annotations.title,
|
|
34656
|
+
description: tool.description,
|
|
34657
|
+
inputSchema: tool.inputSchema.shape,
|
|
34658
|
+
annotations: tool.annotations,
|
|
34659
|
+
_meta: buildToolMeta(tool.name, { requireApproval })
|
|
34660
|
+
},
|
|
34661
|
+
wrapToolHandler(tool)
|
|
34662
|
+
);
|
|
34663
|
+
}
|
|
34114
34664
|
for (const tool of allTools) {
|
|
34115
|
-
server.
|
|
34665
|
+
server.registerTool(
|
|
34666
|
+
tool.name,
|
|
34667
|
+
{
|
|
34668
|
+
// Hoisted out of annotations.title, which every tool file already sets.
|
|
34669
|
+
// The annotations copy deliberately stays where it is: clients reading
|
|
34670
|
+
// the legacy location keep working, so this is purely additive on the
|
|
34671
|
+
// wire rather than a move.
|
|
34672
|
+
title: tool.annotations.title,
|
|
34673
|
+
description: tool.description,
|
|
34674
|
+
inputSchema: tool.inputSchema.shape,
|
|
34675
|
+
annotations: tool.annotations,
|
|
34676
|
+
_meta: buildToolMeta(tool.name, { requireApproval })
|
|
34677
|
+
},
|
|
34678
|
+
wrapToolHandler(tool)
|
|
34679
|
+
);
|
|
34116
34680
|
}
|
|
34117
|
-
server.
|
|
34681
|
+
server.registerResource(
|
|
34118
34682
|
"tailnet-status",
|
|
34119
34683
|
"tailscale://tailnet/status",
|
|
34120
34684
|
{ description: "Current tailnet status including device count and settings", mimeType: "application/json" },
|
|
34121
34685
|
tailnetStatusResource
|
|
34122
34686
|
);
|
|
34123
|
-
server.
|
|
34687
|
+
server.registerResource(
|
|
34124
34688
|
"tailnet-devices",
|
|
34125
34689
|
"tailscale://tailnet/devices",
|
|
34126
34690
|
{ description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
|
|
34127
34691
|
tailnetDevicesResource
|
|
34128
34692
|
);
|
|
34129
|
-
server.
|
|
34693
|
+
server.registerResource(
|
|
34130
34694
|
"tailnet-acl",
|
|
34131
34695
|
"tailscale://tailnet/acl",
|
|
34132
34696
|
{ description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
|
|
34133
34697
|
tailnetAclResource
|
|
34134
34698
|
);
|
|
34135
|
-
server.
|
|
34699
|
+
server.registerResource(
|
|
34136
34700
|
"tailnet-dns",
|
|
34137
34701
|
"tailscale://tailnet/dns",
|
|
34138
34702
|
{
|
|
@@ -34154,18 +34718,30 @@ if (!cliSubcommandHandled) {
|
|
|
34154
34718
|
profileWouldFilter,
|
|
34155
34719
|
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
34156
34720
|
readonlyMode,
|
|
34157
|
-
localCliEnabled
|
|
34721
|
+
localCliEnabled,
|
|
34722
|
+
writeGroups,
|
|
34723
|
+
writeGroupsOverriddenByReadonly
|
|
34158
34724
|
});
|
|
34159
34725
|
console.error(
|
|
34160
34726
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
34161
34727
|
);
|
|
34162
34728
|
const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
34729
|
+
const ADMIN_EQUIVALENT = ["keys", "users", "acl"];
|
|
34730
|
+
const registeredNames = new Set(allTools.map((t) => t.name));
|
|
34731
|
+
const adminWritable = ADMIN_EQUIVALENT.filter(
|
|
34732
|
+
(g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
|
|
34733
|
+
);
|
|
34734
|
+
if (adminWritable.length > 0 && hasCreds) {
|
|
34735
|
+
console.error(
|
|
34736
|
+
`@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent. tailscale_create_key mints an OAuth client with any scopes the caller asks for, tailscale_update_user_role accepts "owner", and tailscale_update_acl rewrites policy for every principal. Scope the Tailscale OAuth client itself to the areas you need -- that bound survives outside this process; this one does not. TAILSCALE_WRITE_GROUPS narrows what this server exposes.`
|
|
34737
|
+
);
|
|
34738
|
+
}
|
|
34163
34739
|
if (!filterSuffix && hasCreds) {
|
|
34164
34740
|
const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
|
|
34165
34741
|
const coreCount = profileCount(PROFILES.core);
|
|
34166
34742
|
const minimalCount = profileCount(PROFILES.minimal);
|
|
34167
34743
|
console.error(
|
|
34168
|
-
`@yawlabs/tailscale-mcp: tip
|
|
34744
|
+
`@yawlabs/tailscale-mcp: tip -- set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
|
|
34169
34745
|
);
|
|
34170
34746
|
}
|
|
34171
34747
|
}
|