@yawlabs/tailscale-mcp 0.21.2 → 0.22.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 +11 -5
- package/bin/tailscale-mcp.mjs +216 -44
- package/dist/index.js +434 -228
- 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,155 @@ 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().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().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
|
+
userId: external_exports.string().describe("The user ID to suspend")
|
|
33301
|
+
}),
|
|
33302
|
+
handler: async (input) => {
|
|
33303
|
+
return apiPost(`/users/${encPath(input.userId)}/suspend`);
|
|
33304
|
+
}
|
|
33305
|
+
},
|
|
33306
|
+
{
|
|
33307
|
+
name: "tailscale_restore_user",
|
|
33308
|
+
description: "Restore a previously suspended user, re-granting them access to the tailnet.",
|
|
33309
|
+
annotations: {
|
|
33310
|
+
title: "Restore user",
|
|
33311
|
+
readOnlyHint: false,
|
|
33312
|
+
destructiveHint: false,
|
|
33313
|
+
idempotentHint: true,
|
|
33314
|
+
openWorldHint: true
|
|
33315
|
+
},
|
|
33316
|
+
inputSchema: external_exports.object({
|
|
33317
|
+
userId: external_exports.string().describe("The user ID to restore")
|
|
33318
|
+
}),
|
|
33319
|
+
handler: async (input) => {
|
|
33320
|
+
return apiPost(`/users/${encPath(input.userId)}/restore`);
|
|
33321
|
+
}
|
|
33322
|
+
},
|
|
33323
|
+
{
|
|
33324
|
+
name: "tailscale_update_user_role",
|
|
33325
|
+
description: "Update a user's role in the tailnet.",
|
|
33326
|
+
annotations: {
|
|
33327
|
+
title: "Update user role",
|
|
33328
|
+
readOnlyHint: false,
|
|
33329
|
+
destructiveHint: false,
|
|
33330
|
+
idempotentHint: true,
|
|
33331
|
+
openWorldHint: true
|
|
33332
|
+
},
|
|
33333
|
+
inputSchema: external_exports.object({
|
|
33334
|
+
userId: external_exports.string().describe("The user ID"),
|
|
33335
|
+
role: external_exports.enum(USER_ROLES).describe("The new role to assign")
|
|
33336
|
+
}),
|
|
33337
|
+
handler: async (input) => {
|
|
33338
|
+
return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
|
|
33339
|
+
}
|
|
33340
|
+
},
|
|
33341
|
+
{
|
|
33342
|
+
name: "tailscale_delete_user",
|
|
33343
|
+
description: "Delete a user from the tailnet. This is irreversible -- the user and all their devices will be removed.",
|
|
33344
|
+
annotations: {
|
|
33345
|
+
title: "Delete user",
|
|
33346
|
+
readOnlyHint: false,
|
|
33347
|
+
destructiveHint: true,
|
|
33348
|
+
idempotentHint: true,
|
|
33349
|
+
openWorldHint: true
|
|
33350
|
+
},
|
|
33351
|
+
inputSchema: external_exports.object({
|
|
33352
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only user id encodes
|
|
33353
|
+
// to "%20" and this irreversible delete returns a 404 that reads like the
|
|
33354
|
+
// user is already gone (rationale: tailnets.ts delete_tailnet).
|
|
33355
|
+
userId: external_exports.string().trim().min(1).describe("The user ID to delete")
|
|
33356
|
+
}),
|
|
33357
|
+
handler: async (input) => {
|
|
33358
|
+
return apiPost(`/users/${encPath(input.userId)}/delete`);
|
|
33359
|
+
}
|
|
33360
|
+
}
|
|
33361
|
+
];
|
|
33362
|
+
|
|
33117
33363
|
// src/tools/invites.ts
|
|
33118
33364
|
var inviteTools = [
|
|
33119
33365
|
// --- Device Invites ---
|
|
@@ -33155,6 +33401,9 @@ var inviteTools = [
|
|
|
33155
33401
|
if (input.multiUse !== void 0) body.multiUse = input.multiUse;
|
|
33156
33402
|
if (input.allowExitNode !== void 0) body.allowExitNode = input.allowExitNode;
|
|
33157
33403
|
if (input.email !== void 0) body.email = input.email;
|
|
33404
|
+
if (Object.keys(body).length === 0) {
|
|
33405
|
+
throw new Error("No fields to update. Provide at least one of: multiUse, allowExitNode, email.");
|
|
33406
|
+
}
|
|
33158
33407
|
return apiPost(`/device/${encPath(input.deviceId)}/device-invites`, body);
|
|
33159
33408
|
}
|
|
33160
33409
|
},
|
|
@@ -33177,7 +33426,7 @@ var inviteTools = [
|
|
|
33177
33426
|
},
|
|
33178
33427
|
{
|
|
33179
33428
|
name: "tailscale_delete_device_invite",
|
|
33180
|
-
description: "Delete a device invite. This is irreversible
|
|
33429
|
+
description: "Delete a device invite. This is irreversible -- the invite link will stop working.",
|
|
33181
33430
|
annotations: {
|
|
33182
33431
|
title: "Delete device invite",
|
|
33183
33432
|
readOnlyHint: false,
|
|
@@ -33237,12 +33486,15 @@ var inviteTools = [
|
|
|
33237
33486
|
},
|
|
33238
33487
|
inputSchema: external_exports.object({
|
|
33239
33488
|
email: external_exports.email().optional().describe("Email address to send the invite to"),
|
|
33240
|
-
role: external_exports.enum(
|
|
33489
|
+
role: external_exports.enum(INVITABLE_ROLES).optional().describe("Role to assign to the invited user (default: member)")
|
|
33241
33490
|
}),
|
|
33242
33491
|
handler: async (input) => {
|
|
33243
33492
|
const body = {};
|
|
33244
33493
|
if (input.email !== void 0) body.email = input.email;
|
|
33245
33494
|
if (input.role !== void 0) body.role = input.role;
|
|
33495
|
+
if (Object.keys(body).length === 0) {
|
|
33496
|
+
throw new Error("No fields to update. Provide at least one of: email, role.");
|
|
33497
|
+
}
|
|
33246
33498
|
return apiPost(`/tailnet/${getTailnet()}/user-invites`, body);
|
|
33247
33499
|
}
|
|
33248
33500
|
},
|
|
@@ -33265,7 +33517,7 @@ var inviteTools = [
|
|
|
33265
33517
|
},
|
|
33266
33518
|
{
|
|
33267
33519
|
name: "tailscale_delete_user_invite",
|
|
33268
|
-
description: "Delete a user invite. This is irreversible
|
|
33520
|
+
description: "Delete a user invite. This is irreversible -- the invite link will stop working.",
|
|
33269
33521
|
annotations: {
|
|
33270
33522
|
title: "Delete user invite",
|
|
33271
33523
|
readOnlyHint: false,
|
|
@@ -33349,7 +33601,7 @@ var keyTools = [
|
|
|
33349
33601
|
openWorldHint: true
|
|
33350
33602
|
},
|
|
33351
33603
|
inputSchema: external_exports.object({
|
|
33352
|
-
keyId: external_exports.string().describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
|
|
33604
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
|
|
33353
33605
|
}),
|
|
33354
33606
|
handler: async (input) => {
|
|
33355
33607
|
return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33374,7 +33626,8 @@ var keyTools = [
|
|
|
33374
33626
|
reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
|
|
33375
33627
|
ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
|
|
33376
33628
|
preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
|
|
33377
|
-
|
|
33629
|
+
// Seconds, so a fractional or non-positive value is nonsense to send.
|
|
33630
|
+
expirySeconds: external_exports.number().int().positive().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
|
|
33378
33631
|
// Shared fields
|
|
33379
33632
|
tags: external_exports.array(external_exports.string()).optional().describe(
|
|
33380
33633
|
"ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
|
|
@@ -33454,7 +33707,14 @@ var keyTools = [
|
|
|
33454
33707
|
openWorldHint: true
|
|
33455
33708
|
},
|
|
33456
33709
|
inputSchema: external_exports.object({
|
|
33457
|
-
|
|
33710
|
+
// `.trim().min(1)`, as on tailscale_delete_oauth_app's appId below and for
|
|
33711
|
+
// the reason tailnets.ts spells out on tailscale_delete_tailnet: a bare
|
|
33712
|
+
// z.string() lets "" through (encPath then sends a DELETE on the keys
|
|
33713
|
+
// collection path itself) and " " through as the literal segment "%20".
|
|
33714
|
+
// On a read that is a wasted round-trip; on this irreversible delete it
|
|
33715
|
+
// returns a 404 that reads like the key is already gone, with no retry to
|
|
33716
|
+
// notice it with.
|
|
33717
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
|
|
33458
33718
|
}),
|
|
33459
33719
|
handler: async (input) => {
|
|
33460
33720
|
return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33462,7 +33722,7 @@ var keyTools = [
|
|
|
33462
33722
|
},
|
|
33463
33723
|
{
|
|
33464
33724
|
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'
|
|
33725
|
+
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
33726
|
annotations: {
|
|
33467
33727
|
title: "Update key",
|
|
33468
33728
|
readOnlyHint: false,
|
|
@@ -33471,7 +33731,7 @@ var keyTools = [
|
|
|
33471
33731
|
openWorldHint: true
|
|
33472
33732
|
},
|
|
33473
33733
|
inputSchema: external_exports.object({
|
|
33474
|
-
keyId: external_exports.string().describe("The key ID to update"),
|
|
33734
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID to update"),
|
|
33475
33735
|
description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
|
|
33476
33736
|
scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
|
|
33477
33737
|
tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
|
|
@@ -33615,6 +33875,10 @@ var DARWIN_CANDIDATES = [
|
|
|
33615
33875
|
];
|
|
33616
33876
|
var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
|
|
33617
33877
|
var execFileImpl = execFileCb;
|
|
33878
|
+
function describeUnexecutableBinary(binary, fromEnv) {
|
|
33879
|
+
const where = fromEnv ? `'${binary}', which is where TAILSCALE_BINARY points` : `'${binary}', found in PATH`;
|
|
33880
|
+
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).`;
|
|
33881
|
+
}
|
|
33618
33882
|
function binaryCandidates(platform) {
|
|
33619
33883
|
if (platform === "darwin") return DARWIN_CANDIDATES;
|
|
33620
33884
|
if (platform === "linux") return LINUX_CANDIDATES;
|
|
@@ -33642,7 +33906,7 @@ function readProcVersion() {
|
|
|
33642
33906
|
}
|
|
33643
33907
|
function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
33644
33908
|
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
|
|
33909
|
+
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
33910
|
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
33911
|
}
|
|
33648
33912
|
if (platform === "darwin") {
|
|
@@ -33653,12 +33917,30 @@ function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
|
33653
33917
|
}
|
|
33654
33918
|
return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
|
|
33655
33919
|
}
|
|
33920
|
+
var MAX_CLI_CONCURRENCY = 4;
|
|
33921
|
+
var cliInFlight = 0;
|
|
33922
|
+
var cliQueue = [];
|
|
33923
|
+
async function acquireCliSlot() {
|
|
33924
|
+
if (cliInFlight < MAX_CLI_CONCURRENCY) {
|
|
33925
|
+
cliInFlight++;
|
|
33926
|
+
return;
|
|
33927
|
+
}
|
|
33928
|
+
await new Promise((resolve) => cliQueue.push(resolve));
|
|
33929
|
+
}
|
|
33930
|
+
function releaseCliSlot() {
|
|
33931
|
+
const next = cliQueue.shift();
|
|
33932
|
+
if (next)
|
|
33933
|
+
next();
|
|
33934
|
+
else cliInFlight--;
|
|
33935
|
+
}
|
|
33656
33936
|
async function runTailscaleCli(args, options = {}) {
|
|
33657
33937
|
const binary = resolveBinary();
|
|
33658
33938
|
const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
|
|
33659
33939
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33940
|
+
await acquireCliSlot();
|
|
33660
33941
|
return new Promise((resolve) => {
|
|
33661
33942
|
execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
|
|
33943
|
+
releaseCliSlot();
|
|
33662
33944
|
const stdoutStr = stdout == null ? "" : String(stdout);
|
|
33663
33945
|
const stderrStr = stderr == null ? "" : String(stderr);
|
|
33664
33946
|
if (err) {
|
|
@@ -33670,6 +33952,10 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33670
33952
|
});
|
|
33671
33953
|
return;
|
|
33672
33954
|
}
|
|
33955
|
+
if (errno.code === "EACCES") {
|
|
33956
|
+
resolve({ ok: false, error: describeUnexecutableBinary(binary, fromEnv) });
|
|
33957
|
+
return;
|
|
33958
|
+
}
|
|
33673
33959
|
if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
33674
33960
|
resolve({
|
|
33675
33961
|
ok: false,
|
|
@@ -33864,10 +34150,12 @@ var logStreamingTools = [
|
|
|
33864
34150
|
apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
|
|
33865
34151
|
apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
|
|
33866
34152
|
]);
|
|
34153
|
+
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
34154
|
const errors = {};
|
|
33868
|
-
if (!configuration.ok) errors.configuration = configuration
|
|
33869
|
-
if (!network.ok) errors.network = network
|
|
33870
|
-
|
|
34155
|
+
if (!configuration.ok) errors.configuration = describeFailure(configuration);
|
|
34156
|
+
if (!network.ok) errors.network = describeFailure(network);
|
|
34157
|
+
const hardFail = (r) => !r.ok && r.status !== 404;
|
|
34158
|
+
if (hardFail(configuration) && hardFail(network)) {
|
|
33871
34159
|
return {
|
|
33872
34160
|
ok: false,
|
|
33873
34161
|
status: configuration.status || network.status || 500,
|
|
@@ -33912,7 +34200,7 @@ var logStreamingTools = [
|
|
|
33912
34200
|
inputSchema: external_exports.object({
|
|
33913
34201
|
logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
|
|
33914
34202
|
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)"),
|
|
34203
|
+
url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations; for s3, an optional S3-compatible endpoint)"),
|
|
33916
34204
|
token: external_exports.string().optional().describe(
|
|
33917
34205
|
"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
34206
|
),
|
|
@@ -33939,11 +34227,19 @@ var logStreamingTools = [
|
|
|
33939
34227
|
if (!input.s3Bucket) missing.push("s3Bucket");
|
|
33940
34228
|
if (!input.s3Region) missing.push("s3Region");
|
|
33941
34229
|
if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
|
|
34230
|
+
let wrongAuthFields = [];
|
|
33942
34231
|
if (input.s3AuthenticationType === "accesskey") {
|
|
33943
34232
|
if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
|
|
33944
34233
|
if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
|
|
34234
|
+
if (input.s3RoleArn !== void 0) wrongAuthFields = ["s3RoleArn"];
|
|
33945
34235
|
} else if (input.s3AuthenticationType === "rolearn") {
|
|
33946
34236
|
if (!input.s3RoleArn) missing.push("s3RoleArn");
|
|
34237
|
+
wrongAuthFields = ["s3AccessKeyId", "s3SecretAccessKey"].filter((f) => input[f] !== void 0);
|
|
34238
|
+
}
|
|
34239
|
+
if (wrongAuthFields.length > 0) {
|
|
34240
|
+
throw new Error(
|
|
34241
|
+
`${wrongAuthFields.join(", ")} cannot be used with s3AuthenticationType '${input.s3AuthenticationType}'.`
|
|
34242
|
+
);
|
|
33947
34243
|
}
|
|
33948
34244
|
if (missing.length > 0) {
|
|
33949
34245
|
throw new Error(
|
|
@@ -34036,9 +34332,11 @@ var logStreamingTools = [
|
|
|
34036
34332
|
// The flag was previously never sent at all, so the server applied whatever
|
|
34037
34333
|
// default it applies to an absent body -- undocumented either way. It now
|
|
34038
34334
|
// 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
|
-
//
|
|
34335
|
+
// lives here rather than in a Zod `.default()` because the tests call the
|
|
34336
|
+
// handlers directly, bypassing the schema, so a schema default would be
|
|
34337
|
+
// invisible to every body assertion (in production the SDK does parse the
|
|
34338
|
+
// input, so a `.default()` would apply there). `??` rather than `||`
|
|
34339
|
+
// because false is a meaningful value.
|
|
34042
34340
|
handler: async (input) => {
|
|
34043
34341
|
return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
|
|
34044
34342
|
}
|
|
@@ -34216,7 +34514,7 @@ var postureTools = [
|
|
|
34216
34514
|
var serviceTools = [
|
|
34217
34515
|
{
|
|
34218
34516
|
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:
|
|
34517
|
+
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
34518
|
annotations: {
|
|
34221
34519
|
title: "List services",
|
|
34222
34520
|
readOnlyHint: true,
|
|
@@ -34282,7 +34580,7 @@ var serviceTools = [
|
|
|
34282
34580
|
},
|
|
34283
34581
|
{
|
|
34284
34582
|
name: "tailscale_delete_service",
|
|
34285
|
-
description: "Delete a Tailscale Service. This is irreversible
|
|
34583
|
+
description: "Delete a Tailscale Service. This is irreversible -- the service's MagicDNS name and virtual IP will be released.",
|
|
34286
34584
|
annotations: {
|
|
34287
34585
|
title: "Delete service",
|
|
34288
34586
|
readOnlyHint: false,
|
|
@@ -34291,7 +34589,10 @@ var serviceTools = [
|
|
|
34291
34589
|
openWorldHint: true
|
|
34292
34590
|
},
|
|
34293
34591
|
inputSchema: external_exports.object({
|
|
34294
|
-
|
|
34592
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only service name
|
|
34593
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
34594
|
+
// like the service is already gone (rationale: tailnets.ts delete_tailnet).
|
|
34595
|
+
serviceName: external_exports.string().trim().min(1).describe("The service name to delete")
|
|
34295
34596
|
}),
|
|
34296
34597
|
handler: async (input) => {
|
|
34297
34598
|
return apiDelete(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}`);
|
|
@@ -34372,6 +34673,12 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
|
|
|
34372
34673
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
34373
34674
|
return data;
|
|
34374
34675
|
}
|
|
34676
|
+
function fetchTailnetStatusInputs() {
|
|
34677
|
+
return Promise.all([
|
|
34678
|
+
apiGet(`/tailnet/${getTailnet()}/devices?fields=default`),
|
|
34679
|
+
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34680
|
+
]);
|
|
34681
|
+
}
|
|
34375
34682
|
var statusTools = [
|
|
34376
34683
|
{
|
|
34377
34684
|
name: "tailscale_status",
|
|
@@ -34385,10 +34692,7 @@ var statusTools = [
|
|
|
34385
34692
|
},
|
|
34386
34693
|
inputSchema: external_exports.object({}),
|
|
34387
34694
|
handler: async () => {
|
|
34388
|
-
const [devicesRes, settingsRes] = await
|
|
34389
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
34390
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34391
|
-
]);
|
|
34695
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
34392
34696
|
if (!devicesRes.ok && !settingsRes.ok) {
|
|
34393
34697
|
return devicesRes;
|
|
34394
34698
|
}
|
|
@@ -34431,7 +34735,7 @@ var tailnetTools = [
|
|
|
34431
34735
|
inputSchema: external_exports.object({
|
|
34432
34736
|
devicesApprovalOn: external_exports.boolean().optional().describe("Whether device approval is required"),
|
|
34433
34737
|
devicesAutoUpdatesOn: external_exports.boolean().optional().describe("Whether auto-updates are enabled"),
|
|
34434
|
-
devicesKeyDurationDays: external_exports.number().optional().describe("Key expiry duration in days"),
|
|
34738
|
+
devicesKeyDurationDays: external_exports.number().int().min(1).max(180).optional().describe("Key expiry duration in days (whole days, 1-180)"),
|
|
34435
34739
|
usersApprovalOn: external_exports.boolean().optional().describe("Whether user approval is required"),
|
|
34436
34740
|
usersRoleAllowedToJoinExternalTailnets: external_exports.enum(["none", "admin", "member"]).optional().describe("Which user roles can join external tailnets"),
|
|
34437
34741
|
networkFlowLoggingOn: external_exports.boolean().optional().describe("Whether network flow logging is enabled"),
|
|
@@ -34499,7 +34803,7 @@ var tailnetTools = [
|
|
|
34499
34803
|
const applied = {};
|
|
34500
34804
|
const failed = {};
|
|
34501
34805
|
for (const { contactType, res } of results) {
|
|
34502
|
-
if (res.ok) applied[contactType] = res.data;
|
|
34806
|
+
if (res.ok) applied[contactType] = res.data ?? { status: res.status };
|
|
34503
34807
|
else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
34504
34808
|
}
|
|
34505
34809
|
const hasFailed = Object.keys(failed).length > 0;
|
|
@@ -34635,135 +34939,6 @@ var tailnetsTools = [
|
|
|
34635
34939
|
}
|
|
34636
34940
|
];
|
|
34637
34941
|
|
|
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
34942
|
// src/tools/webhooks.ts
|
|
34768
34943
|
var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
34769
34944
|
"nodeCreated",
|
|
@@ -34792,7 +34967,13 @@ function getAllowedWebhookEvents() {
|
|
|
34792
34967
|
const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
34793
34968
|
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
|
|
34794
34969
|
}
|
|
34795
|
-
var endpointUrlSchema = external_exports.url().refine((u) =>
|
|
34970
|
+
var endpointUrlSchema = external_exports.url().refine((u) => {
|
|
34971
|
+
try {
|
|
34972
|
+
return new URL(u).protocol === "https:";
|
|
34973
|
+
} catch {
|
|
34974
|
+
return false;
|
|
34975
|
+
}
|
|
34976
|
+
}, "endpointUrl must use https://");
|
|
34796
34977
|
var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
|
|
34797
34978
|
const allowed = getAllowedWebhookEvents();
|
|
34798
34979
|
let knownEventsList = null;
|
|
@@ -34839,7 +35020,7 @@ var webhookTools = [
|
|
|
34839
35020
|
openWorldHint: true
|
|
34840
35021
|
},
|
|
34841
35022
|
inputSchema: external_exports.object({
|
|
34842
|
-
webhookId: external_exports.string().describe("The webhook ID")
|
|
35023
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID")
|
|
34843
35024
|
}),
|
|
34844
35025
|
handler: async (input) => {
|
|
34845
35026
|
return apiGet(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34884,7 +35065,7 @@ var webhookTools = [
|
|
|
34884
35065
|
openWorldHint: true
|
|
34885
35066
|
},
|
|
34886
35067
|
inputSchema: external_exports.object({
|
|
34887
|
-
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
35068
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to update"),
|
|
34888
35069
|
endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
|
|
34889
35070
|
subscriptions: webhookSubscriptionsSchema.optional().describe(
|
|
34890
35071
|
"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 +35083,7 @@ var webhookTools = [
|
|
|
34902
35083
|
},
|
|
34903
35084
|
{
|
|
34904
35085
|
name: "tailscale_delete_webhook",
|
|
34905
|
-
description: "Delete a webhook. This is irreversible
|
|
35086
|
+
description: "Delete a webhook. This is irreversible -- the webhook secret cannot be recovered.",
|
|
34906
35087
|
annotations: {
|
|
34907
35088
|
title: "Delete webhook",
|
|
34908
35089
|
readOnlyHint: false,
|
|
@@ -34911,7 +35092,10 @@ var webhookTools = [
|
|
|
34911
35092
|
openWorldHint: true
|
|
34912
35093
|
},
|
|
34913
35094
|
inputSchema: external_exports.object({
|
|
34914
|
-
|
|
35095
|
+
// `.trim().min(1)`, not a bare string: a whitespace-only webhook id
|
|
35096
|
+
// encodes to "%20" and this irreversible delete returns a 404 that reads
|
|
35097
|
+
// like the webhook is already gone (rationale: tailnets.ts delete_tailnet).
|
|
35098
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to delete")
|
|
34915
35099
|
}),
|
|
34916
35100
|
handler: async (input) => {
|
|
34917
35101
|
return apiDelete(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34919,7 +35103,7 @@ var webhookTools = [
|
|
|
34919
35103
|
},
|
|
34920
35104
|
{
|
|
34921
35105
|
name: "tailscale_rotate_webhook_secret",
|
|
34922
|
-
description: "Rotate a webhook's secret. Returns the new secret
|
|
35106
|
+
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
35107
|
annotations: {
|
|
34924
35108
|
title: "Rotate webhook secret",
|
|
34925
35109
|
readOnlyHint: false,
|
|
@@ -34928,7 +35112,7 @@ var webhookTools = [
|
|
|
34928
35112
|
openWorldHint: true
|
|
34929
35113
|
},
|
|
34930
35114
|
inputSchema: external_exports.object({
|
|
34931
|
-
webhookId: external_exports.string().describe("The webhook ID whose secret to rotate")
|
|
35115
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID whose secret to rotate")
|
|
34932
35116
|
}),
|
|
34933
35117
|
handler: async (input) => {
|
|
34934
35118
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/rotate`);
|
|
@@ -34947,7 +35131,7 @@ var webhookTools = [
|
|
|
34947
35131
|
openWorldHint: true
|
|
34948
35132
|
},
|
|
34949
35133
|
inputSchema: external_exports.object({
|
|
34950
|
-
webhookId: external_exports.string().describe("The webhook ID to test")
|
|
35134
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to test")
|
|
34951
35135
|
}),
|
|
34952
35136
|
handler: async (input) => {
|
|
34953
35137
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/test`);
|
|
@@ -35036,8 +35220,9 @@ function formatTailnetMismatchWarning(env) {
|
|
|
35036
35220
|
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
35221
|
}
|
|
35038
35222
|
function formatBannerFilterSuffix(inputs) {
|
|
35039
|
-
const
|
|
35040
|
-
const
|
|
35223
|
+
const profileEnv = inputs.profileEnv?.trim();
|
|
35224
|
+
const profileValid = !!profileEnv && !inputs.unknownProfile;
|
|
35225
|
+
const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${profileEnv}` : null;
|
|
35041
35226
|
const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
|
|
35042
35227
|
return [
|
|
35043
35228
|
profileLabel,
|
|
@@ -35055,6 +35240,11 @@ function wrapToolHandler(tool) {
|
|
|
35055
35240
|
try {
|
|
35056
35241
|
const result = await tool.handler(input);
|
|
35057
35242
|
const response = result;
|
|
35243
|
+
if (typeof response?.ok !== "boolean") {
|
|
35244
|
+
return {
|
|
35245
|
+
content: [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }]
|
|
35246
|
+
};
|
|
35247
|
+
}
|
|
35058
35248
|
if (!response.ok) {
|
|
35059
35249
|
return {
|
|
35060
35250
|
content: [
|
|
@@ -35080,10 +35270,7 @@ function wrapToolHandler(tool) {
|
|
|
35080
35270
|
};
|
|
35081
35271
|
}
|
|
35082
35272
|
async function tailnetStatusResource(uri) {
|
|
35083
|
-
const [devicesRes, settingsRes] = await
|
|
35084
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
35085
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
35086
|
-
]);
|
|
35273
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
35087
35274
|
const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
|
|
35088
35275
|
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
|
|
35089
35276
|
}
|
|
@@ -35132,7 +35319,7 @@ function explainNotLoaded(group, state) {
|
|
|
35132
35319
|
toEnable: "set TAILSCALE_LOCAL_CLI=1"
|
|
35133
35320
|
};
|
|
35134
35321
|
}
|
|
35135
|
-
if (state.toolsEnv) {
|
|
35322
|
+
if (parseGroupList(state.toolsEnv) && !state.toolsIgnored) {
|
|
35136
35323
|
return {
|
|
35137
35324
|
reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
|
|
35138
35325
|
toEnable: `add "${group}" to TAILSCALE_TOOLS`
|
|
@@ -35156,10 +35343,16 @@ function explainWritesWithheld(group, state) {
|
|
|
35156
35343
|
toEnable: "unset TAILSCALE_READONLY"
|
|
35157
35344
|
};
|
|
35158
35345
|
}
|
|
35159
|
-
const
|
|
35346
|
+
const granted = parseGroupList(state.writeGroupsEnv);
|
|
35347
|
+
if (!granted) {
|
|
35348
|
+
return { reason: "writes are withheld in this group", toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS` };
|
|
35349
|
+
}
|
|
35350
|
+
const unknown2 = granted.filter((g) => !Object.hasOwn(state.fullRegistry, g));
|
|
35351
|
+
const known = granted.filter((g) => Object.hasOwn(state.fullRegistry, g));
|
|
35352
|
+
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
35353
|
return {
|
|
35161
|
-
reason:
|
|
35162
|
-
toEnable:
|
|
35354
|
+
reason: `TAILSCALE_WRITE_GROUPS is set to "${state.writeGroupsEnv?.trim()}", which does not grant writes here${unknownNote}`,
|
|
35355
|
+
toEnable: `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${[...known, group].join(",")}")`
|
|
35163
35356
|
};
|
|
35164
35357
|
}
|
|
35165
35358
|
function buildGroupReports(state) {
|
|
@@ -35248,8 +35441,8 @@ function buildMetaTools(state) {
|
|
|
35248
35441
|
const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
|
|
35249
35442
|
const activeFilters = [
|
|
35250
35443
|
state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
|
|
35251
|
-
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
|
|
35252
|
-
state.readonlyMode ? "
|
|
35444
|
+
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}${state.toolsIgnored ? " (ignored: names no known group)" : ""}` : null,
|
|
35445
|
+
state.readonlyMode ? `TAILSCALE_READONLY=${state.readonlyEnv ?? "1"}` : null,
|
|
35253
35446
|
state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
|
|
35254
35447
|
state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
|
|
35255
35448
|
].filter(Boolean);
|
|
@@ -35289,7 +35482,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
|
|
|
35289
35482
|
);
|
|
35290
35483
|
process.exit(1);
|
|
35291
35484
|
}
|
|
35292
|
-
var version2 = true ? "0.
|
|
35485
|
+
var version2 = true ? "0.22.0" : resolveVersionFallback();
|
|
35293
35486
|
var subcommand = process.argv[2];
|
|
35294
35487
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
35295
35488
|
|
|
@@ -35318,6 +35511,9 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35318
35511
|
}
|
|
35319
35512
|
const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
|
|
35320
35513
|
run(filePath).then(() => process.exit(0)).catch((err) => {
|
|
35514
|
+
if (err instanceof Error && err.name === "CliFailure") {
|
|
35515
|
+
process.exit(1);
|
|
35516
|
+
}
|
|
35321
35517
|
console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
35322
35518
|
process.exit(1);
|
|
35323
35519
|
});
|
|
@@ -35335,6 +35531,8 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35335
35531
|
if (!cliSubcommandHandled) {
|
|
35336
35532
|
const localCliEnabled = isLocalCliEnabled(process.env);
|
|
35337
35533
|
const toolGroups = buildToolGroups(process.env);
|
|
35534
|
+
const fullRegistry = buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" });
|
|
35535
|
+
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35338
35536
|
const {
|
|
35339
35537
|
tools: allTools,
|
|
35340
35538
|
unknownGroups,
|
|
@@ -35353,6 +35551,10 @@ if (!cliSubcommandHandled) {
|
|
|
35353
35551
|
profile: process.env.TAILSCALE_PROFILE,
|
|
35354
35552
|
writeGroups: process.env.TAILSCALE_WRITE_GROUPS
|
|
35355
35553
|
});
|
|
35554
|
+
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35555
|
+
if (tailnetMismatch) {
|
|
35556
|
+
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35557
|
+
}
|
|
35356
35558
|
if (unknownGroups.length > 0) {
|
|
35357
35559
|
const validNames = Object.keys(toolGroups);
|
|
35358
35560
|
const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
|
|
@@ -35362,7 +35564,7 @@ if (!cliSubcommandHandled) {
|
|
|
35362
35564
|
}
|
|
35363
35565
|
if (unknownWriteGroups && unknownWriteGroups.length > 0) {
|
|
35364
35566
|
const validNames = Object.keys(toolGroups);
|
|
35365
|
-
const everyPossibleGroup = Object.keys(
|
|
35567
|
+
const everyPossibleGroup = Object.keys(fullRegistry);
|
|
35366
35568
|
const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
|
|
35367
35569
|
const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
|
|
35368
35570
|
if (notEnabled.length > 0) {
|
|
@@ -35389,10 +35591,6 @@ if (!cliSubcommandHandled) {
|
|
|
35389
35591
|
`@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
35592
|
);
|
|
35391
35593
|
}
|
|
35392
|
-
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35393
|
-
if (tailnetMismatch) {
|
|
35394
|
-
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35395
|
-
}
|
|
35396
35594
|
if (unknownProfile) {
|
|
35397
35595
|
console.error(
|
|
35398
35596
|
`@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 +35606,7 @@ if (!cliSubcommandHandled) {
|
|
|
35408
35606
|
// that is currently disabled -- which is exactly the group an agent needs
|
|
35409
35607
|
// explained. Reporting only what loaded would make local-cli invisible rather
|
|
35410
35608
|
// than explained.
|
|
35411
|
-
fullRegistry
|
|
35609
|
+
fullRegistry,
|
|
35412
35610
|
// Ground truth for availability: what this server actually serves. Deliberately
|
|
35413
35611
|
// not a re-derivation of the filter logic, so the catalog cannot disagree with
|
|
35414
35612
|
// the server about what exists.
|
|
@@ -35416,8 +35614,10 @@ if (!cliSubcommandHandled) {
|
|
|
35416
35614
|
toolsEnv: process.env.TAILSCALE_TOOLS,
|
|
35417
35615
|
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
35418
35616
|
writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
|
|
35419
|
-
readonlyMode
|
|
35420
|
-
|
|
35617
|
+
readonlyMode,
|
|
35618
|
+
readonlyEnv: process.env.TAILSCALE_READONLY,
|
|
35619
|
+
localCliEnabled,
|
|
35620
|
+
toolsIgnored: toolsAllUnknown
|
|
35421
35621
|
});
|
|
35422
35622
|
for (const tool of metaTools) {
|
|
35423
35623
|
server.registerTool(
|
|
@@ -35482,7 +35682,6 @@ if (!cliSubcommandHandled) {
|
|
|
35482
35682
|
`);
|
|
35483
35683
|
process.exit(1);
|
|
35484
35684
|
});
|
|
35485
|
-
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35486
35685
|
const filterSuffix = formatBannerFilterSuffix({
|
|
35487
35686
|
unknownProfile,
|
|
35488
35687
|
explicitTools,
|
|
@@ -35496,15 +35695,22 @@ if (!cliSubcommandHandled) {
|
|
|
35496
35695
|
console.error(
|
|
35497
35696
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
35498
35697
|
);
|
|
35499
|
-
const hasCreds =
|
|
35500
|
-
const ADMIN_EQUIVALENT =
|
|
35698
|
+
const hasCreds = hasUsableCredentials();
|
|
35699
|
+
const ADMIN_EQUIVALENT = {
|
|
35700
|
+
keys: "tailscale_create_key mints an OAuth client with any scopes the caller asks for",
|
|
35701
|
+
users: 'tailscale_update_user_role accepts "owner"',
|
|
35702
|
+
acl: "tailscale_update_acl rewrites policy for every principal"
|
|
35703
|
+
};
|
|
35501
35704
|
const registeredNames = new Set(allTools.map((t) => t.name));
|
|
35502
|
-
const adminWritable = ADMIN_EQUIVALENT.filter(
|
|
35705
|
+
const adminWritable = Object.keys(ADMIN_EQUIVALENT).filter(
|
|
35503
35706
|
(g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
|
|
35504
35707
|
);
|
|
35505
35708
|
if (adminWritable.length > 0 && hasCreds) {
|
|
35709
|
+
const examples = adminWritable.map((g) => ADMIN_EQUIVALENT[g]);
|
|
35710
|
+
const last = examples.pop();
|
|
35711
|
+
const exampleText = examples.length === 0 ? last : `${examples.join(", ")}${examples.length > 1 ? "," : ""} and ${last}`;
|
|
35506
35712
|
console.error(
|
|
35507
|
-
`@yawlabs/tailscale-mcp: note -- this server can write to ${adminWritable.join(", ")}, which is tailnet-admin-equivalent.
|
|
35713
|
+
`@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
35714
|
);
|
|
35509
35715
|
}
|
|
35510
35716
|
if (!filterSuffix && hasCreds) {
|