@yawlabs/tailscale-mcp 0.21.2 → 0.23.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 +16 -10
- package/bin/tailscale-mcp.mjs +216 -44
- package/dist/index.js +469 -243
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -31350,9 +31350,6 @@ var StdioServerTransport = class {
|
|
|
31350
31350
|
}
|
|
31351
31351
|
};
|
|
31352
31352
|
|
|
31353
|
-
// src/cli.ts
|
|
31354
|
-
import { readFileSync } from "node:fs";
|
|
31355
|
-
|
|
31356
31353
|
// src/api.ts
|
|
31357
31354
|
var BASE_URL = "https://api.tailscale.com/api/v2";
|
|
31358
31355
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
@@ -31390,17 +31387,27 @@ function getAuthConfig() {
|
|
|
31390
31387
|
`No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
|
|
31391
31388
|
);
|
|
31392
31389
|
}
|
|
31390
|
+
function hasUsableCredentials() {
|
|
31391
|
+
try {
|
|
31392
|
+
getAuthConfig();
|
|
31393
|
+
return true;
|
|
31394
|
+
} catch {
|
|
31395
|
+
return false;
|
|
31396
|
+
}
|
|
31397
|
+
}
|
|
31393
31398
|
function getOAuthTailnet() {
|
|
31394
31399
|
const raw = process.env.TAILSCALE_OAUTH_TAILNET?.trim();
|
|
31395
31400
|
return raw ? raw : void 0;
|
|
31396
31401
|
}
|
|
31397
|
-
|
|
31402
|
+
var OAUTH_FALLBACK_EXPIRES_IN_SEC = 300;
|
|
31403
|
+
async function getOAuthAccessToken(clientId, clientSecret, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
31398
31404
|
if (oauthToken && Date.now() < oauthToken.expires_at - 6e4) {
|
|
31399
31405
|
return oauthToken.access_token;
|
|
31400
31406
|
}
|
|
31401
31407
|
if (oauthRefreshPromise) {
|
|
31402
31408
|
return oauthRefreshPromise;
|
|
31403
31409
|
}
|
|
31410
|
+
const exchangeTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, Math.max(1, timeoutMs));
|
|
31404
31411
|
oauthRefreshPromise = (async () => {
|
|
31405
31412
|
try {
|
|
31406
31413
|
const oauthTailnet = getOAuthTailnet();
|
|
@@ -31413,7 +31420,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31413
31420
|
client_secret: clientSecret,
|
|
31414
31421
|
grant_type: "client_credentials"
|
|
31415
31422
|
}),
|
|
31416
|
-
signal: AbortSignal.timeout(
|
|
31423
|
+
signal: AbortSignal.timeout(exchangeTimeoutMs)
|
|
31417
31424
|
});
|
|
31418
31425
|
if (!res.ok) {
|
|
31419
31426
|
const body = await res.text();
|
|
@@ -31421,9 +31428,13 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31421
31428
|
throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
|
|
31422
31429
|
}
|
|
31423
31430
|
const data = await res.json();
|
|
31431
|
+
if (typeof data.access_token !== "string" || data.access_token === "") {
|
|
31432
|
+
throw new Error("OAuth token exchange succeeded but the response carried no access_token.");
|
|
31433
|
+
}
|
|
31434
|
+
const expiresInSec = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : OAUTH_FALLBACK_EXPIRES_IN_SEC;
|
|
31424
31435
|
oauthToken = {
|
|
31425
31436
|
access_token: data.access_token,
|
|
31426
|
-
expires_at: Date.now() +
|
|
31437
|
+
expires_at: Date.now() + expiresInSec * 1e3
|
|
31427
31438
|
};
|
|
31428
31439
|
return oauthToken.access_token;
|
|
31429
31440
|
} finally {
|
|
@@ -31437,12 +31448,12 @@ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
|
|
|
31437
31448
|
if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
|
|
31438
31449
|
oauthToken = null;
|
|
31439
31450
|
}
|
|
31440
|
-
async function getAuthHeader() {
|
|
31451
|
+
async function getAuthHeader(timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
31441
31452
|
const config2 = getAuthConfig();
|
|
31442
31453
|
if (config2.kind === "apiKey") {
|
|
31443
31454
|
return `Basic ${Buffer.from(`${config2.apiKey}:`).toString("base64")}`;
|
|
31444
31455
|
}
|
|
31445
|
-
const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret);
|
|
31456
|
+
const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret, timeoutMs);
|
|
31446
31457
|
return `Bearer ${token}`;
|
|
31447
31458
|
}
|
|
31448
31459
|
function getTailnet() {
|
|
@@ -31662,11 +31673,22 @@ async function executeFetch(method, url2, headers, body, timeoutMs) {
|
|
|
31662
31673
|
signal: AbortSignal.timeout(timeoutMs)
|
|
31663
31674
|
});
|
|
31664
31675
|
}
|
|
31676
|
+
var UNDICI_TIMEOUT_PHASES = {
|
|
31677
|
+
UND_ERR_CONNECT_TIMEOUT: "while connecting",
|
|
31678
|
+
UND_ERR_HEADERS_TIMEOUT: "waiting for the response headers",
|
|
31679
|
+
UND_ERR_BODY_TIMEOUT: "reading the response body"
|
|
31680
|
+
};
|
|
31681
|
+
function undiciTimeoutPhase(err) {
|
|
31682
|
+
const code = err?.cause?.code;
|
|
31683
|
+
return typeof code === "string" ? UNDICI_TIMEOUT_PHASES[code] ?? null : null;
|
|
31684
|
+
}
|
|
31665
31685
|
function describeTransportError(err, method, attemptTimeoutMs) {
|
|
31666
31686
|
if (err instanceof Error) {
|
|
31667
31687
|
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
31668
31688
|
return `${method} request timed out after ${attemptTimeoutMs}ms`;
|
|
31669
31689
|
}
|
|
31690
|
+
const phase = undiciTimeoutPhase(err);
|
|
31691
|
+
if (phase) return `${method} request timed out ${phase}`;
|
|
31670
31692
|
const cause = err.cause;
|
|
31671
31693
|
if (cause instanceof Error && cause.message) {
|
|
31672
31694
|
return `${method} request failed: ${err.message} (${cause.message})`;
|
|
@@ -31685,7 +31707,8 @@ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
|
|
|
31685
31707
|
return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
|
|
31686
31708
|
}
|
|
31687
31709
|
function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, priorTransportError) {
|
|
31688
|
-
if (status !== 404
|
|
31710
|
+
if (status !== 404 && status !== 0) return error51;
|
|
31711
|
+
if (method.toUpperCase() !== "DELETE") return error51;
|
|
31689
31712
|
const causes = [];
|
|
31690
31713
|
if (priorGatewayStatus !== void 0) causes.push(`returned HTTP ${priorGatewayStatus}`);
|
|
31691
31714
|
if (priorTransportError !== void 0) causes.push(`never returned a response (${priorTransportError})`);
|
|
@@ -31693,6 +31716,11 @@ function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, pr
|
|
|
31693
31716
|
const subject = causes.length > 1 ? "earlier attempts" : "an earlier attempt";
|
|
31694
31717
|
return `${error51 || `HTTP ${status}`} (${subject} ${causes.join(" and ")}; the delete may already have succeeded)`;
|
|
31695
31718
|
}
|
|
31719
|
+
function annotateTransportFailure(error51, method, priorGatewayStatus, priorTransportError) {
|
|
31720
|
+
if (method.toUpperCase() !== "DELETE") return error51;
|
|
31721
|
+
if (priorGatewayStatus === void 0 && priorTransportError === void 0) return error51;
|
|
31722
|
+
return annotateAmbiguousDelete(error51, 0, method, priorGatewayStatus, priorTransportError);
|
|
31723
|
+
}
|
|
31696
31724
|
async function apiRequest(method, path, body, options) {
|
|
31697
31725
|
const headers = {};
|
|
31698
31726
|
if (options?.accept) {
|
|
@@ -31723,11 +31751,14 @@ async function apiRequest(method, path, body, options) {
|
|
|
31723
31751
|
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
31724
31752
|
const requestBudgetMs = getRequestBudgetMs();
|
|
31725
31753
|
return withConcurrencyLimit(async (queuedForMs) => {
|
|
31726
|
-
|
|
31754
|
+
const authTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, requestBudgetMs - (Date.now() - startedAt));
|
|
31755
|
+
headers.Authorization = await getAuthHeader(authTimeoutMs);
|
|
31727
31756
|
let res;
|
|
31728
31757
|
let lastTransportError;
|
|
31729
31758
|
let priorGatewayStatus;
|
|
31730
31759
|
let priorTransportError;
|
|
31760
|
+
let priorRateLimited = false;
|
|
31761
|
+
const withRateLimitNote = (error51) => priorRateLimited ? `${error51} (an earlier attempt was rate-limited: HTTP 429)` : error51;
|
|
31731
31762
|
let budgetMs = requestBudgetMs;
|
|
31732
31763
|
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
31733
31764
|
const remaining = budgetMs - (Date.now() - startedAt);
|
|
@@ -31735,7 +31766,14 @@ async function apiRequest(method, path, body, options) {
|
|
|
31735
31766
|
return {
|
|
31736
31767
|
ok: false,
|
|
31737
31768
|
status: 0,
|
|
31738
|
-
error:
|
|
31769
|
+
error: withRateLimitNote(
|
|
31770
|
+
annotateTransportFailure(
|
|
31771
|
+
describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError),
|
|
31772
|
+
method,
|
|
31773
|
+
priorGatewayStatus,
|
|
31774
|
+
priorTransportError
|
|
31775
|
+
)
|
|
31776
|
+
)
|
|
31739
31777
|
};
|
|
31740
31778
|
}
|
|
31741
31779
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
@@ -31747,12 +31785,27 @@ async function apiRequest(method, path, body, options) {
|
|
|
31747
31785
|
const desc = describeTransportError(err, method, attemptTimeoutMs);
|
|
31748
31786
|
lastTransportError = desc;
|
|
31749
31787
|
if (!isRetryable || attempt === MAX_429_RETRIES) {
|
|
31750
|
-
return {
|
|
31788
|
+
return {
|
|
31789
|
+
ok: false,
|
|
31790
|
+
status: 0,
|
|
31791
|
+
error: withRateLimitNote(annotateTransportFailure(desc, method, priorGatewayStatus, priorTransportError))
|
|
31792
|
+
};
|
|
31751
31793
|
}
|
|
31752
31794
|
const delay2 = compute429DelayMs(null, attempt);
|
|
31753
31795
|
const elapsed3 = Date.now() - startedAt;
|
|
31754
31796
|
if (budgetMs - elapsed3 - delay2 <= 0) {
|
|
31755
|
-
return {
|
|
31797
|
+
return {
|
|
31798
|
+
ok: false,
|
|
31799
|
+
status: 0,
|
|
31800
|
+
error: withRateLimitNote(
|
|
31801
|
+
annotateTransportFailure(
|
|
31802
|
+
`${desc}; request budget exhausted before retry.`,
|
|
31803
|
+
method,
|
|
31804
|
+
priorGatewayStatus,
|
|
31805
|
+
priorTransportError
|
|
31806
|
+
)
|
|
31807
|
+
)
|
|
31808
|
+
};
|
|
31756
31809
|
}
|
|
31757
31810
|
debugLog(
|
|
31758
31811
|
` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
|
|
@@ -31776,7 +31829,9 @@ async function apiRequest(method, path, body, options) {
|
|
|
31776
31829
|
break;
|
|
31777
31830
|
}
|
|
31778
31831
|
debugLog(` -> ${res.status} (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
31779
|
-
if (res.status
|
|
31832
|
+
if (res.status === 429) {
|
|
31833
|
+
priorRateLimited = true;
|
|
31834
|
+
} else {
|
|
31780
31835
|
priorGatewayStatus = res.status;
|
|
31781
31836
|
budgetMs = retryCeilingMs;
|
|
31782
31837
|
}
|
|
@@ -31818,13 +31873,17 @@ async function apiRequest(method, path, body, options) {
|
|
|
31818
31873
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
31819
31874
|
return { ok: true, status: response.status, etag };
|
|
31820
31875
|
}
|
|
31821
|
-
const
|
|
31876
|
+
const text = await response.text();
|
|
31877
|
+
if (text.trim() === "") {
|
|
31878
|
+
return { ok: true, status: response.status, etag };
|
|
31879
|
+
}
|
|
31880
|
+
const data = JSON.parse(text);
|
|
31822
31881
|
return { ok: true, status: response.status, data, etag };
|
|
31823
31882
|
} catch (err) {
|
|
31824
31883
|
return {
|
|
31825
31884
|
ok: false,
|
|
31826
31885
|
status: response.status,
|
|
31827
|
-
error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${err instanceof Error ? err.message : String(err)}`,
|
|
31886
|
+
error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${undiciTimeoutPhase(err) ? `timed out ${undiciTimeoutPhase(err)}` : err instanceof Error ? err.message : String(err)}`,
|
|
31828
31887
|
etag
|
|
31829
31888
|
};
|
|
31830
31889
|
}
|
|
@@ -31847,6 +31906,7 @@ async function apiDelete(path, options) {
|
|
|
31847
31906
|
}
|
|
31848
31907
|
|
|
31849
31908
|
// src/cli.ts
|
|
31909
|
+
import { readFileSync } from "node:fs";
|
|
31850
31910
|
function isWarningsOnly(message, data) {
|
|
31851
31911
|
if (!/^warning/i.test(message)) return false;
|
|
31852
31912
|
if (!Array.isArray(data) || data.length === 0) return false;
|
|
@@ -31880,13 +31940,22 @@ function parseValidationError(rawBody) {
|
|
|
31880
31940
|
...details ? { details } : {}
|
|
31881
31941
|
};
|
|
31882
31942
|
}
|
|
31943
|
+
var CliFailure = class extends Error {
|
|
31944
|
+
constructor() {
|
|
31945
|
+
super("CLI failure reported on stderr");
|
|
31946
|
+
this.name = "CliFailure";
|
|
31947
|
+
}
|
|
31948
|
+
};
|
|
31883
31949
|
function readPolicyFile(filePath) {
|
|
31884
31950
|
try {
|
|
31885
31951
|
const text = readFileSync(filePath, "utf-8");
|
|
31886
31952
|
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
31887
31953
|
} catch (err) {
|
|
31888
|
-
|
|
31889
|
-
|
|
31954
|
+
const denied = err?.code === "ERR_ACCESS_DENIED";
|
|
31955
|
+
const hint = denied ? " -- file access is denied by the tailscale-mcp permission sandbox; run this command with the sandbox off (README: Sandboxing)" : "";
|
|
31956
|
+
console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}${hint}`);
|
|
31957
|
+
process.exitCode = 1;
|
|
31958
|
+
throw new CliFailure();
|
|
31890
31959
|
}
|
|
31891
31960
|
}
|
|
31892
31961
|
async function validatePolicy(policy) {
|
|
@@ -31898,13 +31967,15 @@ async function validatePolicy(policy) {
|
|
|
31898
31967
|
});
|
|
31899
31968
|
if (!validateRes.ok) {
|
|
31900
31969
|
console.error(`ACL validation failed: ${validateRes.error}`);
|
|
31901
|
-
process.
|
|
31970
|
+
process.exitCode = 1;
|
|
31971
|
+
throw new CliFailure();
|
|
31902
31972
|
}
|
|
31903
31973
|
const validation = parseValidationError(validateRes.rawBody);
|
|
31904
31974
|
if (validation.kind !== "valid") {
|
|
31905
31975
|
console.error(`ACL validation failed: ${validation.message}${validation.details ? `
|
|
31906
31976
|
${validation.details}` : ""}`);
|
|
31907
|
-
process.
|
|
31977
|
+
process.exitCode = 1;
|
|
31978
|
+
throw new CliFailure();
|
|
31908
31979
|
}
|
|
31909
31980
|
}
|
|
31910
31981
|
async function validateAcl(filePath) {
|
|
@@ -31914,12 +31985,13 @@ async function validateAcl(filePath) {
|
|
|
31914
31985
|
}
|
|
31915
31986
|
async function deployAcl(filePath) {
|
|
31916
31987
|
const policy = readPolicyFile(filePath);
|
|
31988
|
+
await validatePolicy(policy);
|
|
31917
31989
|
const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
|
|
31918
31990
|
if (!getRes.ok || !getRes.etag) {
|
|
31919
31991
|
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31920
|
-
process.
|
|
31992
|
+
process.exitCode = 1;
|
|
31993
|
+
throw new CliFailure();
|
|
31921
31994
|
}
|
|
31922
|
-
await validatePolicy(policy);
|
|
31923
31995
|
const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31924
31996
|
rawBody: policy,
|
|
31925
31997
|
contentType: "application/hujson",
|
|
@@ -31935,7 +32007,8 @@ async function deployAcl(filePath) {
|
|
|
31935
32007
|
} else {
|
|
31936
32008
|
console.error(`ACL deploy failed: ${deployRes.error}`);
|
|
31937
32009
|
}
|
|
31938
|
-
process.
|
|
32010
|
+
process.exitCode = 1;
|
|
32011
|
+
throw new CliFailure();
|
|
31939
32012
|
}
|
|
31940
32013
|
console.log("ACL deployed successfully");
|
|
31941
32014
|
}
|
|
@@ -32055,13 +32128,13 @@ async function previewAccess(policy, principal) {
|
|
|
32055
32128
|
}
|
|
32056
32129
|
return {
|
|
32057
32130
|
ok: true,
|
|
32058
|
-
access: accessSet(parsed.matches, parsed.postures, false),
|
|
32059
32131
|
hasPostureDefs: !!parsed.postures,
|
|
32060
32132
|
matches: parsed.matches,
|
|
32061
32133
|
postureDefs: parsed.postures
|
|
32062
32134
|
};
|
|
32063
32135
|
}
|
|
32064
32136
|
var ETAG_FOOTER_MARKER = "// ETag: ";
|
|
32137
|
+
var NO_ETAG_MARKER = "// WARNING: the API returned no ETag";
|
|
32065
32138
|
function stripEtagFooter(body) {
|
|
32066
32139
|
const lines = body.split("\n");
|
|
32067
32140
|
let cut = lines.length;
|
|
@@ -32069,7 +32142,7 @@ function stripEtagFooter(body) {
|
|
|
32069
32142
|
const line = lines[i].trim();
|
|
32070
32143
|
if (line === "") continue;
|
|
32071
32144
|
if (!line.startsWith("//")) break;
|
|
32072
|
-
if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
|
|
32145
|
+
if (line.startsWith(ETAG_FOOTER_MARKER) || line.startsWith(NO_ETAG_MARKER)) cut = i;
|
|
32073
32146
|
}
|
|
32074
32147
|
return lines.slice(0, cut).join("\n");
|
|
32075
32148
|
}
|
|
@@ -32087,7 +32160,7 @@ function normalizeIfMatch(etag) {
|
|
|
32087
32160
|
var aclTools = [
|
|
32088
32161
|
{
|
|
32089
32162
|
name: "tailscale_get_acl",
|
|
32090
|
-
description: "Get the current ACL policy for your tailnet. Returns the raw policy text with original formatting preserved, including comments and trailing commas (HuJSON). Also returns an ETag
|
|
32163
|
+
description: "Get the current ACL policy for your tailnet. Returns the raw policy text with original formatting preserved, including comments and trailing commas (HuJSON). Also returns an ETag -- you must pass it to tailscale_update_acl to safely update the policy.",
|
|
32091
32164
|
annotations: {
|
|
32092
32165
|
title: "Get ACL policy",
|
|
32093
32166
|
readOnlyHint: true,
|
|
@@ -32106,11 +32179,20 @@ var aclTools = [
|
|
|
32106
32179
|
"",
|
|
32107
32180
|
`${ETAG_FOOTER_MARKER}${res.etag}`,
|
|
32108
32181
|
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
32109
|
-
"// (HuJSON treats // as a comment
|
|
32182
|
+
"// (HuJSON treats // as a comment -- safe to leave in or strip before re-submitting.)",
|
|
32110
32183
|
""
|
|
32111
32184
|
].join("\n");
|
|
32112
32185
|
return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
|
|
32113
32186
|
}
|
|
32187
|
+
if (res.ok) {
|
|
32188
|
+
const warning = [
|
|
32189
|
+
"",
|
|
32190
|
+
`${NO_ETAG_MARKER} for this policy, so there is none to pass to`,
|
|
32191
|
+
"// tailscale_update_acl. Call tailscale_get_acl again before updating.",
|
|
32192
|
+
""
|
|
32193
|
+
].join("\n");
|
|
32194
|
+
return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${warning}` };
|
|
32195
|
+
}
|
|
32114
32196
|
return res;
|
|
32115
32197
|
}
|
|
32116
32198
|
},
|
|
@@ -32147,9 +32229,12 @@ var aclTools = [
|
|
|
32147
32229
|
// this is the code that builds the header, and because the handlers are what
|
|
32148
32230
|
// the tests call directly -- a transform on the schema would be invisible to
|
|
32149
32231
|
// every assertion made at the header.
|
|
32232
|
+
// The get_acl footer is stripped before the POST: it is inert HuJSON, but left
|
|
32233
|
+
// in it would store a comment naming an ETag that stops matching on this very
|
|
32234
|
+
// write, and diff_acl_access would compare against that decorated baseline.
|
|
32150
32235
|
handler: async (input) => {
|
|
32151
32236
|
return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
32152
|
-
rawBody: input.policy,
|
|
32237
|
+
rawBody: stripEtagFooter(input.policy),
|
|
32153
32238
|
contentType: "application/hujson",
|
|
32154
32239
|
ifMatch: normalizeIfMatch(input.etag),
|
|
32155
32240
|
acceptRaw: true,
|
|
@@ -32213,7 +32298,7 @@ var aclTools = [
|
|
|
32213
32298
|
},
|
|
32214
32299
|
{
|
|
32215
32300
|
name: "tailscale_diff_acl_access",
|
|
32216
|
-
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, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening `posture:corp` shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops after 60 seconds
|
|
32301
|
+
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, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening `posture:corp` shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops starting new users after 60 seconds (a user whose previews are already in flight still finishes); 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.",
|
|
32217
32302
|
annotations: {
|
|
32218
32303
|
title: "Diff ACL access",
|
|
32219
32304
|
readOnlyHint: true,
|
|
@@ -32370,7 +32455,9 @@ var aclTools = [
|
|
|
32370
32455
|
// src/tools/audit.ts
|
|
32371
32456
|
function assertRFC3339(value, label) {
|
|
32372
32457
|
const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
32373
|
-
const err = () => new Error(
|
|
32458
|
+
const err = () => new Error(
|
|
32459
|
+
`${label} must be a valid RFC3339 date-time with an uppercase 'T' and 'Z' (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`
|
|
32460
|
+
);
|
|
32374
32461
|
if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
|
|
32375
32462
|
throw err();
|
|
32376
32463
|
}
|
|
@@ -32454,7 +32541,7 @@ var auditTools = [
|
|
|
32454
32541
|
},
|
|
32455
32542
|
{
|
|
32456
32543
|
name: "tailscale_get_network_flow_logs",
|
|
32457
|
-
description: "Get network traffic flow logs showing connections between devices. Shows source/destination nodes, timestamps, and traffic metadata
|
|
32544
|
+
description: "Get network traffic flow logs showing connections between devices. Shows source/destination nodes, timestamps, and traffic metadata -- useful for security monitoring and debugging connectivity.",
|
|
32458
32545
|
annotations: {
|
|
32459
32546
|
title: "Get network flow logs",
|
|
32460
32547
|
readOnlyHint: true,
|
|
@@ -32491,7 +32578,7 @@ function isCidr(s) {
|
|
|
32491
32578
|
if (!/^\d+$/.test(prefix)) return false;
|
|
32492
32579
|
const prefixN = Number(prefix);
|
|
32493
32580
|
if (net.isIPv4(addr)) return prefixN <= 32;
|
|
32494
|
-
if (net.isIPv6(addr)) return prefixN <= 128;
|
|
32581
|
+
if (net.isIPv6(addr)) return !addr.includes("%") && prefixN <= 128;
|
|
32495
32582
|
return false;
|
|
32496
32583
|
}
|
|
32497
32584
|
var DEVICE_FIELDS_DESC = "Which device fields to return. Tailscale documents exactly two values. 'default' (also what you get when this is omitted) is the limited set: addresses, id, nodeId, user, name, hostname, clientVersion, updateAvailable, os, created, connectedToControl, lastSeen, keyExpiryDisabled, expires, authorized, isExternal, machineKey, nodeKey, blocksIncomingConnections, tailnetLockKey, tailnetLockError, tags, isEphemeral. 'all' adds advertisedRoutes, enabledRoutes, clientConnectivity (endpoints, DERP latency), sshEnabled, distro, multipleConnections and postureIdentity (serial numbers and, where a posture integration collects them, hardware/MAC addresses). Omitting it does NOT return everything.";
|
|
@@ -32587,7 +32674,7 @@ var deviceTools = [
|
|
|
32587
32674
|
},
|
|
32588
32675
|
{
|
|
32589
32676
|
name: "tailscale_delete_device",
|
|
32590
|
-
description: "Permanently remove a device from the tailnet. This is irreversible
|
|
32677
|
+
description: "Permanently remove a device from the tailnet. This is irreversible -- the device must re-authenticate to rejoin.",
|
|
32591
32678
|
annotations: {
|
|
32592
32679
|
title: "Delete device",
|
|
32593
32680
|
readOnlyHint: false,
|
|
@@ -32596,7 +32683,10 @@ var deviceTools = [
|
|
|
32596
32683
|
openWorldHint: true
|
|
32597
32684
|
},
|
|
32598
32685
|
inputSchema: external_exports.object({
|
|
32599
|
-
|
|
32686
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only device id encodes
|
|
32687
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
32688
|
+
// device is already gone (rationale: tailnets.ts delete_tailnet).
|
|
32689
|
+
deviceId: external_exports.string().trim().min(1).describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
|
|
32600
32690
|
}),
|
|
32601
32691
|
handler: async (input) => {
|
|
32602
32692
|
return apiDelete(`/device/${encPath(input.deviceId)}`);
|
|
@@ -32658,7 +32748,7 @@ var deviceTools = [
|
|
|
32658
32748
|
},
|
|
32659
32749
|
{
|
|
32660
32750
|
name: "tailscale_set_device_routes",
|
|
32661
|
-
description: "Set the enabled subnet routes for a device. Replaces all currently enabled routes
|
|
32751
|
+
description: "Set the enabled subnet routes for a device. Replaces all currently enabled routes -- pass the full list of routes you want enabled.",
|
|
32662
32752
|
annotations: {
|
|
32663
32753
|
title: "Set device routes",
|
|
32664
32754
|
readOnlyHint: false,
|
|
@@ -32756,7 +32846,7 @@ var deviceTools = [
|
|
|
32756
32846
|
},
|
|
32757
32847
|
{
|
|
32758
32848
|
name: "tailscale_set_device_tags",
|
|
32759
|
-
description: "Set ACL tags on a device. Replaces all existing tags
|
|
32849
|
+
description: "Set ACL tags on a device. Replaces all existing tags -- pass the full list of tags you want applied.",
|
|
32760
32850
|
annotations: {
|
|
32761
32851
|
title: "Set device tags",
|
|
32762
32852
|
readOnlyHint: false,
|
|
@@ -32815,7 +32905,7 @@ var deviceTools = [
|
|
|
32815
32905
|
},
|
|
32816
32906
|
{
|
|
32817
32907
|
name: "tailscale_set_devices_authorized",
|
|
32818
|
-
description: "Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
|
|
32908
|
+
description: "Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. POSTs are never retried, so on a large batch a rate-limited device lands in data.failed with status 429: re-call with just those ids, or set TAILSCALE_MAX_CONCURRENT to cap requests in flight. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
|
|
32819
32909
|
annotations: {
|
|
32820
32910
|
title: "Set devices authorized (bulk)",
|
|
32821
32911
|
readOnlyHint: false,
|
|
@@ -32857,7 +32947,7 @@ var deviceTools = [
|
|
|
32857
32947
|
},
|
|
32858
32948
|
{
|
|
32859
32949
|
name: "tailscale_batch_update_posture_attributes",
|
|
32860
|
-
description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics
|
|
32950
|
+
description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics -- pass null as the attribute config to delete.",
|
|
32861
32951
|
annotations: {
|
|
32862
32952
|
title: "Batch update posture attributes",
|
|
32863
32953
|
readOnlyHint: false,
|
|
@@ -33085,7 +33175,7 @@ var dnsTools = [
|
|
|
33085
33175
|
},
|
|
33086
33176
|
{
|
|
33087
33177
|
name: "tailscale_set_dns_configuration",
|
|
33088
|
-
description: "Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference).",
|
|
33178
|
+
description: "Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference). This is a replace-all endpoint: pass every top-level field you want to keep, because a field you omit may be cleared rather than left alone.",
|
|
33089
33179
|
annotations: {
|
|
33090
33180
|
title: "Set DNS configuration (unified)",
|
|
33091
33181
|
readOnlyHint: false,
|
|
@@ -33098,9 +33188,16 @@ var dnsTools = [
|
|
|
33098
33188
|
inputSchema: external_exports.object({
|
|
33099
33189
|
dns: external_exports.array(external_exports.string()).optional().describe("List of DNS server IP addresses"),
|
|
33100
33190
|
searchPaths: external_exports.array(external_exports.string()).optional().describe("List of DNS search domains"),
|
|
33101
|
-
|
|
33191
|
+
// `.nullable()` to match the PUT/PATCH split-dns siblings above: the
|
|
33192
|
+
// spec's SplitDns schema types each value as an array OR null.
|
|
33193
|
+
splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).optional().describe("Map of domain to nameserver list for split DNS, or to null to clear that domain"),
|
|
33102
33194
|
magicDNS: external_exports.boolean().optional().describe("Whether to enable MagicDNS")
|
|
33103
33195
|
}),
|
|
33196
|
+
// UNVERIFIED: whether an omitted top-level field is preserved or cleared by
|
|
33197
|
+
// the API on a partial body has never been probed live here. The description
|
|
33198
|
+
// warns the caller it MAY be cleared rather than promising a merge -- the
|
|
33199
|
+
// safe reading of a replace-all endpoint -- because claiming preservation we
|
|
33200
|
+
// have not observed is how a silent wipe gets shipped.
|
|
33104
33201
|
handler: async (input) => {
|
|
33105
33202
|
const body = {};
|
|
33106
33203
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -33114,6 +33211,158 @@ var dnsTools = [
|
|
|
33114
33211
|
}
|
|
33115
33212
|
];
|
|
33116
33213
|
|
|
33214
|
+
// src/tools/users.ts
|
|
33215
|
+
var USER_ROLES = [
|
|
33216
|
+
"owner",
|
|
33217
|
+
"admin",
|
|
33218
|
+
"it-admin",
|
|
33219
|
+
"network-admin",
|
|
33220
|
+
"billing-admin",
|
|
33221
|
+
"auditor",
|
|
33222
|
+
"member"
|
|
33223
|
+
];
|
|
33224
|
+
var INVITABLE_ROLES = [
|
|
33225
|
+
"member",
|
|
33226
|
+
"admin",
|
|
33227
|
+
"it-admin",
|
|
33228
|
+
"network-admin",
|
|
33229
|
+
"billing-admin",
|
|
33230
|
+
"auditor"
|
|
33231
|
+
];
|
|
33232
|
+
var userTools = [
|
|
33233
|
+
{
|
|
33234
|
+
name: "tailscale_list_users",
|
|
33235
|
+
description: "List all users in your tailnet.",
|
|
33236
|
+
annotations: {
|
|
33237
|
+
title: "List users",
|
|
33238
|
+
readOnlyHint: true,
|
|
33239
|
+
destructiveHint: false,
|
|
33240
|
+
idempotentHint: true,
|
|
33241
|
+
openWorldHint: true
|
|
33242
|
+
},
|
|
33243
|
+
inputSchema: external_exports.object({
|
|
33244
|
+
type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
|
|
33245
|
+
role: external_exports.enum(USER_ROLES).optional().describe("Filter by user role")
|
|
33246
|
+
}),
|
|
33247
|
+
handler: async (input) => {
|
|
33248
|
+
const params = new URLSearchParams();
|
|
33249
|
+
if (input.type) params.set("type", input.type);
|
|
33250
|
+
if (input.role) params.set("role", input.role);
|
|
33251
|
+
const qs = params.toString();
|
|
33252
|
+
return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
|
|
33253
|
+
}
|
|
33254
|
+
},
|
|
33255
|
+
{
|
|
33256
|
+
name: "tailscale_get_user",
|
|
33257
|
+
description: "Get details for a specific user.",
|
|
33258
|
+
annotations: {
|
|
33259
|
+
title: "Get user",
|
|
33260
|
+
readOnlyHint: true,
|
|
33261
|
+
destructiveHint: false,
|
|
33262
|
+
idempotentHint: true,
|
|
33263
|
+
openWorldHint: true
|
|
33264
|
+
},
|
|
33265
|
+
inputSchema: external_exports.object({
|
|
33266
|
+
userId: external_exports.string().trim().min(1).describe("The user ID")
|
|
33267
|
+
}),
|
|
33268
|
+
handler: async (input) => {
|
|
33269
|
+
return apiGet(`/users/${encPath(input.userId)}`);
|
|
33270
|
+
}
|
|
33271
|
+
},
|
|
33272
|
+
{
|
|
33273
|
+
name: "tailscale_approve_user",
|
|
33274
|
+
description: "Approve a pending user, granting them access to the tailnet.",
|
|
33275
|
+
annotations: {
|
|
33276
|
+
title: "Approve user",
|
|
33277
|
+
readOnlyHint: false,
|
|
33278
|
+
destructiveHint: false,
|
|
33279
|
+
idempotentHint: true,
|
|
33280
|
+
openWorldHint: true
|
|
33281
|
+
},
|
|
33282
|
+
inputSchema: external_exports.object({
|
|
33283
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to approve")
|
|
33284
|
+
}),
|
|
33285
|
+
handler: async (input) => {
|
|
33286
|
+
return apiPost(`/users/${encPath(input.userId)}/approve`);
|
|
33287
|
+
}
|
|
33288
|
+
},
|
|
33289
|
+
{
|
|
33290
|
+
name: "tailscale_suspend_user",
|
|
33291
|
+
description: "Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.",
|
|
33292
|
+
annotations: {
|
|
33293
|
+
title: "Suspend user",
|
|
33294
|
+
readOnlyHint: false,
|
|
33295
|
+
destructiveHint: true,
|
|
33296
|
+
idempotentHint: true,
|
|
33297
|
+
openWorldHint: true
|
|
33298
|
+
},
|
|
33299
|
+
inputSchema: external_exports.object({
|
|
33300
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
|
|
33301
|
+
// to "%20" and suspend returns a 404 that reads like the user is already
|
|
33302
|
+
// gone when it is not (rationale: tailnets.ts delete_tailnet).
|
|
33303
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to suspend")
|
|
33304
|
+
}),
|
|
33305
|
+
handler: async (input) => {
|
|
33306
|
+
return apiPost(`/users/${encPath(input.userId)}/suspend`);
|
|
33307
|
+
}
|
|
33308
|
+
},
|
|
33309
|
+
{
|
|
33310
|
+
name: "tailscale_restore_user",
|
|
33311
|
+
description: "Restore a previously suspended user, re-granting them access to the tailnet.",
|
|
33312
|
+
annotations: {
|
|
33313
|
+
title: "Restore user",
|
|
33314
|
+
readOnlyHint: false,
|
|
33315
|
+
destructiveHint: false,
|
|
33316
|
+
idempotentHint: true,
|
|
33317
|
+
openWorldHint: true
|
|
33318
|
+
},
|
|
33319
|
+
inputSchema: external_exports.object({
|
|
33320
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to restore")
|
|
33321
|
+
}),
|
|
33322
|
+
handler: async (input) => {
|
|
33323
|
+
return apiPost(`/users/${encPath(input.userId)}/restore`);
|
|
33324
|
+
}
|
|
33325
|
+
},
|
|
33326
|
+
{
|
|
33327
|
+
name: "tailscale_update_user_role",
|
|
33328
|
+
description: "Update a user's role in the tailnet.",
|
|
33329
|
+
annotations: {
|
|
33330
|
+
title: "Update user role",
|
|
33331
|
+
readOnlyHint: false,
|
|
33332
|
+
destructiveHint: false,
|
|
33333
|
+
idempotentHint: true,
|
|
33334
|
+
openWorldHint: true
|
|
33335
|
+
},
|
|
33336
|
+
inputSchema: external_exports.object({
|
|
33337
|
+
userId: external_exports.string().trim().min(1).describe("The user ID"),
|
|
33338
|
+
role: external_exports.enum(USER_ROLES).describe("The new role to assign")
|
|
33339
|
+
}),
|
|
33340
|
+
handler: async (input) => {
|
|
33341
|
+
return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
|
|
33342
|
+
}
|
|
33343
|
+
},
|
|
33344
|
+
{
|
|
33345
|
+
name: "tailscale_delete_user",
|
|
33346
|
+
description: "Delete a user from the tailnet. This is irreversible -- the user and all their devices will be removed.",
|
|
33347
|
+
annotations: {
|
|
33348
|
+
title: "Delete user",
|
|
33349
|
+
readOnlyHint: false,
|
|
33350
|
+
destructiveHint: true,
|
|
33351
|
+
idempotentHint: true,
|
|
33352
|
+
openWorldHint: true
|
|
33353
|
+
},
|
|
33354
|
+
inputSchema: external_exports.object({
|
|
33355
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
|
|
33356
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33357
|
+
// user is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33358
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to delete")
|
|
33359
|
+
}),
|
|
33360
|
+
handler: async (input) => {
|
|
33361
|
+
return apiPost(`/users/${encPath(input.userId)}/delete`);
|
|
33362
|
+
}
|
|
33363
|
+
}
|
|
33364
|
+
];
|
|
33365
|
+
|
|
33117
33366
|
// src/tools/invites.ts
|
|
33118
33367
|
var inviteTools = [
|
|
33119
33368
|
// --- Device Invites ---
|
|
@@ -33128,7 +33377,7 @@ var inviteTools = [
|
|
|
33128
33377
|
openWorldHint: true
|
|
33129
33378
|
},
|
|
33130
33379
|
inputSchema: external_exports.object({
|
|
33131
|
-
deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
|
|
33380
|
+
deviceId: external_exports.string().trim().min(1).describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
|
|
33132
33381
|
}),
|
|
33133
33382
|
handler: async (input) => {
|
|
33134
33383
|
return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
|
|
@@ -33145,7 +33394,7 @@ var inviteTools = [
|
|
|
33145
33394
|
openWorldHint: true
|
|
33146
33395
|
},
|
|
33147
33396
|
inputSchema: external_exports.object({
|
|
33148
|
-
deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
|
|
33397
|
+
deviceId: external_exports.string().trim().min(1).describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
|
|
33149
33398
|
multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
33150
33399
|
allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
|
|
33151
33400
|
email: external_exports.email().optional().describe("Email address to send the invite to")
|
|
@@ -33155,6 +33404,9 @@ var inviteTools = [
|
|
|
33155
33404
|
if (input.multiUse !== void 0) body.multiUse = input.multiUse;
|
|
33156
33405
|
if (input.allowExitNode !== void 0) body.allowExitNode = input.allowExitNode;
|
|
33157
33406
|
if (input.email !== void 0) body.email = input.email;
|
|
33407
|
+
if (Object.keys(body).length === 0) {
|
|
33408
|
+
throw new Error("No fields to update. Provide at least one of: multiUse, allowExitNode, email.");
|
|
33409
|
+
}
|
|
33158
33410
|
return apiPost(`/device/${encPath(input.deviceId)}/device-invites`, body);
|
|
33159
33411
|
}
|
|
33160
33412
|
},
|
|
@@ -33169,7 +33421,7 @@ var inviteTools = [
|
|
|
33169
33421
|
openWorldHint: true
|
|
33170
33422
|
},
|
|
33171
33423
|
inputSchema: external_exports.object({
|
|
33172
|
-
inviteId: external_exports.string().describe("The device invite ID")
|
|
33424
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID")
|
|
33173
33425
|
}),
|
|
33174
33426
|
handler: async (input) => {
|
|
33175
33427
|
return apiGet(`/device-invites/${encPath(input.inviteId)}`);
|
|
@@ -33177,7 +33429,7 @@ var inviteTools = [
|
|
|
33177
33429
|
},
|
|
33178
33430
|
{
|
|
33179
33431
|
name: "tailscale_delete_device_invite",
|
|
33180
|
-
description: "Delete a device invite. This is irreversible
|
|
33432
|
+
description: "Delete a device invite. This is irreversible -- the invite link will stop working.",
|
|
33181
33433
|
annotations: {
|
|
33182
33434
|
title: "Delete device invite",
|
|
33183
33435
|
readOnlyHint: false,
|
|
@@ -33186,7 +33438,10 @@ var inviteTools = [
|
|
|
33186
33438
|
openWorldHint: true
|
|
33187
33439
|
},
|
|
33188
33440
|
inputSchema: external_exports.object({
|
|
33189
|
-
inviteId: external_exports.string().describe("The device invite ID to delete")
|
|
33441
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID to delete")
|
|
33442
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
|
|
33443
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33444
|
+
// invite is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33190
33445
|
}),
|
|
33191
33446
|
handler: async (input) => {
|
|
33192
33447
|
return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
|
|
@@ -33237,12 +33492,15 @@ var inviteTools = [
|
|
|
33237
33492
|
},
|
|
33238
33493
|
inputSchema: external_exports.object({
|
|
33239
33494
|
email: external_exports.email().optional().describe("Email address to send the invite to"),
|
|
33240
|
-
role: external_exports.enum(
|
|
33495
|
+
role: external_exports.enum(INVITABLE_ROLES).optional().describe("Role to assign to the invited user (default: member)")
|
|
33241
33496
|
}),
|
|
33242
33497
|
handler: async (input) => {
|
|
33243
33498
|
const body = {};
|
|
33244
33499
|
if (input.email !== void 0) body.email = input.email;
|
|
33245
33500
|
if (input.role !== void 0) body.role = input.role;
|
|
33501
|
+
if (Object.keys(body).length === 0) {
|
|
33502
|
+
throw new Error("No fields to update. Provide at least one of: email, role.");
|
|
33503
|
+
}
|
|
33246
33504
|
return apiPost(`/tailnet/${getTailnet()}/user-invites`, body);
|
|
33247
33505
|
}
|
|
33248
33506
|
},
|
|
@@ -33257,7 +33515,7 @@ var inviteTools = [
|
|
|
33257
33515
|
openWorldHint: true
|
|
33258
33516
|
},
|
|
33259
33517
|
inputSchema: external_exports.object({
|
|
33260
|
-
inviteId: external_exports.string().describe("The user invite ID")
|
|
33518
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID")
|
|
33261
33519
|
}),
|
|
33262
33520
|
handler: async (input) => {
|
|
33263
33521
|
return apiGet(`/user-invites/${encPath(input.inviteId)}`);
|
|
@@ -33265,7 +33523,7 @@ var inviteTools = [
|
|
|
33265
33523
|
},
|
|
33266
33524
|
{
|
|
33267
33525
|
name: "tailscale_delete_user_invite",
|
|
33268
|
-
description: "Delete a user invite. This is irreversible
|
|
33526
|
+
description: "Delete a user invite. This is irreversible -- the invite link will stop working.",
|
|
33269
33527
|
annotations: {
|
|
33270
33528
|
title: "Delete user invite",
|
|
33271
33529
|
readOnlyHint: false,
|
|
@@ -33274,7 +33532,10 @@ var inviteTools = [
|
|
|
33274
33532
|
openWorldHint: true
|
|
33275
33533
|
},
|
|
33276
33534
|
inputSchema: external_exports.object({
|
|
33277
|
-
inviteId: external_exports.string().describe("The user invite ID to delete")
|
|
33535
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID to delete")
|
|
33536
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only inviteId encodes
|
|
33537
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33538
|
+
// invite is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33278
33539
|
}),
|
|
33279
33540
|
handler: async (input) => {
|
|
33280
33541
|
return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
|
|
@@ -33292,7 +33553,7 @@ var inviteTools = [
|
|
|
33292
33553
|
openWorldHint: true
|
|
33293
33554
|
},
|
|
33294
33555
|
inputSchema: external_exports.object({
|
|
33295
|
-
inviteId: external_exports.string().describe("The device invite ID to resend")
|
|
33556
|
+
inviteId: external_exports.string().trim().min(1).describe("The device invite ID to resend")
|
|
33296
33557
|
}),
|
|
33297
33558
|
handler: async (input) => {
|
|
33298
33559
|
return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
|
|
@@ -33310,7 +33571,7 @@ var inviteTools = [
|
|
|
33310
33571
|
openWorldHint: true
|
|
33311
33572
|
},
|
|
33312
33573
|
inputSchema: external_exports.object({
|
|
33313
|
-
inviteId: external_exports.string().describe("The user invite ID to resend")
|
|
33574
|
+
inviteId: external_exports.string().trim().min(1).describe("The user invite ID to resend")
|
|
33314
33575
|
}),
|
|
33315
33576
|
handler: async (input) => {
|
|
33316
33577
|
return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
|
|
@@ -33349,7 +33610,7 @@ var keyTools = [
|
|
|
33349
33610
|
openWorldHint: true
|
|
33350
33611
|
},
|
|
33351
33612
|
inputSchema: external_exports.object({
|
|
33352
|
-
keyId: external_exports.string().describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
|
|
33613
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
|
|
33353
33614
|
}),
|
|
33354
33615
|
handler: async (input) => {
|
|
33355
33616
|
return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33374,7 +33635,8 @@ var keyTools = [
|
|
|
33374
33635
|
reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
|
|
33375
33636
|
ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
|
|
33376
33637
|
preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
|
|
33377
|
-
|
|
33638
|
+
// Seconds, so a fractional or non-positive value is nonsense to send.
|
|
33639
|
+
expirySeconds: external_exports.number().int().positive().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
|
|
33378
33640
|
// Shared fields
|
|
33379
33641
|
tags: external_exports.array(external_exports.string()).optional().describe(
|
|
33380
33642
|
"ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
|
|
@@ -33454,7 +33716,14 @@ var keyTools = [
|
|
|
33454
33716
|
openWorldHint: true
|
|
33455
33717
|
},
|
|
33456
33718
|
inputSchema: external_exports.object({
|
|
33457
|
-
|
|
33719
|
+
// `.trim().min(1)`, as on tailscale_delete_oauth_app's appId below and for
|
|
33720
|
+
// the reason tailnets.ts spells out on tailscale_delete_tailnet: a bare
|
|
33721
|
+
// z.string() lets "" through (encPath then sends a DELETE on the keys
|
|
33722
|
+
// collection path itself) and " " through as the literal segment "%20".
|
|
33723
|
+
// On a read that is a wasted round-trip; on this irreversible delete it
|
|
33724
|
+
// returns a 404 that reads like the key is already gone, with no retry to
|
|
33725
|
+
// notice it with.
|
|
33726
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
|
|
33458
33727
|
}),
|
|
33459
33728
|
handler: async (input) => {
|
|
33460
33729
|
return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33462,7 +33731,7 @@ var keyTools = [
|
|
|
33462
33731
|
},
|
|
33463
33732
|
{
|
|
33464
33733
|
name: "tailscale_update_key",
|
|
33465
|
-
description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description'
|
|
33734
|
+
description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description' -- the Tailscale API will reject other fields.",
|
|
33466
33735
|
annotations: {
|
|
33467
33736
|
title: "Update key",
|
|
33468
33737
|
readOnlyHint: false,
|
|
@@ -33471,7 +33740,7 @@ var keyTools = [
|
|
|
33471
33740
|
openWorldHint: true
|
|
33472
33741
|
},
|
|
33473
33742
|
inputSchema: external_exports.object({
|
|
33474
|
-
keyId: external_exports.string().describe("The key ID to update"),
|
|
33743
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID to update"),
|
|
33475
33744
|
description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
|
|
33476
33745
|
scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
|
|
33477
33746
|
tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
|
|
@@ -33603,7 +33872,7 @@ var keyTools = [
|
|
|
33603
33872
|
// src/tools/local-cli.ts
|
|
33604
33873
|
import * as net2 from "node:net";
|
|
33605
33874
|
|
|
33606
|
-
// src/local-cli.ts
|
|
33875
|
+
// src/local-cli-runner.ts
|
|
33607
33876
|
import { execFile as execFileCb } from "node:child_process";
|
|
33608
33877
|
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
33609
33878
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -33615,6 +33884,10 @@ var DARWIN_CANDIDATES = [
|
|
|
33615
33884
|
];
|
|
33616
33885
|
var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
|
|
33617
33886
|
var execFileImpl = execFileCb;
|
|
33887
|
+
function describeUnexecutableBinary(binary, fromEnv) {
|
|
33888
|
+
const where = fromEnv ? `'${binary}', which is where TAILSCALE_BINARY points` : `'${binary}', found in PATH`;
|
|
33889
|
+
return `Found the 'tailscale' binary at ${where}, but it could not be executed (EACCES). It must be an executable file -- not a directory -- with execute permission for the user running this server. Set TAILSCALE_BINARY to the absolute path of the tailscale executable, or reinstall Tailscale (https://tailscale.com/download).`;
|
|
33890
|
+
}
|
|
33618
33891
|
function binaryCandidates(platform) {
|
|
33619
33892
|
if (platform === "darwin") return DARWIN_CANDIDATES;
|
|
33620
33893
|
if (platform === "linux") return LINUX_CANDIDATES;
|
|
@@ -33642,7 +33915,7 @@ function readProcVersion() {
|
|
|
33642
33915
|
}
|
|
33643
33916
|
function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
33644
33917
|
if (fromEnv) {
|
|
33645
|
-
const pathNote = platform === "win32" ? `On Windows the value must be a Windows path (C:/Program Files/Tailscale/tailscale.exe), not an MSYS one (/c/...) -- Git Bash rewrites /c/... when you type it at the prompt, but a value read from an MCP client's JSON config or a .env file arrives untranslated.` : `It must be the absolute path of an executable file
|
|
33918
|
+
const pathNote = platform === "win32" ? `On Windows the value must be a Windows path (C:/Program Files/Tailscale/tailscale.exe), not an MSYS one (/c/...) -- Git Bash rewrites /c/... when you type it at the prompt, but a value read from an MCP client's JSON config or a .env file arrives untranslated. It must also name tailscale.exe itself, not the folder it is in.` : `It must be the absolute path of an executable file, not a shell alias or function.`;
|
|
33646
33919
|
return `Could not find the 'tailscale' binary at '${binary}', which is where TAILSCALE_BINARY points. PATH was never consulted, so nothing here is a PATH problem. ${pathNote}`;
|
|
33647
33920
|
}
|
|
33648
33921
|
if (platform === "darwin") {
|
|
@@ -33653,12 +33926,30 @@ function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
|
33653
33926
|
}
|
|
33654
33927
|
return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
|
|
33655
33928
|
}
|
|
33929
|
+
var MAX_CLI_CONCURRENCY = 4;
|
|
33930
|
+
var cliInFlight = 0;
|
|
33931
|
+
var cliQueue = [];
|
|
33932
|
+
async function acquireCliSlot() {
|
|
33933
|
+
if (cliInFlight < MAX_CLI_CONCURRENCY) {
|
|
33934
|
+
cliInFlight++;
|
|
33935
|
+
return;
|
|
33936
|
+
}
|
|
33937
|
+
await new Promise((resolve) => cliQueue.push(resolve));
|
|
33938
|
+
}
|
|
33939
|
+
function releaseCliSlot() {
|
|
33940
|
+
const next = cliQueue.shift();
|
|
33941
|
+
if (next)
|
|
33942
|
+
next();
|
|
33943
|
+
else cliInFlight--;
|
|
33944
|
+
}
|
|
33656
33945
|
async function runTailscaleCli(args, options = {}) {
|
|
33657
33946
|
const binary = resolveBinary();
|
|
33658
33947
|
const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
|
|
33659
33948
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33949
|
+
await acquireCliSlot();
|
|
33660
33950
|
return new Promise((resolve) => {
|
|
33661
33951
|
execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
|
|
33952
|
+
releaseCliSlot();
|
|
33662
33953
|
const stdoutStr = stdout == null ? "" : String(stdout);
|
|
33663
33954
|
const stderrStr = stderr == null ? "" : String(stderr);
|
|
33664
33955
|
if (err) {
|
|
@@ -33670,9 +33961,14 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33670
33961
|
});
|
|
33671
33962
|
return;
|
|
33672
33963
|
}
|
|
33964
|
+
if (errno.code === "EACCES") {
|
|
33965
|
+
resolve({ ok: false, error: describeUnexecutableBinary(binary, fromEnv) });
|
|
33966
|
+
return;
|
|
33967
|
+
}
|
|
33673
33968
|
if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
33674
33969
|
resolve({
|
|
33675
33970
|
ok: false,
|
|
33971
|
+
kind: "output-limit",
|
|
33676
33972
|
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.`
|
|
33677
33973
|
});
|
|
33678
33974
|
return;
|
|
@@ -33680,6 +33976,7 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33680
33976
|
if (errno.killed) {
|
|
33681
33977
|
resolve({
|
|
33682
33978
|
ok: false,
|
|
33979
|
+
kind: "timeout",
|
|
33683
33980
|
error: `'${binary} ${args.join(" ")}' timed out after ${timeoutMs}ms`
|
|
33684
33981
|
});
|
|
33685
33982
|
return;
|
|
@@ -33743,10 +34040,10 @@ var localCliTools = [
|
|
|
33743
34040
|
if (input.peers === false) args.push("--peers=false");
|
|
33744
34041
|
if (input.activeOnly) args.push("--active");
|
|
33745
34042
|
const result = await runTailscaleCli(args, { parseJson: true });
|
|
33746
|
-
if (!result.ok && result.
|
|
34043
|
+
if (!result.ok && result.kind === "output-limit") {
|
|
33747
34044
|
return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
|
|
33748
34045
|
}
|
|
33749
|
-
if (!result.ok && result.
|
|
34046
|
+
if (!result.ok && result.kind === "timeout") {
|
|
33750
34047
|
return {
|
|
33751
34048
|
...result,
|
|
33752
34049
|
error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
|
|
@@ -33864,10 +34161,12 @@ var logStreamingTools = [
|
|
|
33864
34161
|
apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
|
|
33865
34162
|
apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
|
|
33866
34163
|
]);
|
|
34164
|
+
const describeFailure = (r) => r.status === 404 ? "HTTP 404: not configured (Tailscale also answers 404 when this log type is unsupported or the caller cannot view it)" : r.error || `HTTP ${r.status}`;
|
|
33867
34165
|
const errors = {};
|
|
33868
|
-
if (!configuration.ok) errors.configuration = configuration
|
|
33869
|
-
if (!network.ok) errors.network = network
|
|
33870
|
-
|
|
34166
|
+
if (!configuration.ok) errors.configuration = describeFailure(configuration);
|
|
34167
|
+
if (!network.ok) errors.network = describeFailure(network);
|
|
34168
|
+
const hardFail = (r) => !r.ok && r.status !== 404;
|
|
34169
|
+
if (hardFail(configuration) && hardFail(network)) {
|
|
33871
34170
|
return {
|
|
33872
34171
|
ok: false,
|
|
33873
34172
|
status: configuration.status || network.status || 500,
|
|
@@ -33912,7 +34211,7 @@ var logStreamingTools = [
|
|
|
33912
34211
|
inputSchema: external_exports.object({
|
|
33913
34212
|
logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
|
|
33914
34213
|
destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
|
|
33915
|
-
url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations)"),
|
|
34214
|
+
url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations; for s3, an optional S3-compatible endpoint)"),
|
|
33916
34215
|
token: external_exports.string().optional().describe(
|
|
33917
34216
|
"Authentication token or API key for the destination. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
33918
34217
|
),
|
|
@@ -33939,11 +34238,19 @@ var logStreamingTools = [
|
|
|
33939
34238
|
if (!input.s3Bucket) missing.push("s3Bucket");
|
|
33940
34239
|
if (!input.s3Region) missing.push("s3Region");
|
|
33941
34240
|
if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
|
|
34241
|
+
let wrongAuthFields = [];
|
|
33942
34242
|
if (input.s3AuthenticationType === "accesskey") {
|
|
33943
34243
|
if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
|
|
33944
34244
|
if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
|
|
34245
|
+
if (input.s3RoleArn !== void 0) wrongAuthFields = ["s3RoleArn"];
|
|
33945
34246
|
} else if (input.s3AuthenticationType === "rolearn") {
|
|
33946
34247
|
if (!input.s3RoleArn) missing.push("s3RoleArn");
|
|
34248
|
+
wrongAuthFields = ["s3AccessKeyId", "s3SecretAccessKey"].filter((f) => input[f] !== void 0);
|
|
34249
|
+
}
|
|
34250
|
+
if (wrongAuthFields.length > 0) {
|
|
34251
|
+
throw new Error(
|
|
34252
|
+
`${wrongAuthFields.join(", ")} cannot be used with s3AuthenticationType '${input.s3AuthenticationType}'.`
|
|
34253
|
+
);
|
|
33947
34254
|
}
|
|
33948
34255
|
if (missing.length > 0) {
|
|
33949
34256
|
throw new Error(
|
|
@@ -34036,9 +34343,11 @@ var logStreamingTools = [
|
|
|
34036
34343
|
// The flag was previously never sent at all, so the server applied whatever
|
|
34037
34344
|
// default it applies to an absent body -- undocumented either way. It now
|
|
34038
34345
|
// goes on the wire explicitly, as the Go client always does. The default
|
|
34039
|
-
// lives here rather than in a Zod `.default()` because
|
|
34040
|
-
//
|
|
34041
|
-
//
|
|
34346
|
+
// lives here rather than in a Zod `.default()` because the tests call the
|
|
34347
|
+
// handlers directly, bypassing the schema, so a schema default would be
|
|
34348
|
+
// invisible to every body assertion (in production the SDK does parse the
|
|
34349
|
+
// input, so a `.default()` would apply there). `??` rather than `||`
|
|
34350
|
+
// because false is a meaningful value.
|
|
34042
34351
|
handler: async (input) => {
|
|
34043
34352
|
return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
|
|
34044
34353
|
}
|
|
@@ -34118,7 +34427,7 @@ var postureTools = [
|
|
|
34118
34427
|
openWorldHint: true
|
|
34119
34428
|
},
|
|
34120
34429
|
inputSchema: external_exports.object({
|
|
34121
|
-
integrationId: external_exports.string().describe("The posture integration ID")
|
|
34430
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID")
|
|
34122
34431
|
}),
|
|
34123
34432
|
handler: async (input) => {
|
|
34124
34433
|
return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
|
|
@@ -34173,7 +34482,7 @@ var postureTools = [
|
|
|
34173
34482
|
openWorldHint: true
|
|
34174
34483
|
},
|
|
34175
34484
|
inputSchema: external_exports.object({
|
|
34176
|
-
integrationId: external_exports.string().describe("The posture integration ID to update"),
|
|
34485
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to update"),
|
|
34177
34486
|
clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
|
|
34178
34487
|
clientSecret: external_exports.string().optional().describe(
|
|
34179
34488
|
"Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
@@ -34204,7 +34513,10 @@ var postureTools = [
|
|
|
34204
34513
|
openWorldHint: true
|
|
34205
34514
|
},
|
|
34206
34515
|
inputSchema: external_exports.object({
|
|
34207
|
-
integrationId: external_exports.string().describe("The posture integration ID to delete")
|
|
34516
|
+
integrationId: external_exports.string().trim().min(1).describe("The posture integration ID to delete")
|
|
34517
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only integrationId
|
|
34518
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
34519
|
+
// like the integration is already gone (rationale: tailnets.ts delete_tailnet).
|
|
34208
34520
|
}),
|
|
34209
34521
|
handler: async (input) => {
|
|
34210
34522
|
return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
|
|
@@ -34216,7 +34528,7 @@ var postureTools = [
|
|
|
34216
34528
|
var serviceTools = [
|
|
34217
34529
|
{
|
|
34218
34530
|
name: "tailscale_list_services",
|
|
34219
|
-
description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note:
|
|
34531
|
+
description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note: this MCP has no create-service tool. The API documents its Service PUT as create-or-update, but tailscale_update_service sends only the fields you pass, so do not use it to create one. Use the update/delete/approval tools here once the service exists.",
|
|
34220
34532
|
annotations: {
|
|
34221
34533
|
title: "List services",
|
|
34222
34534
|
readOnlyHint: true,
|
|
@@ -34282,7 +34594,7 @@ var serviceTools = [
|
|
|
34282
34594
|
},
|
|
34283
34595
|
{
|
|
34284
34596
|
name: "tailscale_delete_service",
|
|
34285
|
-
description: "Delete a Tailscale Service. This is irreversible
|
|
34597
|
+
description: "Delete a Tailscale Service. This is irreversible -- the service's MagicDNS name and virtual IP will be released.",
|
|
34286
34598
|
annotations: {
|
|
34287
34599
|
title: "Delete service",
|
|
34288
34600
|
readOnlyHint: false,
|
|
@@ -34291,7 +34603,10 @@ var serviceTools = [
|
|
|
34291
34603
|
openWorldHint: true
|
|
34292
34604
|
},
|
|
34293
34605
|
inputSchema: external_exports.object({
|
|
34294
|
-
|
|
34606
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only service name
|
|
34607
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
34608
|
+
// like the service is already gone (rationale: tailnets.ts delete_tailnet).
|
|
34609
|
+
serviceName: external_exports.string().trim().min(1).describe("The service name to delete")
|
|
34295
34610
|
}),
|
|
34296
34611
|
handler: async (input) => {
|
|
34297
34612
|
return apiDelete(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}`);
|
|
@@ -34372,6 +34687,12 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
|
|
|
34372
34687
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
34373
34688
|
return data;
|
|
34374
34689
|
}
|
|
34690
|
+
function fetchTailnetStatusInputs() {
|
|
34691
|
+
return Promise.all([
|
|
34692
|
+
apiGet(`/tailnet/${getTailnet()}/devices?fields=default`),
|
|
34693
|
+
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34694
|
+
]);
|
|
34695
|
+
}
|
|
34375
34696
|
var statusTools = [
|
|
34376
34697
|
{
|
|
34377
34698
|
name: "tailscale_status",
|
|
@@ -34385,12 +34706,15 @@ var statusTools = [
|
|
|
34385
34706
|
},
|
|
34386
34707
|
inputSchema: external_exports.object({}),
|
|
34387
34708
|
handler: async () => {
|
|
34388
|
-
const [devicesRes, settingsRes] = await
|
|
34389
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
34390
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34391
|
-
]);
|
|
34709
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
34392
34710
|
if (!devicesRes.ok && !settingsRes.ok) {
|
|
34393
|
-
|
|
34711
|
+
if (settingsRes.error === devicesRes.error && settingsRes.status === devicesRes.status) {
|
|
34712
|
+
return devicesRes;
|
|
34713
|
+
}
|
|
34714
|
+
return {
|
|
34715
|
+
...devicesRes,
|
|
34716
|
+
error: `${devicesRes.error || `HTTP ${devicesRes.status}`} (the settings fetch also failed: ${settingsRes.error || `HTTP ${settingsRes.status}`})`
|
|
34717
|
+
};
|
|
34394
34718
|
}
|
|
34395
34719
|
const data = composeTailnetStatusData(devicesRes, settingsRes, {
|
|
34396
34720
|
connected: true,
|
|
@@ -34431,7 +34755,7 @@ var tailnetTools = [
|
|
|
34431
34755
|
inputSchema: external_exports.object({
|
|
34432
34756
|
devicesApprovalOn: external_exports.boolean().optional().describe("Whether device approval is required"),
|
|
34433
34757
|
devicesAutoUpdatesOn: external_exports.boolean().optional().describe("Whether auto-updates are enabled"),
|
|
34434
|
-
devicesKeyDurationDays: external_exports.number().optional().describe("Key expiry duration in days"),
|
|
34758
|
+
devicesKeyDurationDays: external_exports.number().int().min(1).max(180).optional().describe("Key expiry duration in days (whole days, 1-180)"),
|
|
34435
34759
|
usersApprovalOn: external_exports.boolean().optional().describe("Whether user approval is required"),
|
|
34436
34760
|
usersRoleAllowedToJoinExternalTailnets: external_exports.enum(["none", "admin", "member"]).optional().describe("Which user roles can join external tailnets"),
|
|
34437
34761
|
networkFlowLoggingOn: external_exports.boolean().optional().describe("Whether network flow logging is enabled"),
|
|
@@ -34499,7 +34823,7 @@ var tailnetTools = [
|
|
|
34499
34823
|
const applied = {};
|
|
34500
34824
|
const failed = {};
|
|
34501
34825
|
for (const { contactType, res } of results) {
|
|
34502
|
-
if (res.ok) applied[contactType] = res.data;
|
|
34826
|
+
if (res.ok) applied[contactType] = res.data ?? { status: res.status };
|
|
34503
34827
|
else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
34504
34828
|
}
|
|
34505
34829
|
const hasFailed = Object.keys(failed).length > 0;
|
|
@@ -34635,135 +34959,6 @@ var tailnetsTools = [
|
|
|
34635
34959
|
}
|
|
34636
34960
|
];
|
|
34637
34961
|
|
|
34638
|
-
// src/tools/users.ts
|
|
34639
|
-
var userTools = [
|
|
34640
|
-
{
|
|
34641
|
-
name: "tailscale_list_users",
|
|
34642
|
-
description: "List all users in your tailnet.",
|
|
34643
|
-
annotations: {
|
|
34644
|
-
title: "List users",
|
|
34645
|
-
readOnlyHint: true,
|
|
34646
|
-
destructiveHint: false,
|
|
34647
|
-
idempotentHint: true,
|
|
34648
|
-
openWorldHint: true
|
|
34649
|
-
},
|
|
34650
|
-
inputSchema: external_exports.object({
|
|
34651
|
-
type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
|
|
34652
|
-
role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).optional().describe("Filter by user role")
|
|
34653
|
-
}),
|
|
34654
|
-
handler: async (input) => {
|
|
34655
|
-
const params = new URLSearchParams();
|
|
34656
|
-
if (input.type) params.set("type", input.type);
|
|
34657
|
-
if (input.role) params.set("role", input.role);
|
|
34658
|
-
const qs = params.toString();
|
|
34659
|
-
return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
|
|
34660
|
-
}
|
|
34661
|
-
},
|
|
34662
|
-
{
|
|
34663
|
-
name: "tailscale_get_user",
|
|
34664
|
-
description: "Get details for a specific user.",
|
|
34665
|
-
annotations: {
|
|
34666
|
-
title: "Get user",
|
|
34667
|
-
readOnlyHint: true,
|
|
34668
|
-
destructiveHint: false,
|
|
34669
|
-
idempotentHint: true,
|
|
34670
|
-
openWorldHint: true
|
|
34671
|
-
},
|
|
34672
|
-
inputSchema: external_exports.object({
|
|
34673
|
-
userId: external_exports.string().describe("The user ID")
|
|
34674
|
-
}),
|
|
34675
|
-
handler: async (input) => {
|
|
34676
|
-
return apiGet(`/users/${encPath(input.userId)}`);
|
|
34677
|
-
}
|
|
34678
|
-
},
|
|
34679
|
-
{
|
|
34680
|
-
name: "tailscale_approve_user",
|
|
34681
|
-
description: "Approve a pending user, granting them access to the tailnet.",
|
|
34682
|
-
annotations: {
|
|
34683
|
-
title: "Approve user",
|
|
34684
|
-
readOnlyHint: false,
|
|
34685
|
-
destructiveHint: false,
|
|
34686
|
-
idempotentHint: true,
|
|
34687
|
-
openWorldHint: true
|
|
34688
|
-
},
|
|
34689
|
-
inputSchema: external_exports.object({
|
|
34690
|
-
userId: external_exports.string().describe("The user ID to approve")
|
|
34691
|
-
}),
|
|
34692
|
-
handler: async (input) => {
|
|
34693
|
-
return apiPost(`/users/${encPath(input.userId)}/approve`);
|
|
34694
|
-
}
|
|
34695
|
-
},
|
|
34696
|
-
{
|
|
34697
|
-
name: "tailscale_suspend_user",
|
|
34698
|
-
description: "Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.",
|
|
34699
|
-
annotations: {
|
|
34700
|
-
title: "Suspend user",
|
|
34701
|
-
readOnlyHint: false,
|
|
34702
|
-
destructiveHint: true,
|
|
34703
|
-
idempotentHint: true,
|
|
34704
|
-
openWorldHint: true
|
|
34705
|
-
},
|
|
34706
|
-
inputSchema: external_exports.object({
|
|
34707
|
-
userId: external_exports.string().describe("The user ID to suspend")
|
|
34708
|
-
}),
|
|
34709
|
-
handler: async (input) => {
|
|
34710
|
-
return apiPost(`/users/${encPath(input.userId)}/suspend`);
|
|
34711
|
-
}
|
|
34712
|
-
},
|
|
34713
|
-
{
|
|
34714
|
-
name: "tailscale_restore_user",
|
|
34715
|
-
description: "Restore a previously suspended user, re-granting them access to the tailnet.",
|
|
34716
|
-
annotations: {
|
|
34717
|
-
title: "Restore user",
|
|
34718
|
-
readOnlyHint: false,
|
|
34719
|
-
destructiveHint: false,
|
|
34720
|
-
idempotentHint: true,
|
|
34721
|
-
openWorldHint: true
|
|
34722
|
-
},
|
|
34723
|
-
inputSchema: external_exports.object({
|
|
34724
|
-
userId: external_exports.string().describe("The user ID to restore")
|
|
34725
|
-
}),
|
|
34726
|
-
handler: async (input) => {
|
|
34727
|
-
return apiPost(`/users/${encPath(input.userId)}/restore`);
|
|
34728
|
-
}
|
|
34729
|
-
},
|
|
34730
|
-
{
|
|
34731
|
-
name: "tailscale_update_user_role",
|
|
34732
|
-
description: "Update a user's role in the tailnet.",
|
|
34733
|
-
annotations: {
|
|
34734
|
-
title: "Update user role",
|
|
34735
|
-
readOnlyHint: false,
|
|
34736
|
-
destructiveHint: false,
|
|
34737
|
-
idempotentHint: true,
|
|
34738
|
-
openWorldHint: true
|
|
34739
|
-
},
|
|
34740
|
-
inputSchema: external_exports.object({
|
|
34741
|
-
userId: external_exports.string().describe("The user ID"),
|
|
34742
|
-
role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).describe("The new role to assign")
|
|
34743
|
-
}),
|
|
34744
|
-
handler: async (input) => {
|
|
34745
|
-
return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
|
|
34746
|
-
}
|
|
34747
|
-
},
|
|
34748
|
-
{
|
|
34749
|
-
name: "tailscale_delete_user",
|
|
34750
|
-
description: "Delete a user from the tailnet. This is irreversible \u2014 the user and all their devices will be removed.",
|
|
34751
|
-
annotations: {
|
|
34752
|
-
title: "Delete user",
|
|
34753
|
-
readOnlyHint: false,
|
|
34754
|
-
destructiveHint: true,
|
|
34755
|
-
idempotentHint: true,
|
|
34756
|
-
openWorldHint: true
|
|
34757
|
-
},
|
|
34758
|
-
inputSchema: external_exports.object({
|
|
34759
|
-
userId: external_exports.string().describe("The user ID to delete")
|
|
34760
|
-
}),
|
|
34761
|
-
handler: async (input) => {
|
|
34762
|
-
return apiPost(`/users/${encPath(input.userId)}/delete`);
|
|
34763
|
-
}
|
|
34764
|
-
}
|
|
34765
|
-
];
|
|
34766
|
-
|
|
34767
34962
|
// src/tools/webhooks.ts
|
|
34768
34963
|
var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
34769
34964
|
"nodeCreated",
|
|
@@ -34792,7 +34987,13 @@ function getAllowedWebhookEvents() {
|
|
|
34792
34987
|
const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
34793
34988
|
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
|
|
34794
34989
|
}
|
|
34795
|
-
var endpointUrlSchema = external_exports.url().refine((u) =>
|
|
34990
|
+
var endpointUrlSchema = external_exports.url().refine((u) => {
|
|
34991
|
+
try {
|
|
34992
|
+
return new URL(u).protocol === "https:";
|
|
34993
|
+
} catch {
|
|
34994
|
+
return false;
|
|
34995
|
+
}
|
|
34996
|
+
}, "endpointUrl must use https://");
|
|
34796
34997
|
var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
|
|
34797
34998
|
const allowed = getAllowedWebhookEvents();
|
|
34798
34999
|
let knownEventsList = null;
|
|
@@ -34839,7 +35040,7 @@ var webhookTools = [
|
|
|
34839
35040
|
openWorldHint: true
|
|
34840
35041
|
},
|
|
34841
35042
|
inputSchema: external_exports.object({
|
|
34842
|
-
webhookId: external_exports.string().describe("The webhook ID")
|
|
35043
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID")
|
|
34843
35044
|
}),
|
|
34844
35045
|
handler: async (input) => {
|
|
34845
35046
|
return apiGet(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34884,7 +35085,7 @@ var webhookTools = [
|
|
|
34884
35085
|
openWorldHint: true
|
|
34885
35086
|
},
|
|
34886
35087
|
inputSchema: external_exports.object({
|
|
34887
|
-
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
35088
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to update"),
|
|
34888
35089
|
endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
|
|
34889
35090
|
subscriptions: webhookSubscriptionsSchema.optional().describe(
|
|
34890
35091
|
"Updated list of event types to subscribe to (at least one). 'categoryTailnetManagement' and 'categoryDeviceMisconfigurations' subscribe to a whole category, including events Tailscale adds to it later."
|
|
@@ -34902,7 +35103,7 @@ var webhookTools = [
|
|
|
34902
35103
|
},
|
|
34903
35104
|
{
|
|
34904
35105
|
name: "tailscale_delete_webhook",
|
|
34905
|
-
description: "Delete a webhook. This is irreversible
|
|
35106
|
+
description: "Delete a webhook. This is irreversible -- the webhook secret cannot be recovered.",
|
|
34906
35107
|
annotations: {
|
|
34907
35108
|
title: "Delete webhook",
|
|
34908
35109
|
readOnlyHint: false,
|
|
@@ -34911,7 +35112,10 @@ var webhookTools = [
|
|
|
34911
35112
|
openWorldHint: true
|
|
34912
35113
|
},
|
|
34913
35114
|
inputSchema: external_exports.object({
|
|
34914
|
-
|
|
35115
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only webhook id
|
|
35116
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
35117
|
+
// like the webhook is already gone (rationale: tailnets.ts delete_tailnet).
|
|
35118
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to delete")
|
|
34915
35119
|
}),
|
|
34916
35120
|
handler: async (input) => {
|
|
34917
35121
|
return apiDelete(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34919,7 +35123,7 @@ var webhookTools = [
|
|
|
34919
35123
|
},
|
|
34920
35124
|
{
|
|
34921
35125
|
name: "tailscale_rotate_webhook_secret",
|
|
34922
|
-
description: "Rotate a webhook's secret. Returns the new secret
|
|
35126
|
+
description: "Rotate a webhook's secret. Returns the new secret -- save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
|
|
34923
35127
|
annotations: {
|
|
34924
35128
|
title: "Rotate webhook secret",
|
|
34925
35129
|
readOnlyHint: false,
|
|
@@ -34928,7 +35132,7 @@ var webhookTools = [
|
|
|
34928
35132
|
openWorldHint: true
|
|
34929
35133
|
},
|
|
34930
35134
|
inputSchema: external_exports.object({
|
|
34931
|
-
webhookId: external_exports.string().describe("The webhook ID whose secret to rotate")
|
|
35135
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID whose secret to rotate")
|
|
34932
35136
|
}),
|
|
34933
35137
|
handler: async (input) => {
|
|
34934
35138
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/rotate`);
|
|
@@ -34947,7 +35151,7 @@ var webhookTools = [
|
|
|
34947
35151
|
openWorldHint: true
|
|
34948
35152
|
},
|
|
34949
35153
|
inputSchema: external_exports.object({
|
|
34950
|
-
webhookId: external_exports.string().describe("The webhook ID to test")
|
|
35154
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to test")
|
|
34951
35155
|
}),
|
|
34952
35156
|
handler: async (input) => {
|
|
34953
35157
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/test`);
|
|
@@ -35036,8 +35240,9 @@ function formatTailnetMismatchWarning(env) {
|
|
|
35036
35240
|
return `TAILSCALE_OAUTH_TAILNET="${oauthTailnet}" but TAILSCALE_TAILNET="${explicit}". The OAuth token will be scoped to the former while tool requests are addressed to the latter, so every tailnet-scoped tool will fail with HTTP 403. Either unset TAILSCALE_TAILNET (or set it to "-") to follow the token, or set both to the same tailnet.`;
|
|
35037
35241
|
}
|
|
35038
35242
|
function formatBannerFilterSuffix(inputs) {
|
|
35039
|
-
const
|
|
35040
|
-
const
|
|
35243
|
+
const profileEnv = inputs.profileEnv?.trim();
|
|
35244
|
+
const profileValid = !!profileEnv && !inputs.unknownProfile;
|
|
35245
|
+
const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${profileEnv}` : null;
|
|
35041
35246
|
const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
|
|
35042
35247
|
return [
|
|
35043
35248
|
profileLabel,
|
|
@@ -35055,6 +35260,11 @@ function wrapToolHandler(tool) {
|
|
|
35055
35260
|
try {
|
|
35056
35261
|
const result = await tool.handler(input);
|
|
35057
35262
|
const response = result;
|
|
35263
|
+
if (typeof response?.ok !== "boolean") {
|
|
35264
|
+
return {
|
|
35265
|
+
content: [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }]
|
|
35266
|
+
};
|
|
35267
|
+
}
|
|
35058
35268
|
if (!response.ok) {
|
|
35059
35269
|
return {
|
|
35060
35270
|
content: [
|
|
@@ -35080,10 +35290,7 @@ function wrapToolHandler(tool) {
|
|
|
35080
35290
|
};
|
|
35081
35291
|
}
|
|
35082
35292
|
async function tailnetStatusResource(uri) {
|
|
35083
|
-
const [devicesRes, settingsRes] = await
|
|
35084
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
35085
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
35086
|
-
]);
|
|
35293
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
35087
35294
|
const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
|
|
35088
35295
|
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
|
|
35089
35296
|
}
|
|
@@ -35132,7 +35339,7 @@ function explainNotLoaded(group, state) {
|
|
|
35132
35339
|
toEnable: "set TAILSCALE_LOCAL_CLI=1"
|
|
35133
35340
|
};
|
|
35134
35341
|
}
|
|
35135
|
-
if (state.toolsEnv) {
|
|
35342
|
+
if (parseGroupList(state.toolsEnv) && !state.toolsIgnored) {
|
|
35136
35343
|
return {
|
|
35137
35344
|
reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
|
|
35138
35345
|
toEnable: `add "${group}" to TAILSCALE_TOOLS`
|
|
@@ -35156,10 +35363,16 @@ function explainWritesWithheld(group, state) {
|
|
|
35156
35363
|
toEnable: "unset TAILSCALE_READONLY"
|
|
35157
35364
|
};
|
|
35158
35365
|
}
|
|
35159
|
-
const
|
|
35366
|
+
const granted = parseGroupList(state.writeGroupsEnv);
|
|
35367
|
+
if (!granted) {
|
|
35368
|
+
return { reason: "writes are withheld in this group", toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS` };
|
|
35369
|
+
}
|
|
35370
|
+
const unknown2 = granted.filter((g) => !Object.hasOwn(state.fullRegistry, g));
|
|
35371
|
+
const known = granted.filter((g) => Object.hasOwn(state.fullRegistry, g));
|
|
35372
|
+
const unknownNote = unknown2.length > 0 ? `; ${unknown2.map((g) => `"${g}"`).join(", ")} ${unknown2.length > 1 ? "are not group names and grant" : "is not a group name and grants"} nothing` : "";
|
|
35160
35373
|
return {
|
|
35161
|
-
reason:
|
|
35162
|
-
toEnable:
|
|
35374
|
+
reason: `TAILSCALE_WRITE_GROUPS is set to "${state.writeGroupsEnv?.trim()}", which does not grant writes here${unknownNote}`,
|
|
35375
|
+
toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${[...known, group].join(",")}")`
|
|
35163
35376
|
};
|
|
35164
35377
|
}
|
|
35165
35378
|
function buildGroupReports(state) {
|
|
@@ -35248,8 +35461,8 @@ function buildMetaTools(state) {
|
|
|
35248
35461
|
const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
|
|
35249
35462
|
const activeFilters = [
|
|
35250
35463
|
state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
|
|
35251
|
-
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
|
|
35252
|
-
state.readonlyMode ? "
|
|
35464
|
+
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}${state.toolsIgnored ? " (ignored: names no known group)" : ""}` : null,
|
|
35465
|
+
state.readonlyMode ? `TAILSCALE_READONLY=${state.readonlyEnv ?? "1"}` : null,
|
|
35253
35466
|
state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
|
|
35254
35467
|
state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
|
|
35255
35468
|
].filter(Boolean);
|
|
@@ -35289,7 +35502,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
|
|
|
35289
35502
|
);
|
|
35290
35503
|
process.exit(1);
|
|
35291
35504
|
}
|
|
35292
|
-
var version2 = true ? "0.
|
|
35505
|
+
var version2 = true ? "0.23.0" : resolveVersionFallback();
|
|
35293
35506
|
var subcommand = process.argv[2];
|
|
35294
35507
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
35295
35508
|
|
|
@@ -35318,6 +35531,9 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35318
35531
|
}
|
|
35319
35532
|
const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
|
|
35320
35533
|
run(filePath).then(() => process.exit(0)).catch((err) => {
|
|
35534
|
+
if (err instanceof Error && err.name === "CliFailure") {
|
|
35535
|
+
process.exit(1);
|
|
35536
|
+
}
|
|
35321
35537
|
console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
35322
35538
|
process.exit(1);
|
|
35323
35539
|
});
|
|
@@ -35335,6 +35551,8 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35335
35551
|
if (!cliSubcommandHandled) {
|
|
35336
35552
|
const localCliEnabled = isLocalCliEnabled(process.env);
|
|
35337
35553
|
const toolGroups = buildToolGroups(process.env);
|
|
35554
|
+
const fullRegistry = buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" });
|
|
35555
|
+
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35338
35556
|
const {
|
|
35339
35557
|
tools: allTools,
|
|
35340
35558
|
unknownGroups,
|
|
@@ -35353,6 +35571,10 @@ if (!cliSubcommandHandled) {
|
|
|
35353
35571
|
profile: process.env.TAILSCALE_PROFILE,
|
|
35354
35572
|
writeGroups: process.env.TAILSCALE_WRITE_GROUPS
|
|
35355
35573
|
});
|
|
35574
|
+
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35575
|
+
if (tailnetMismatch) {
|
|
35576
|
+
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35577
|
+
}
|
|
35356
35578
|
if (unknownGroups.length > 0) {
|
|
35357
35579
|
const validNames = Object.keys(toolGroups);
|
|
35358
35580
|
const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
|
|
@@ -35362,7 +35584,7 @@ if (!cliSubcommandHandled) {
|
|
|
35362
35584
|
}
|
|
35363
35585
|
if (unknownWriteGroups && unknownWriteGroups.length > 0) {
|
|
35364
35586
|
const validNames = Object.keys(toolGroups);
|
|
35365
|
-
const everyPossibleGroup = Object.keys(
|
|
35587
|
+
const everyPossibleGroup = Object.keys(fullRegistry);
|
|
35366
35588
|
const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
|
|
35367
35589
|
const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
|
|
35368
35590
|
if (notEnabled.length > 0) {
|
|
@@ -35389,10 +35611,6 @@ if (!cliSubcommandHandled) {
|
|
|
35389
35611
|
`@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.`
|
|
35390
35612
|
);
|
|
35391
35613
|
}
|
|
35392
|
-
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35393
|
-
if (tailnetMismatch) {
|
|
35394
|
-
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35395
|
-
}
|
|
35396
35614
|
if (unknownProfile) {
|
|
35397
35615
|
console.error(
|
|
35398
35616
|
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
|
|
@@ -35408,7 +35626,7 @@ if (!cliSubcommandHandled) {
|
|
|
35408
35626
|
// that is currently disabled -- which is exactly the group an agent needs
|
|
35409
35627
|
// explained. Reporting only what loaded would make local-cli invisible rather
|
|
35410
35628
|
// than explained.
|
|
35411
|
-
fullRegistry
|
|
35629
|
+
fullRegistry,
|
|
35412
35630
|
// Ground truth for availability: what this server actually serves. Deliberately
|
|
35413
35631
|
// not a re-derivation of the filter logic, so the catalog cannot disagree with
|
|
35414
35632
|
// the server about what exists.
|
|
@@ -35416,8 +35634,10 @@ if (!cliSubcommandHandled) {
|
|
|
35416
35634
|
toolsEnv: process.env.TAILSCALE_TOOLS,
|
|
35417
35635
|
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
35418
35636
|
writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
|
|
35419
|
-
readonlyMode
|
|
35420
|
-
|
|
35637
|
+
readonlyMode,
|
|
35638
|
+
readonlyEnv: process.env.TAILSCALE_READONLY,
|
|
35639
|
+
localCliEnabled,
|
|
35640
|
+
toolsIgnored: toolsAllUnknown
|
|
35421
35641
|
});
|
|
35422
35642
|
for (const tool of metaTools) {
|
|
35423
35643
|
server.registerTool(
|
|
@@ -35482,7 +35702,6 @@ if (!cliSubcommandHandled) {
|
|
|
35482
35702
|
`);
|
|
35483
35703
|
process.exit(1);
|
|
35484
35704
|
});
|
|
35485
|
-
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35486
35705
|
const filterSuffix = formatBannerFilterSuffix({
|
|
35487
35706
|
unknownProfile,
|
|
35488
35707
|
explicitTools,
|
|
@@ -35496,15 +35715,22 @@ if (!cliSubcommandHandled) {
|
|
|
35496
35715
|
console.error(
|
|
35497
35716
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
35498
35717
|
);
|
|
35499
|
-
const hasCreds =
|
|
35500
|
-
const ADMIN_EQUIVALENT =
|
|
35718
|
+
const hasCreds = hasUsableCredentials();
|
|
35719
|
+
const ADMIN_EQUIVALENT = {
|
|
35720
|
+
keys: "tailscale_create_key mints an OAuth client with any scopes the caller asks for",
|
|
35721
|
+
users: 'tailscale_update_user_role accepts "owner"',
|
|
35722
|
+
acl: "tailscale_update_acl rewrites policy for every principal"
|
|
35723
|
+
};
|
|
35501
35724
|
const registeredNames = new Set(allTools.map((t) => t.name));
|
|
35502
|
-
const adminWritable = ADMIN_EQUIVALENT.filter(
|
|
35725
|
+
const adminWritable = Object.keys(ADMIN_EQUIVALENT).filter(
|
|
35503
35726
|
(g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
|
|
35504
35727
|
);
|
|
35505
35728
|
if (adminWritable.length > 0 && hasCreds) {
|
|
35729
|
+
const examples = adminWritable.map((g) => ADMIN_EQUIVALENT[g]);
|
|
35730
|
+
const last = examples.pop();
|
|
35731
|
+
const exampleText = examples.length === 0 ? last : `${examples.join(", ")}${examples.length > 1 ? "," : ""} and ${last}`;
|
|
35506
35732
|
console.error(
|
|
35507
|
-
`@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent.
|
|
35733
|
+
`@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent. ${exampleText}. 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.`
|
|
35508
35734
|
);
|
|
35509
35735
|
}
|
|
35510
35736
|
if (!filterSuffix && hasCreds) {
|