@yawlabs/tailscale-mcp 0.20.2 → 0.21.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 +95 -41
- package/bin/tailscale-mcp.mjs +45 -0
- package/dist/index.js +464 -121
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -31297,7 +31297,9 @@ var DEFAULT_429_DELAY_MS = 1e3;
|
|
|
31297
31297
|
var MAX_429_DELAY_MS = 3e4;
|
|
31298
31298
|
var MAX_429_JITTER_MS = 250;
|
|
31299
31299
|
var MAX_REQUEST_BUDGET_MS = 9e4;
|
|
31300
|
+
var GATEWAY_5XX_BUDGET_FRACTION = 0.5;
|
|
31300
31301
|
var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
|
|
31302
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
31301
31303
|
var oauthToken = null;
|
|
31302
31304
|
var oauthRefreshPromise = null;
|
|
31303
31305
|
function getAuthConfig() {
|
|
@@ -31351,7 +31353,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31351
31353
|
});
|
|
31352
31354
|
if (!res.ok) {
|
|
31353
31355
|
const body = await res.text();
|
|
31354
|
-
const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://console.tailscale.com/admin/settings/
|
|
31356
|
+
const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://console.tailscale.com/admin/settings/trust-credentials)." + (oauthTailnet ? ` Targeting tailnet "${oauthTailnet}" via TAILSCALE_OAUTH_TAILNET -- that requires an OAuth client from the CREATING tailnet with the 'all' scope.` : "") : "";
|
|
31355
31357
|
throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
|
|
31356
31358
|
}
|
|
31357
31359
|
const data = await res.json();
|
|
@@ -31385,6 +31387,7 @@ function getTailnet() {
|
|
|
31385
31387
|
function encPath(segment) {
|
|
31386
31388
|
return encodeURIComponent(segment);
|
|
31387
31389
|
}
|
|
31390
|
+
var DEVICE_ID_HINT = "nodeId from tailscale_list_devices (e.g. nPM2KNuedB21DEVEL); numeric id ok; not the nodeKey.";
|
|
31388
31391
|
function validateTags(tags) {
|
|
31389
31392
|
if (!tags || tags.length === 0) return;
|
|
31390
31393
|
const invalid = tags.filter((t) => !t.startsWith("tag:"));
|
|
@@ -31403,6 +31406,7 @@ function validateAndSanitizeDescription(value) {
|
|
|
31403
31406
|
`description ${JSON.stringify(value)} contains no valid characters after sanitization. Allowed characters: alphanumeric, spaces, and hyphens (max 50 chars).`
|
|
31404
31407
|
);
|
|
31405
31408
|
}
|
|
31409
|
+
var OAUTH_SCOPE_TABLE_HEADING = "OAuth scopes by tool group";
|
|
31406
31410
|
function formatAuthError(status, apiBody) {
|
|
31407
31411
|
let usingOAuth = false;
|
|
31408
31412
|
try {
|
|
@@ -31411,7 +31415,7 @@ function formatAuthError(status, apiBody) {
|
|
|
31411
31415
|
usingOAuth = false;
|
|
31412
31416
|
}
|
|
31413
31417
|
const headline = status === 401 ? "Authentication failed (HTTP 401)." : "Authorization failed (HTTP 403): the request was authenticated but not permitted for this resource.";
|
|
31414
|
-
const cause = status === 401 ? usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked" : usingOAuth ?
|
|
31418
|
+
const cause = status === 401 ? usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked" : usingOAuth ? ` - OAuth client is missing a scope required for this endpoint (scopes per tool group: README, "${OAUTH_SCOPE_TABLE_HEADING}")` : " - API key lacks the permission required for this endpoint";
|
|
31415
31419
|
const lines = [headline, "", "Possible causes:", cause];
|
|
31416
31420
|
if (status === 401 && process.platform === "win32" && !usingOAuth) {
|
|
31417
31421
|
lines.push(
|
|
@@ -31422,13 +31426,89 @@ function formatAuthError(status, apiBody) {
|
|
|
31422
31426
|
" 2. Set TAILSCALE_API_KEY as a Windows user environment variable (System Properties > Environment Variables)"
|
|
31423
31427
|
);
|
|
31424
31428
|
}
|
|
31425
|
-
const link = status === 401 ? "Generate a new key at: https://console.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the
|
|
31429
|
+
const link = status === 401 ? "Generate a new key at: https://console.tailscale.com/admin/settings/keys" : usingOAuth ? "Adjust the credential's scopes at: https://console.tailscale.com/admin/settings/trust-credentials" : "Adjust the API key permissions at: https://console.tailscale.com/admin/settings/keys";
|
|
31426
31430
|
lines.push("", link);
|
|
31427
31431
|
if (apiBody) {
|
|
31428
31432
|
lines.push("", `API response: ${apiBody}`);
|
|
31429
31433
|
}
|
|
31430
31434
|
return lines.join("\n");
|
|
31431
31435
|
}
|
|
31436
|
+
var ERROR_DATA_MAX_LINES = 100;
|
|
31437
|
+
var ERROR_DATA_MAX_CHARS = 8e3;
|
|
31438
|
+
var ERROR_DATA_MAX_JSON_CHARS = 1e3;
|
|
31439
|
+
var SENSITIVE_DATA_KEY = /secret|token|password|credential|key/i;
|
|
31440
|
+
function redactSensitive(value) {
|
|
31441
|
+
if (Array.isArray(value)) return value.map(redactSensitive);
|
|
31442
|
+
if (value === null || typeof value !== "object") return value;
|
|
31443
|
+
const out = {};
|
|
31444
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
31445
|
+
out[key] = SENSITIVE_DATA_KEY.test(key) ? "[redacted]" : redactSensitive(inner);
|
|
31446
|
+
}
|
|
31447
|
+
return out;
|
|
31448
|
+
}
|
|
31449
|
+
function asStringArray(value) {
|
|
31450
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : void 0;
|
|
31451
|
+
}
|
|
31452
|
+
function renderJsonEntry(value) {
|
|
31453
|
+
const json2 = JSON.stringify(redactSensitive(value)) ?? String(value);
|
|
31454
|
+
if (json2.length <= ERROR_DATA_MAX_JSON_CHARS) return json2;
|
|
31455
|
+
return `${json2.slice(0, ERROR_DATA_MAX_JSON_CHARS)}... (${json2.length - ERROR_DATA_MAX_JSON_CHARS} more characters)`;
|
|
31456
|
+
}
|
|
31457
|
+
function joinErrorDataBlocks(blocks) {
|
|
31458
|
+
const out = [];
|
|
31459
|
+
let lines = 0;
|
|
31460
|
+
let chars = 0;
|
|
31461
|
+
let shown = 0;
|
|
31462
|
+
for (const block of blocks) {
|
|
31463
|
+
const blockChars = block.reduce((n, line) => n + line.length + 1, 0);
|
|
31464
|
+
if (lines + block.length <= ERROR_DATA_MAX_LINES && chars + blockChars <= ERROR_DATA_MAX_CHARS) {
|
|
31465
|
+
out.push(...block);
|
|
31466
|
+
lines += block.length;
|
|
31467
|
+
chars += blockChars;
|
|
31468
|
+
shown++;
|
|
31469
|
+
continue;
|
|
31470
|
+
}
|
|
31471
|
+
if (shown === 0) {
|
|
31472
|
+
const kept = block.slice(0, ERROR_DATA_MAX_LINES);
|
|
31473
|
+
out.push(...kept);
|
|
31474
|
+
if (kept.length < block.length) out.push(`... and ${block.length - kept.length} more lines in this entry`);
|
|
31475
|
+
shown++;
|
|
31476
|
+
}
|
|
31477
|
+
break;
|
|
31478
|
+
}
|
|
31479
|
+
const dropped = blocks.length - shown;
|
|
31480
|
+
if (dropped > 0) out.push(`... and ${dropped} more ${dropped === 1 ? "entry" : "entries"}`);
|
|
31481
|
+
return out.join("\n");
|
|
31482
|
+
}
|
|
31483
|
+
function formatApiErrorData(data) {
|
|
31484
|
+
if (!Array.isArray(data) || data.length === 0) return "";
|
|
31485
|
+
const blocks = [];
|
|
31486
|
+
for (const entry of data) {
|
|
31487
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
31488
|
+
blocks.push([renderJsonEntry(entry)]);
|
|
31489
|
+
continue;
|
|
31490
|
+
}
|
|
31491
|
+
const obj = entry;
|
|
31492
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
31493
|
+
const rendered = [];
|
|
31494
|
+
if (typeof obj.user === "string") {
|
|
31495
|
+
consumed.add("user");
|
|
31496
|
+
if (obj.user.length > 0) rendered.push(`For user ${obj.user}:`);
|
|
31497
|
+
}
|
|
31498
|
+
for (const [key, heading] of [
|
|
31499
|
+
["errors", "Errors found:"],
|
|
31500
|
+
["warnings", "Warnings found:"]
|
|
31501
|
+
]) {
|
|
31502
|
+
const items = asStringArray(obj[key]);
|
|
31503
|
+
if (!items) continue;
|
|
31504
|
+
consumed.add(key);
|
|
31505
|
+
if (items.length > 0) rendered.push(heading, ...items.map((item) => `- ${item}`));
|
|
31506
|
+
}
|
|
31507
|
+
if (rendered.length > 0 && Object.keys(obj).every((key) => consumed.has(key))) blocks.push(rendered);
|
|
31508
|
+
else blocks.push([renderJsonEntry(obj)]);
|
|
31509
|
+
}
|
|
31510
|
+
return joinErrorDataBlocks(blocks);
|
|
31511
|
+
}
|
|
31432
31512
|
function extractErrorMessage(body) {
|
|
31433
31513
|
if (!body) return body;
|
|
31434
31514
|
const trimmed = body.trim();
|
|
@@ -31437,8 +31517,12 @@ function extractErrorMessage(body) {
|
|
|
31437
31517
|
const parsed = JSON.parse(trimmed);
|
|
31438
31518
|
if (parsed && typeof parsed === "object") {
|
|
31439
31519
|
const obj = parsed;
|
|
31440
|
-
|
|
31441
|
-
if (
|
|
31520
|
+
const message = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : typeof obj.error === "string" && obj.error.length > 0 ? obj.error : void 0;
|
|
31521
|
+
if (message !== void 0) {
|
|
31522
|
+
const details = formatApiErrorData(obj.data);
|
|
31523
|
+
return details ? `${message}
|
|
31524
|
+
${details}` : message;
|
|
31525
|
+
}
|
|
31442
31526
|
}
|
|
31443
31527
|
} catch {
|
|
31444
31528
|
}
|
|
@@ -31536,6 +31620,15 @@ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
|
|
|
31536
31620
|
}
|
|
31537
31621
|
return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
|
|
31538
31622
|
}
|
|
31623
|
+
function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, priorTransportError) {
|
|
31624
|
+
if (status !== 404 || method.toUpperCase() !== "DELETE") return error51;
|
|
31625
|
+
const causes = [];
|
|
31626
|
+
if (priorGatewayStatus !== void 0) causes.push(`returned HTTP ${priorGatewayStatus}`);
|
|
31627
|
+
if (priorTransportError !== void 0) causes.push(`never returned a response (${priorTransportError})`);
|
|
31628
|
+
if (causes.length === 0) return error51;
|
|
31629
|
+
const subject = causes.length > 1 ? "earlier attempts" : "an earlier attempt";
|
|
31630
|
+
return `${error51 || `HTTP ${status}`} (${subject} ${causes.join(" and ")}; the delete may already have succeeded)`;
|
|
31631
|
+
}
|
|
31539
31632
|
async function apiRequest(method, path, body, options) {
|
|
31540
31633
|
const headers = {};
|
|
31541
31634
|
if (options?.accept) {
|
|
@@ -31569,16 +31662,20 @@ async function apiRequest(method, path, body, options) {
|
|
|
31569
31662
|
headers.Authorization = await getAuthHeader();
|
|
31570
31663
|
let res;
|
|
31571
31664
|
let lastTransportError;
|
|
31665
|
+
let priorGatewayStatus;
|
|
31666
|
+
let priorTransportError;
|
|
31667
|
+
let budgetMs = requestBudgetMs;
|
|
31572
31668
|
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
31573
|
-
const remaining =
|
|
31669
|
+
const remaining = budgetMs - (Date.now() - startedAt);
|
|
31574
31670
|
if (remaining <= 0) {
|
|
31575
31671
|
return {
|
|
31576
31672
|
ok: false,
|
|
31577
31673
|
status: 0,
|
|
31578
|
-
error: describeBudgetExhaustion(
|
|
31674
|
+
error: describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError)
|
|
31579
31675
|
};
|
|
31580
31676
|
}
|
|
31581
31677
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
31678
|
+
const attemptStartedAt = Date.now();
|
|
31582
31679
|
let attemptRes;
|
|
31583
31680
|
try {
|
|
31584
31681
|
attemptRes = await executeFetch(method, url2, headers, fetchBody, attemptTimeoutMs);
|
|
@@ -31590,25 +31687,35 @@ async function apiRequest(method, path, body, options) {
|
|
|
31590
31687
|
}
|
|
31591
31688
|
const delay2 = compute429DelayMs(null, attempt);
|
|
31592
31689
|
const elapsed3 = Date.now() - startedAt;
|
|
31593
|
-
if (
|
|
31690
|
+
if (budgetMs - elapsed3 - delay2 <= 0) {
|
|
31594
31691
|
return { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
|
|
31595
31692
|
}
|
|
31596
31693
|
debugLog(
|
|
31597
31694
|
` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
|
|
31598
31695
|
);
|
|
31696
|
+
priorTransportError = desc;
|
|
31599
31697
|
await new Promise((r) => setTimeout(r, delay2));
|
|
31600
31698
|
continue;
|
|
31601
31699
|
}
|
|
31602
31700
|
res = attemptRes;
|
|
31603
|
-
if (res.status
|
|
31701
|
+
if (!RETRYABLE_STATUSES.has(res.status) || attempt === MAX_429_RETRIES || !isRetryable) break;
|
|
31604
31702
|
const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
|
|
31703
|
+
const gatewayCeilingMs = Math.min(budgetMs, Math.floor(requestBudgetMs * GATEWAY_5XX_BUDGET_FRACTION));
|
|
31704
|
+
const retryCeilingMs = res.status === 429 ? budgetMs : gatewayCeilingMs;
|
|
31605
31705
|
const elapsed2 = Date.now() - startedAt;
|
|
31606
|
-
const
|
|
31706
|
+
const predictedCostMs = res.status === 429 ? delay : delay + (Date.now() - attemptStartedAt);
|
|
31707
|
+
const nextAttemptBudgetMs = retryCeilingMs - elapsed2 - predictedCostMs;
|
|
31607
31708
|
if (nextAttemptBudgetMs <= 0) {
|
|
31608
|
-
debugLog(
|
|
31709
|
+
debugLog(
|
|
31710
|
+
` -> ${res.status} (attempt ${attempt + 1}), giving up: budget exhausted (${elapsed2}ms + ${predictedCostMs}ms of ${retryCeilingMs}ms)`
|
|
31711
|
+
);
|
|
31609
31712
|
break;
|
|
31610
31713
|
}
|
|
31611
|
-
debugLog(` ->
|
|
31714
|
+
debugLog(` -> ${res.status} (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
31715
|
+
if (res.status !== 429) {
|
|
31716
|
+
priorGatewayStatus = res.status;
|
|
31717
|
+
budgetMs = retryCeilingMs;
|
|
31718
|
+
}
|
|
31612
31719
|
await res.text().catch(() => void 0);
|
|
31613
31720
|
await new Promise((r) => setTimeout(r, delay));
|
|
31614
31721
|
}
|
|
@@ -31624,14 +31731,25 @@ async function apiRequest(method, path, body, options) {
|
|
|
31624
31731
|
const rawBody = await response.text();
|
|
31625
31732
|
if (!response.ok) {
|
|
31626
31733
|
const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, rawBody) : extractErrorMessage(rawBody);
|
|
31627
|
-
return {
|
|
31734
|
+
return {
|
|
31735
|
+
ok: false,
|
|
31736
|
+
status: response.status,
|
|
31737
|
+
error: annotateAmbiguousDelete(error51, response.status, method, priorGatewayStatus, priorTransportError),
|
|
31738
|
+
rawBody,
|
|
31739
|
+
etag
|
|
31740
|
+
};
|
|
31628
31741
|
}
|
|
31629
31742
|
return { ok: true, status: response.status, rawBody, etag };
|
|
31630
31743
|
}
|
|
31631
31744
|
if (!response.ok) {
|
|
31632
31745
|
const errorBody = await response.text();
|
|
31633
31746
|
const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, errorBody) : extractErrorMessage(errorBody);
|
|
31634
|
-
return {
|
|
31747
|
+
return {
|
|
31748
|
+
ok: false,
|
|
31749
|
+
status: response.status,
|
|
31750
|
+
error: annotateAmbiguousDelete(error51, response.status, method, priorGatewayStatus, priorTransportError),
|
|
31751
|
+
etag
|
|
31752
|
+
};
|
|
31635
31753
|
}
|
|
31636
31754
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
31637
31755
|
return { ok: true, status: response.status, etag };
|
|
@@ -31665,26 +31783,43 @@ async function apiDelete(path, options) {
|
|
|
31665
31783
|
}
|
|
31666
31784
|
|
|
31667
31785
|
// src/cli.ts
|
|
31786
|
+
function isWarningsOnly(message, data) {
|
|
31787
|
+
if (!/^warning/i.test(message)) return false;
|
|
31788
|
+
if (!Array.isArray(data) || data.length === 0) return false;
|
|
31789
|
+
return data.every((entry) => {
|
|
31790
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
31791
|
+
const obj = entry;
|
|
31792
|
+
if (!Object.keys(obj).every((key) => key === "user" || key === "warnings")) return false;
|
|
31793
|
+
if (obj.user !== void 0 && typeof obj.user !== "string") return false;
|
|
31794
|
+
return Array.isArray(obj.warnings) && obj.warnings.length > 0 && obj.warnings.every((w) => typeof w === "string");
|
|
31795
|
+
});
|
|
31796
|
+
}
|
|
31668
31797
|
function parseValidationError(rawBody) {
|
|
31669
31798
|
const trimmed = rawBody?.trim();
|
|
31670
|
-
if (!trimmed) return
|
|
31799
|
+
if (!trimmed) return { kind: "valid" };
|
|
31671
31800
|
let parsed;
|
|
31672
31801
|
try {
|
|
31673
31802
|
parsed = JSON.parse(trimmed);
|
|
31674
31803
|
} catch {
|
|
31675
|
-
return trimmed;
|
|
31804
|
+
return { kind: "failure", message: trimmed };
|
|
31676
31805
|
}
|
|
31677
31806
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
31678
|
-
return trimmed;
|
|
31807
|
+
return { kind: "failure", message: trimmed };
|
|
31679
31808
|
}
|
|
31680
31809
|
const obj = parsed;
|
|
31681
|
-
|
|
31682
|
-
if (
|
|
31683
|
-
|
|
31810
|
+
const message = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : typeof obj.error === "string" && obj.error.length > 0 ? obj.error : void 0;
|
|
31811
|
+
if (message === void 0) return { kind: "valid" };
|
|
31812
|
+
const details = formatApiErrorData(obj.data);
|
|
31813
|
+
return {
|
|
31814
|
+
kind: isWarningsOnly(message, obj.data) ? "warnings" : "failure",
|
|
31815
|
+
message,
|
|
31816
|
+
...details ? { details } : {}
|
|
31817
|
+
};
|
|
31684
31818
|
}
|
|
31685
31819
|
function readPolicyFile(filePath) {
|
|
31686
31820
|
try {
|
|
31687
|
-
|
|
31821
|
+
const text = readFileSync(filePath, "utf-8");
|
|
31822
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
31688
31823
|
} catch (err) {
|
|
31689
31824
|
console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
31690
31825
|
process.exit(1);
|
|
@@ -31701,9 +31836,10 @@ async function validatePolicy(policy) {
|
|
|
31701
31836
|
console.error(`ACL validation failed: ${validateRes.error}`);
|
|
31702
31837
|
process.exit(1);
|
|
31703
31838
|
}
|
|
31704
|
-
const
|
|
31705
|
-
if (
|
|
31706
|
-
console.error(`ACL validation failed: ${
|
|
31839
|
+
const validation = parseValidationError(validateRes.rawBody);
|
|
31840
|
+
if (validation.kind !== "valid") {
|
|
31841
|
+
console.error(`ACL validation failed: ${validation.message}${validation.details ? `
|
|
31842
|
+
${validation.details}` : ""}`);
|
|
31707
31843
|
process.exit(1);
|
|
31708
31844
|
}
|
|
31709
31845
|
}
|
|
@@ -31873,6 +32009,17 @@ function stripEtagFooter(body) {
|
|
|
31873
32009
|
}
|
|
31874
32010
|
return lines.slice(0, cut).join("\n");
|
|
31875
32011
|
}
|
|
32012
|
+
function normalizeIfMatch(etag) {
|
|
32013
|
+
const trimmed = etag.trim();
|
|
32014
|
+
if (trimmed.startsWith("W/")) return trimmed;
|
|
32015
|
+
const inner = trimmed.replace(/^"+|"+$/g, "").trim();
|
|
32016
|
+
if (!inner) {
|
|
32017
|
+
throw new Error(
|
|
32018
|
+
"etag is empty once its quotes are removed -- a value with nothing inside its quotes cannot match any ETag the tailnet holds, so this overwrite would come back 412."
|
|
32019
|
+
);
|
|
32020
|
+
}
|
|
32021
|
+
return `"${inner}"`;
|
|
32022
|
+
}
|
|
31876
32023
|
var aclTools = [
|
|
31877
32024
|
{
|
|
31878
32025
|
name: "tailscale_get_acl",
|
|
@@ -31905,7 +32052,7 @@ var aclTools = [
|
|
|
31905
32052
|
},
|
|
31906
32053
|
{
|
|
31907
32054
|
name: "tailscale_update_acl",
|
|
31908
|
-
description: "Update the ACL policy for your tailnet. Accepts the full policy as a string to preserve formatting, comments, and trailing commas (HuJSON). You MUST pass the ETag from tailscale_get_acl to prevent overwriting concurrent changes. Always get the current ACL first, make targeted edits to the text, and pass the full modified text back.",
|
|
32055
|
+
description: "Update the ACL policy for your tailnet. Accepts the full policy as a string to preserve formatting, comments, and trailing commas (HuJSON). You MUST pass the ETag from tailscale_get_acl to prevent overwriting concurrent changes, or `ts-default` for the first write to a tailnet nobody has edited yet. Always get the current ACL first, make targeted edits to the text, and pass the full modified text back.",
|
|
31909
32056
|
annotations: {
|
|
31910
32057
|
title: "Update ACL policy",
|
|
31911
32058
|
readOnlyHint: false,
|
|
@@ -31920,7 +32067,9 @@ var aclTools = [
|
|
|
31920
32067
|
policy: external_exports.string().describe(
|
|
31921
32068
|
"The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
|
|
31922
32069
|
),
|
|
31923
|
-
etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe(
|
|
32070
|
+
etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe(
|
|
32071
|
+
"The ETag from tailscale_get_acl (quotes optional -- they are normalized). Required to prevent concurrent edit conflicts. For the FIRST write to a fresh tailnet you may pass `ts-default` instead: the update then succeeds only if the policy file is still Tailscale's untouched default."
|
|
32072
|
+
)
|
|
31924
32073
|
}),
|
|
31925
32074
|
// `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
|
|
31926
32075
|
// `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
|
|
@@ -31930,11 +32079,15 @@ var aclTools = [
|
|
|
31930
32079
|
// on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
|
|
31931
32080
|
// header goes out carrying a precondition that cannot match any real ETag --
|
|
31932
32081
|
// a confusing 412 instead of a local validation error naming the field.
|
|
32082
|
+
// Quote normalization lives in the handler rather than in the schema because
|
|
32083
|
+
// this is the code that builds the header, and because the handlers are what
|
|
32084
|
+
// the tests call directly -- a transform on the schema would be invisible to
|
|
32085
|
+
// every assertion made at the header.
|
|
31933
32086
|
handler: async (input) => {
|
|
31934
32087
|
return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31935
32088
|
rawBody: input.policy,
|
|
31936
32089
|
contentType: "application/hujson",
|
|
31937
|
-
ifMatch: input.etag,
|
|
32090
|
+
ifMatch: normalizeIfMatch(input.etag),
|
|
31938
32091
|
acceptRaw: true,
|
|
31939
32092
|
accept: "application/hujson"
|
|
31940
32093
|
});
|
|
@@ -32165,9 +32318,9 @@ function assertRFC3339(value, label) {
|
|
|
32165
32318
|
}
|
|
32166
32319
|
}
|
|
32167
32320
|
var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
32168
|
-
function assertLogRange(start, end, label) {
|
|
32321
|
+
function assertLogRange(start, end, label, nowMs = Date.now()) {
|
|
32169
32322
|
const startMs = Date.parse(start);
|
|
32170
|
-
const endMs = end ? Date.parse(end) :
|
|
32323
|
+
const endMs = end ? Date.parse(end) : nowMs;
|
|
32171
32324
|
if (endMs < startMs) {
|
|
32172
32325
|
throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
|
|
32173
32326
|
}
|
|
@@ -32177,10 +32330,14 @@ function assertLogRange(start, end, label) {
|
|
|
32177
32330
|
);
|
|
32178
32331
|
}
|
|
32179
32332
|
}
|
|
32333
|
+
function isoSecond(now) {
|
|
32334
|
+
const pad = (value, width = 2) => String(value).padStart(width, "0");
|
|
32335
|
+
return `${pad(now.getUTCFullYear(), 4)}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())}Z`;
|
|
32336
|
+
}
|
|
32180
32337
|
var auditTools = [
|
|
32181
32338
|
{
|
|
32182
32339
|
name: "tailscale_get_audit_log",
|
|
32183
|
-
description: "Get the tailnet audit/configuration log. Shows who changed what and when
|
|
32340
|
+
description: "Get the tailnet audit/configuration log. Shows who changed what and when -- useful for troubleshooting and compliance. Optional actor, target and event filters narrow the query server-side, so a targeted question doesn't have to pull the whole window.",
|
|
32184
32341
|
annotations: {
|
|
32185
32342
|
title: "Get audit log",
|
|
32186
32343
|
readOnlyHint: true,
|
|
@@ -32188,16 +32345,46 @@ var auditTools = [
|
|
|
32188
32345
|
idempotentHint: true,
|
|
32189
32346
|
openWorldHint: true
|
|
32190
32347
|
},
|
|
32348
|
+
// The three filters are free strings rather than the spec's 138-value event
|
|
32349
|
+
// enum: that list is still growing (the PAM_* entries are recent), and a
|
|
32350
|
+
// hard enum would make a new event type unqueryable rather than merely
|
|
32351
|
+
// unvalidated. `.trim().min(1)` is the house idiom -- a bare min(1) admits
|
|
32352
|
+
// " ", which goes on the wire as `event=+...` and comes back empty with no
|
|
32353
|
+
// error.
|
|
32354
|
+
//
|
|
32355
|
+
// `.max(1)` for now. The spec declares no style/explode on these array
|
|
32356
|
+
// parameters, so repeated keys are inferred from the OpenAPI default and
|
|
32357
|
+
// nothing upstream corroborates it: neither the Go client nor Terraform
|
|
32358
|
+
// implements this endpoint, and the KB page documents only start and end. A
|
|
32359
|
+
// single value is wire-identical whether the server wants repeated keys or a
|
|
32360
|
+
// comma-joined list, so it cannot under-report; two values under the wrong
|
|
32361
|
+
// guess would return a subset with no error, which in a compliance query
|
|
32362
|
+
// reads as "that change never happened". Lift the cap once a live call
|
|
32363
|
+
// settles it.
|
|
32191
32364
|
inputSchema: external_exports.object({
|
|
32192
32365
|
start: external_exports.string().describe("Start time in RFC3339 format (e.g. '2026-04-01T00:00:00Z'). Required."),
|
|
32193
|
-
end: external_exports.string().optional().describe(
|
|
32366
|
+
end: external_exports.string().optional().describe(
|
|
32367
|
+
"End time in RFC3339 format. Optional: when omitted the tool sends the current time, which Tailscale's API requires."
|
|
32368
|
+
),
|
|
32369
|
+
actor: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
|
|
32370
|
+
"Server-side filter: one exact actor ID, or '~text' to wildcard-match a login or display name (e.g. '~bob'). One value per call -- how the API reads a repeated filter key is not verified yet."
|
|
32371
|
+
),
|
|
32372
|
+
target: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
|
|
32373
|
+
"Server-side filter: one string, matched against any part of any of an entry's targets (ID or name). One value per call, as for actor."
|
|
32374
|
+
),
|
|
32375
|
+
event: external_exports.array(external_exports.string().trim().min(1)).max(1).optional().describe(
|
|
32376
|
+
"Server-side filter: one event type from Tailscale's audit event list, e.g. 'TAILNET.UPDATE.ACL', 'TAILNET.UPDATE.DNS_CONFIG', 'NODE.CREATE', 'NODE.DELETE', 'API_KEY.CREATE', 'USER.UPDATE.USER_ROLE', 'WEBHOOK_ENDPOINT.CREATE'. Not a closed set -- the list keeps growing. One value per call, as for actor."
|
|
32377
|
+
)
|
|
32194
32378
|
}),
|
|
32195
32379
|
handler: async (input) => {
|
|
32196
32380
|
assertRFC3339(input.start, "start");
|
|
32197
32381
|
if (input.end) assertRFC3339(input.end, "end");
|
|
32198
|
-
|
|
32199
|
-
|
|
32200
|
-
|
|
32382
|
+
const wireEnd = input.end ? input.end : isoSecond(/* @__PURE__ */ new Date());
|
|
32383
|
+
assertLogRange(input.start, input.end, "tailscale_get_audit_log", Date.parse(wireEnd));
|
|
32384
|
+
const params = new URLSearchParams({ start: input.start, end: wireEnd });
|
|
32385
|
+
for (const actor of input.actor ?? []) params.append("actor", actor);
|
|
32386
|
+
for (const target of input.target ?? []) params.append("target", target);
|
|
32387
|
+
for (const event of input.event ?? []) params.append("event", event);
|
|
32201
32388
|
return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
|
|
32202
32389
|
}
|
|
32203
32390
|
},
|
|
@@ -32211,16 +32398,20 @@ var auditTools = [
|
|
|
32211
32398
|
idempotentHint: true,
|
|
32212
32399
|
openWorldHint: true
|
|
32213
32400
|
},
|
|
32401
|
+
// The spec gives this endpoint no actor/target/event filters -- they are on
|
|
32402
|
+
// the configuration log only.
|
|
32214
32403
|
inputSchema: external_exports.object({
|
|
32215
32404
|
start: external_exports.string().describe("Start time in RFC3339 format (e.g. '2026-04-01T00:00:00Z'). Required."),
|
|
32216
|
-
end: external_exports.string().optional().describe(
|
|
32405
|
+
end: external_exports.string().optional().describe(
|
|
32406
|
+
"End time in RFC3339 format. Optional: when omitted the tool sends the current time, which Tailscale's API requires."
|
|
32407
|
+
)
|
|
32217
32408
|
}),
|
|
32218
32409
|
handler: async (input) => {
|
|
32219
32410
|
assertRFC3339(input.start, "start");
|
|
32220
32411
|
if (input.end) assertRFC3339(input.end, "end");
|
|
32221
|
-
|
|
32222
|
-
|
|
32223
|
-
|
|
32412
|
+
const wireEnd = input.end ? input.end : isoSecond(/* @__PURE__ */ new Date());
|
|
32413
|
+
assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs", Date.parse(wireEnd));
|
|
32414
|
+
const params = new URLSearchParams({ start: input.start, end: wireEnd });
|
|
32224
32415
|
return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
|
|
32225
32416
|
}
|
|
32226
32417
|
}
|
|
@@ -32239,10 +32430,12 @@ function isCidr(s) {
|
|
|
32239
32430
|
if (net.isIPv6(addr)) return prefixN <= 128;
|
|
32240
32431
|
return false;
|
|
32241
32432
|
}
|
|
32433
|
+
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.";
|
|
32434
|
+
var DEVICE_LAST_SEEN_NOTE = " 'lastSeen' is omitted while a device is connected (connectedToControl: true) and for devices that have never been online -- on a connected device a missing lastSeen means online now, not never seen.";
|
|
32242
32435
|
var deviceTools = [
|
|
32243
32436
|
{
|
|
32244
32437
|
name: "tailscale_list_devices",
|
|
32245
|
-
description:
|
|
32438
|
+
description: `List all devices in your tailnet with their status, IP addresses, OS, and last seen time.${DEVICE_LAST_SEEN_NOTE}`,
|
|
32246
32439
|
annotations: {
|
|
32247
32440
|
title: "List devices",
|
|
32248
32441
|
readOnlyHint: true,
|
|
@@ -32251,11 +32444,9 @@ var deviceTools = [
|
|
|
32251
32444
|
openWorldHint: true
|
|
32252
32445
|
},
|
|
32253
32446
|
inputSchema: external_exports.object({
|
|
32254
|
-
fields: external_exports.string().optional().describe(
|
|
32255
|
-
|
|
32256
|
-
|
|
32257
|
-
filters: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
|
|
32258
|
-
"Server-side filters as key-value pairs. Filter by any top-level device property (e.g. { isEphemeral: 'true', os: 'linux', tags: 'tag:prod' }). Multiple filters are ANDed together."
|
|
32447
|
+
fields: external_exports.string().optional().describe(`${DEVICE_FIELDS_DESC} Any other value is forwarded unvalidated; Tailscale documents none.`),
|
|
32448
|
+
filters: external_exports.record(external_exports.string(), external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)])).optional().describe(
|
|
32449
|
+
"Server-side filters on top-level device properties, exact match only (e.g. { isEphemeral: 'true', os: 'linux' }). All filters are ANDed. Pass an array to repeat a key: { tags: ['tag:prod', 'tag:subnetrouter'] } sends tags=..&tags=.. and matches devices whose tags contain BOTH. Properties that are complex objects (e.g. clientConnectivity) cannot be filtered; repeating a key on a non-list property is undocumented upstream."
|
|
32259
32450
|
)
|
|
32260
32451
|
}),
|
|
32261
32452
|
handler: async (input) => {
|
|
@@ -32268,7 +32459,7 @@ var deviceTools = [
|
|
|
32268
32459
|
"filters.fields is not allowed -- use the top-level 'fields' parameter to select which device fields to return."
|
|
32269
32460
|
);
|
|
32270
32461
|
}
|
|
32271
|
-
params.
|
|
32462
|
+
for (const one of Array.isArray(value) ? value : [value]) params.append(key, one);
|
|
32272
32463
|
}
|
|
32273
32464
|
}
|
|
32274
32465
|
const qs = params.toString();
|
|
@@ -32277,7 +32468,7 @@ var deviceTools = [
|
|
|
32277
32468
|
},
|
|
32278
32469
|
{
|
|
32279
32470
|
name: "tailscale_get_device",
|
|
32280
|
-
description:
|
|
32471
|
+
description: `Get detailed information about a specific device by its ID. Returns the default field subset unless fields: 'all'.${DEVICE_LAST_SEEN_NOTE}`,
|
|
32281
32472
|
annotations: {
|
|
32282
32473
|
title: "Get device",
|
|
32283
32474
|
readOnlyHint: true,
|
|
@@ -32286,10 +32477,14 @@ var deviceTools = [
|
|
|
32286
32477
|
openWorldHint: true
|
|
32287
32478
|
},
|
|
32288
32479
|
inputSchema: external_exports.object({
|
|
32289
|
-
deviceId: external_exports.string().describe(
|
|
32480
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32481
|
+
fields: external_exports.enum(["all", "default"]).optional().describe(DEVICE_FIELDS_DESC)
|
|
32290
32482
|
}),
|
|
32291
32483
|
handler: async (input) => {
|
|
32292
|
-
|
|
32484
|
+
const params = new URLSearchParams();
|
|
32485
|
+
if (input.fields) params.set("fields", input.fields);
|
|
32486
|
+
const qs = params.toString();
|
|
32487
|
+
return apiGet(`/device/${encPath(input.deviceId)}${qs ? `?${qs}` : ""}`);
|
|
32293
32488
|
}
|
|
32294
32489
|
},
|
|
32295
32490
|
{
|
|
@@ -32303,7 +32498,7 @@ var deviceTools = [
|
|
|
32303
32498
|
openWorldHint: true
|
|
32304
32499
|
},
|
|
32305
32500
|
inputSchema: external_exports.object({
|
|
32306
|
-
deviceId: external_exports.string().describe(
|
|
32501
|
+
deviceId: external_exports.string().describe(`The device ID to authorize. ${DEVICE_ID_HINT}`)
|
|
32307
32502
|
}),
|
|
32308
32503
|
handler: async (input) => {
|
|
32309
32504
|
return apiPost(`/device/${encPath(input.deviceId)}/authorized`, { authorized: true });
|
|
@@ -32320,7 +32515,7 @@ var deviceTools = [
|
|
|
32320
32515
|
openWorldHint: true
|
|
32321
32516
|
},
|
|
32322
32517
|
inputSchema: external_exports.object({
|
|
32323
|
-
deviceId: external_exports.string().describe(
|
|
32518
|
+
deviceId: external_exports.string().describe(`The device ID to deauthorize. ${DEVICE_ID_HINT}`)
|
|
32324
32519
|
}),
|
|
32325
32520
|
handler: async (input) => {
|
|
32326
32521
|
return apiPost(`/device/${encPath(input.deviceId)}/authorized`, { authorized: false });
|
|
@@ -32337,7 +32532,7 @@ var deviceTools = [
|
|
|
32337
32532
|
openWorldHint: true
|
|
32338
32533
|
},
|
|
32339
32534
|
inputSchema: external_exports.object({
|
|
32340
|
-
deviceId: external_exports.string().describe(
|
|
32535
|
+
deviceId: external_exports.string().describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
|
|
32341
32536
|
}),
|
|
32342
32537
|
handler: async (input) => {
|
|
32343
32538
|
return apiDelete(`/device/${encPath(input.deviceId)}`);
|
|
@@ -32345,7 +32540,7 @@ var deviceTools = [
|
|
|
32345
32540
|
},
|
|
32346
32541
|
{
|
|
32347
32542
|
name: "tailscale_rename_device",
|
|
32348
|
-
description: "Set the name of a device in the tailnet.",
|
|
32543
|
+
description: "Set the name of a device in the tailnet, or reset it to its OS hostname.",
|
|
32349
32544
|
annotations: {
|
|
32350
32545
|
title: "Rename device",
|
|
32351
32546
|
readOnlyHint: false,
|
|
@@ -32354,8 +32549,10 @@ var deviceTools = [
|
|
|
32354
32549
|
openWorldHint: true
|
|
32355
32550
|
},
|
|
32356
32551
|
inputSchema: external_exports.object({
|
|
32357
|
-
deviceId: external_exports.string().describe(
|
|
32358
|
-
name: external_exports.string().describe(
|
|
32552
|
+
deviceId: external_exports.string().describe(`The device ID to rename. ${DEVICE_ID_HINT}`),
|
|
32553
|
+
name: external_exports.string().describe(
|
|
32554
|
+
"New device name: the FQDN (e.g. 'nodename.your-tailnet.ts.net') or just the base name (e.g. 'nodename'). Pass an empty string to reset the name to one generated from the OS hostname (per Tailscale's API spec)."
|
|
32555
|
+
)
|
|
32359
32556
|
}),
|
|
32360
32557
|
handler: async (input) => {
|
|
32361
32558
|
return apiPost(`/device/${encPath(input.deviceId)}/name`, { name: input.name });
|
|
@@ -32372,7 +32569,7 @@ var deviceTools = [
|
|
|
32372
32569
|
openWorldHint: true
|
|
32373
32570
|
},
|
|
32374
32571
|
inputSchema: external_exports.object({
|
|
32375
|
-
deviceId: external_exports.string().describe(
|
|
32572
|
+
deviceId: external_exports.string().describe(`The device ID to expire. ${DEVICE_ID_HINT}`)
|
|
32376
32573
|
}),
|
|
32377
32574
|
handler: async (input) => {
|
|
32378
32575
|
return apiPost(`/device/${encPath(input.deviceId)}/expire`);
|
|
@@ -32389,7 +32586,7 @@ var deviceTools = [
|
|
|
32389
32586
|
openWorldHint: true
|
|
32390
32587
|
},
|
|
32391
32588
|
inputSchema: external_exports.object({
|
|
32392
|
-
deviceId: external_exports.string().describe(
|
|
32589
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
|
|
32393
32590
|
}),
|
|
32394
32591
|
handler: async (input) => {
|
|
32395
32592
|
return apiGet(`/device/${encPath(input.deviceId)}/routes`);
|
|
@@ -32408,7 +32605,7 @@ var deviceTools = [
|
|
|
32408
32605
|
openWorldHint: true
|
|
32409
32606
|
},
|
|
32410
32607
|
inputSchema: external_exports.object({
|
|
32411
|
-
deviceId: external_exports.string().describe(
|
|
32608
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32412
32609
|
routes: external_exports.array(external_exports.string().refine(isCidr, { message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" })).describe(
|
|
32413
32610
|
"Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
|
|
32414
32611
|
)
|
|
@@ -32428,7 +32625,7 @@ var deviceTools = [
|
|
|
32428
32625
|
openWorldHint: true
|
|
32429
32626
|
},
|
|
32430
32627
|
inputSchema: external_exports.object({
|
|
32431
|
-
deviceId: external_exports.string().describe(
|
|
32628
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
|
|
32432
32629
|
}),
|
|
32433
32630
|
handler: async (input) => {
|
|
32434
32631
|
return apiGet(`/device/${encPath(input.deviceId)}/attributes`);
|
|
@@ -32445,12 +32642,22 @@ var deviceTools = [
|
|
|
32445
32642
|
openWorldHint: true
|
|
32446
32643
|
},
|
|
32447
32644
|
inputSchema: external_exports.object({
|
|
32448
|
-
deviceId: external_exports.string().describe(
|
|
32449
|
-
|
|
32450
|
-
|
|
32645
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32646
|
+
// The length and charset rules below are Tailscale's, and Tailscale is
|
|
32647
|
+
// the one that enforces them -- only the 'custom:' prefix is checked
|
|
32648
|
+
// client-side, because that one is a namespace choice an agent gets
|
|
32649
|
+
// wrong by accident. A local regex for the rest would be one more thing
|
|
32650
|
+
// to drift out of step with the API's own error.
|
|
32651
|
+
attributeKey: external_exports.string().describe(
|
|
32652
|
+
"The attribute key (must start with 'custom:', e.g. 'custom:lastAuditDate'). Max 128 characters including the prefix; letters, numbers, underscores and colons only. Keys are case-sensitive but are checked for uniqueness case-insensitively, so 'custom:MyAttribute' and 'custom:myattribute' cannot both exist in one tailnet."
|
|
32653
|
+
),
|
|
32654
|
+
value: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()]).describe(
|
|
32655
|
+
"The attribute value: a string (max 50 characters, letters, numbers, underscores and periods only), an integer number (JSON-safe, up to 2^53-1), or a boolean. The type is fixed by the first value written for a key -- every device's value for that key must then be the same type."
|
|
32656
|
+
),
|
|
32451
32657
|
expiry: external_exports.string().optional().describe(
|
|
32452
32658
|
"Optional expiry time in RFC3339 format (e.g. '2026-12-01T00:00:00Z'). Attribute is automatically removed after expiry."
|
|
32453
|
-
)
|
|
32659
|
+
),
|
|
32660
|
+
comment: external_exports.string().max(200).optional().describe("Optional comment added to the audit log explaining why the attribute is being set (max 200 chars)")
|
|
32454
32661
|
}),
|
|
32455
32662
|
handler: async (input) => {
|
|
32456
32663
|
if (!input.attributeKey.startsWith("custom:")) {
|
|
@@ -32458,6 +32665,7 @@ var deviceTools = [
|
|
|
32458
32665
|
}
|
|
32459
32666
|
const body = { value: input.value };
|
|
32460
32667
|
if (input.expiry !== void 0) body.expiry = input.expiry;
|
|
32668
|
+
if (input.comment !== void 0) body.comment = input.comment;
|
|
32461
32669
|
return apiPost(`/device/${encPath(input.deviceId)}/attributes/${encPath(input.attributeKey)}`, body);
|
|
32462
32670
|
}
|
|
32463
32671
|
},
|
|
@@ -32472,7 +32680,7 @@ var deviceTools = [
|
|
|
32472
32680
|
openWorldHint: true
|
|
32473
32681
|
},
|
|
32474
32682
|
inputSchema: external_exports.object({
|
|
32475
|
-
deviceId: external_exports.string().describe(
|
|
32683
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32476
32684
|
attributeKey: external_exports.string().describe("The attribute key to delete (e.g. 'custom:lastAuditDate')")
|
|
32477
32685
|
}),
|
|
32478
32686
|
handler: async (input) => {
|
|
@@ -32495,7 +32703,7 @@ var deviceTools = [
|
|
|
32495
32703
|
openWorldHint: true
|
|
32496
32704
|
},
|
|
32497
32705
|
inputSchema: external_exports.object({
|
|
32498
|
-
deviceId: external_exports.string().describe(
|
|
32706
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32499
32707
|
tags: external_exports.array(external_exports.string()).describe("Full list of ACL tags (e.g. ['tag:server', 'tag:production']). Replaces all existing tags.")
|
|
32500
32708
|
}),
|
|
32501
32709
|
handler: async (input) => {
|
|
@@ -32514,7 +32722,7 @@ var deviceTools = [
|
|
|
32514
32722
|
openWorldHint: true
|
|
32515
32723
|
},
|
|
32516
32724
|
inputSchema: external_exports.object({
|
|
32517
|
-
deviceId: external_exports.string().describe(
|
|
32725
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32518
32726
|
ipv4: external_exports.ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
|
|
32519
32727
|
}),
|
|
32520
32728
|
handler: async (input) => {
|
|
@@ -32532,7 +32740,7 @@ var deviceTools = [
|
|
|
32532
32740
|
openWorldHint: true
|
|
32533
32741
|
},
|
|
32534
32742
|
inputSchema: external_exports.object({
|
|
32535
|
-
deviceId: external_exports.string().describe(
|
|
32743
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
32536
32744
|
keyExpiryDisabled: external_exports.boolean().describe("Whether to disable key expiry for this device")
|
|
32537
32745
|
}),
|
|
32538
32746
|
handler: async (input) => {
|
|
@@ -32554,7 +32762,7 @@ var deviceTools = [
|
|
|
32554
32762
|
openWorldHint: true
|
|
32555
32763
|
},
|
|
32556
32764
|
inputSchema: external_exports.object({
|
|
32557
|
-
deviceIds: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to update"),
|
|
32765
|
+
deviceIds: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to update (nodeIds preferred; legacy numeric ids also work)"),
|
|
32558
32766
|
authorized: external_exports.boolean().describe("true to authorize, false to deauthorize")
|
|
32559
32767
|
}),
|
|
32560
32768
|
handler: async (input) => {
|
|
@@ -32607,7 +32815,7 @@ var deviceTools = [
|
|
|
32607
32815
|
])
|
|
32608
32816
|
)
|
|
32609
32817
|
).describe(
|
|
32610
|
-
'Map of device ID to attribute config map (e.g. { "
|
|
32818
|
+
'Map of device ID to attribute config map (e.g. { "nPM2KNuedB21DEVEL": { "custom:compliant": { "value": "true" } }, "nPpz3VEKzX11DEVEL": { "custom:compliant": { "value": false, "expiry": "2026-12-01T00:00:00Z" } } }). Keys are device IDs, nodeIds preferred. Pass null as the config to delete an attribute.'
|
|
32611
32819
|
),
|
|
32612
32820
|
comment: external_exports.string().max(200).optional().describe("Optional comment added to the audit log explaining why attributes are being set (max 200 chars)")
|
|
32613
32821
|
}),
|
|
@@ -32649,7 +32857,7 @@ var dnsTools = [
|
|
|
32649
32857
|
},
|
|
32650
32858
|
{
|
|
32651
32859
|
name: "tailscale_set_nameservers",
|
|
32652
|
-
description: "Set the DNS nameservers for your tailnet. Replaces all existing nameservers.",
|
|
32860
|
+
description: "Set the DNS nameservers for your tailnet. Replaces all existing nameservers. Removing every nameserver may also change MagicDNS: the API reference says it is switched off, while Tailscale's current MagicDNS docs say a nameserver is no longer required -- check `magicDNS` in the response.",
|
|
32653
32861
|
annotations: {
|
|
32654
32862
|
title: "Set nameservers",
|
|
32655
32863
|
readOnlyHint: false,
|
|
@@ -32718,7 +32926,7 @@ var dnsTools = [
|
|
|
32718
32926
|
},
|
|
32719
32927
|
{
|
|
32720
32928
|
name: "tailscale_set_split_dns",
|
|
32721
|
-
description: "Set split DNS configuration. Maps domains to specific nameservers. Replaces the entire split DNS configuration.",
|
|
32929
|
+
description: "Set split DNS configuration. Maps domains to specific nameservers. Replaces the entire split DNS configuration: a domain you leave out is removed, and an empty object clears every domain. Per the API reference, setting a domain to null clears that domain's nameservers.",
|
|
32722
32930
|
annotations: {
|
|
32723
32931
|
title: "Set split DNS",
|
|
32724
32932
|
readOnlyHint: false,
|
|
@@ -32729,8 +32937,11 @@ var dnsTools = [
|
|
|
32729
32937
|
openWorldHint: true
|
|
32730
32938
|
},
|
|
32731
32939
|
inputSchema: external_exports.object({
|
|
32732
|
-
|
|
32733
|
-
|
|
32940
|
+
// `.nullable()` per the spec's SplitDns schema, which types each value as
|
|
32941
|
+
// an array OR null. Forwarded verbatim below, so null reaches the API as
|
|
32942
|
+
// JSON null rather than being pruned.
|
|
32943
|
+
splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).describe(
|
|
32944
|
+
'Map of domain to nameserver list, or to null to clear that domain (e.g. { "corp.example.com": ["10.0.0.1"], "old.example.com": null })'
|
|
32734
32945
|
)
|
|
32735
32946
|
}),
|
|
32736
32947
|
handler: async (input) => {
|
|
@@ -32754,7 +32965,7 @@ var dnsTools = [
|
|
|
32754
32965
|
},
|
|
32755
32966
|
{
|
|
32756
32967
|
name: "tailscale_set_dns_preferences",
|
|
32757
|
-
description: "Set DNS preferences for your tailnet, such as enabling or disabling MagicDNS.",
|
|
32968
|
+
description: "Set DNS preferences for your tailnet, such as enabling or disabling MagicDNS. The API reference says enabling can fail when the tailnet has no nameservers; if it does, add one with tailscale_set_nameservers first.",
|
|
32758
32969
|
annotations: {
|
|
32759
32970
|
title: "Set DNS preferences",
|
|
32760
32971
|
readOnlyHint: false,
|
|
@@ -32773,7 +32984,7 @@ var dnsTools = [
|
|
|
32773
32984
|
},
|
|
32774
32985
|
{
|
|
32775
32986
|
name: "tailscale_update_split_dns",
|
|
32776
|
-
description: "Partially update split DNS configuration. Merges the provided domains with the existing config
|
|
32987
|
+
description: "Partially update split DNS configuration. Merges the provided domains with the existing config -- only the specified domains are changed, others are untouched. To remove a domain, set it to null: that is the idiom the API reference documents. An empty array is also accepted and forwarded as-is -- it is what Tailscale's Terraform provider sends.",
|
|
32777
32988
|
annotations: {
|
|
32778
32989
|
title: "Update split DNS (partial)",
|
|
32779
32990
|
readOnlyHint: false,
|
|
@@ -32782,8 +32993,11 @@ var dnsTools = [
|
|
|
32782
32993
|
openWorldHint: true
|
|
32783
32994
|
},
|
|
32784
32995
|
inputSchema: external_exports.object({
|
|
32785
|
-
|
|
32786
|
-
|
|
32996
|
+
// Nullable for the same reason as the PUT sibling above: the spec's
|
|
32997
|
+
// SplitDns body types each value as an array OR null, and null is how it
|
|
32998
|
+
// documents clearing a domain.
|
|
32999
|
+
splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string()).nullable()).describe(
|
|
33000
|
+
'Map of domain to nameserver list to merge, or to null to remove that domain (e.g. { "new.example.com": ["10.0.0.3"], "old.example.com": null }). Only specified domains are changed.'
|
|
32787
33001
|
)
|
|
32788
33002
|
}),
|
|
32789
33003
|
handler: async (input) => {
|
|
@@ -32850,7 +33064,7 @@ var inviteTools = [
|
|
|
32850
33064
|
openWorldHint: true
|
|
32851
33065
|
},
|
|
32852
33066
|
inputSchema: external_exports.object({
|
|
32853
|
-
deviceId: external_exports.string().describe(
|
|
33067
|
+
deviceId: external_exports.string().describe(`The device ID to list invites for. ${DEVICE_ID_HINT}`)
|
|
32854
33068
|
}),
|
|
32855
33069
|
handler: async (input) => {
|
|
32856
33070
|
return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
|
|
@@ -32867,7 +33081,7 @@ var inviteTools = [
|
|
|
32867
33081
|
openWorldHint: true
|
|
32868
33082
|
},
|
|
32869
33083
|
inputSchema: external_exports.object({
|
|
32870
|
-
deviceId: external_exports.string().describe(
|
|
33084
|
+
deviceId: external_exports.string().describe(`The device ID to create an invite for. ${DEVICE_ID_HINT}`),
|
|
32871
33085
|
multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
32872
33086
|
allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
|
|
32873
33087
|
email: external_exports.email().optional().describe("Email address to send the invite to")
|
|
@@ -32934,7 +33148,7 @@ var inviteTools = [
|
|
|
32934
33148
|
// --- User Invites ---
|
|
32935
33149
|
{
|
|
32936
33150
|
name: "tailscale_list_user_invites",
|
|
32937
|
-
description: "List
|
|
33151
|
+
description: "List the open (not yet accepted) user invites for your tailnet. Accepted invites are not returned.",
|
|
32938
33152
|
annotations: {
|
|
32939
33153
|
title: "List user invites",
|
|
32940
33154
|
readOnlyHint: true,
|
|
@@ -33044,7 +33258,7 @@ var inviteTools = [
|
|
|
33044
33258
|
var keyTools = [
|
|
33045
33259
|
{
|
|
33046
33260
|
name: "tailscale_list_keys",
|
|
33047
|
-
description: "List keys in your tailnet.
|
|
33261
|
+
description: "List keys in your tailnet: auth keys, API access tokens, OAuth clients and federated identities. Without 'all', what comes back depends on the credential this server runs on -- a user-owned API key sees only that user's keys (including the API access token the server itself is using, keyType 'api'); an OAuth-client token sees the tailnet's OAuth clients; a federated-identity token sees its federated identities. Set 'all' to true for the tailnet-wide list (needs the matching :read scopes; only 'all:read' and 'all' return every API access token).",
|
|
33048
33262
|
annotations: {
|
|
33049
33263
|
title: "List keys",
|
|
33050
33264
|
readOnlyHint: true,
|
|
@@ -33053,7 +33267,7 @@ var keyTools = [
|
|
|
33053
33267
|
openWorldHint: true
|
|
33054
33268
|
},
|
|
33055
33269
|
inputSchema: external_exports.object({
|
|
33056
|
-
all: external_exports.boolean().optional().describe("When true,
|
|
33270
|
+
all: external_exports.boolean().optional().describe("When true, list keys tailnet-wide instead of the credential-dependent default set. Default: false")
|
|
33057
33271
|
}),
|
|
33058
33272
|
handler: async (input) => {
|
|
33059
33273
|
const qs = input.all ? "?all=true" : "";
|
|
@@ -33062,7 +33276,7 @@ var keyTools = [
|
|
|
33062
33276
|
},
|
|
33063
33277
|
{
|
|
33064
33278
|
name: "tailscale_get_key",
|
|
33065
|
-
description: "Get details for a specific key (auth key, OAuth client, or federated identity).",
|
|
33279
|
+
description: "Get details for a specific key (auth key, API access token, OAuth client, or federated identity). A revoked or expired key is still returned, with `invalid: true`.",
|
|
33066
33280
|
annotations: {
|
|
33067
33281
|
title: "Get key",
|
|
33068
33282
|
readOnlyHint: true,
|
|
@@ -33071,7 +33285,7 @@ var keyTools = [
|
|
|
33071
33285
|
openWorldHint: true
|
|
33072
33286
|
},
|
|
33073
33287
|
inputSchema: external_exports.object({
|
|
33074
|
-
keyId: external_exports.string().describe("The key ID (auth key, OAuth client, or federated identity)")
|
|
33288
|
+
keyId: external_exports.string().describe("The key ID (auth key, API access token, OAuth client, or federated identity)")
|
|
33075
33289
|
}),
|
|
33076
33290
|
handler: async (input) => {
|
|
33077
33291
|
return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33079,7 +33293,7 @@ var keyTools = [
|
|
|
33079
33293
|
},
|
|
33080
33294
|
{
|
|
33081
33295
|
name: "tailscale_create_key",
|
|
33082
|
-
description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value
|
|
33296
|
+
description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value -- save it immediately, as it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:core:read','dns:read']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:core:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
|
|
33083
33297
|
annotations: {
|
|
33084
33298
|
title: "Create key",
|
|
33085
33299
|
readOnlyHint: false,
|
|
@@ -33102,7 +33316,9 @@ var keyTools = [
|
|
|
33102
33316
|
"ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
|
|
33103
33317
|
),
|
|
33104
33318
|
// Client + Federated fields
|
|
33105
|
-
scopes: external_exports.array(external_exports.string()).optional().describe(
|
|
33319
|
+
scopes: external_exports.array(external_exports.string()).optional().describe(
|
|
33320
|
+
"(client/federated) OAuth scopes to grant (e.g. ['devices:core:read', 'dns:read']). Use the current scope names listed at https://tailscale.com/kb/1623/trust-credentials#scopes; the pre-2024 names such as 'devices:read' and 'acl' are legacy."
|
|
33321
|
+
),
|
|
33106
33322
|
// Federated-only fields
|
|
33107
33323
|
issuer: external_exports.string().optional().describe("(federated only) OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com')"),
|
|
33108
33324
|
subject: external_exports.string().optional().describe("(federated only) Expected subject claim, supports * wildcards"),
|
|
@@ -33165,7 +33381,7 @@ var keyTools = [
|
|
|
33165
33381
|
},
|
|
33166
33382
|
{
|
|
33167
33383
|
name: "tailscale_delete_key",
|
|
33168
|
-
description: "Delete a key (auth key, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately.",
|
|
33384
|
+
description: "Delete a key (auth key, API access token, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately. API access tokens are deletable here too: if keyId is the token this server authenticates with -- it shows up in tailscale_list_keys under API-key auth -- the server revokes its own credential and every later call fails with 401 until it is reconfigured.",
|
|
33169
33385
|
annotations: {
|
|
33170
33386
|
title: "Delete key",
|
|
33171
33387
|
readOnlyHint: false,
|
|
@@ -33174,7 +33390,7 @@ var keyTools = [
|
|
|
33174
33390
|
openWorldHint: true
|
|
33175
33391
|
},
|
|
33176
33392
|
inputSchema: external_exports.object({
|
|
33177
|
-
keyId: external_exports.string().describe("The key ID to delete (auth key, OAuth client, or federated identity)")
|
|
33393
|
+
keyId: external_exports.string().describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
|
|
33178
33394
|
}),
|
|
33179
33395
|
handler: async (input) => {
|
|
33180
33396
|
return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33225,12 +33441,15 @@ var keyTools = [
|
|
|
33225
33441
|
// tailscale_create_key above: an OAuth client is a machine credential you
|
|
33226
33442
|
// hold, whereas an OAuth App is a three-legged authorization-code app that
|
|
33227
33443
|
// lets a THIRD PARTY enroll one device into your tailnet after a user
|
|
33228
|
-
// consents.
|
|
33229
|
-
// mints exactly one auth key per authorization
|
|
33230
|
-
// -- re-authorization is required per device by
|
|
33444
|
+
// consents. Tailscale's device-provisioning guide documents one scope,
|
|
33445
|
+
// `auth_keys:create:once`, which mints exactly one auth key per authorization
|
|
33446
|
+
// and returns no refresh token -- re-authorization is required per device by
|
|
33447
|
+
// design. The API reference's own example shows the bare `auth_keys:create`
|
|
33448
|
+
// instead, and the schema restricts neither, so the description names both
|
|
33449
|
+
// sources rather than picking one.
|
|
33231
33450
|
{
|
|
33232
33451
|
name: "tailscale_create_oauth_app",
|
|
33233
|
-
description: "Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\
|
|
33452
|
+
description: "Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nUse scope 'auth_keys:create:once' (one auth key per authorization, no refresh token) -- the scope Tailscale's device-provisioning guide documents. The API reference's example shows 'auth_keys:create'; this tool does not restrict the value. Distinct from tailscale_create_key with keyType='client', which mints a machine-to-machine OAuth client instead.",
|
|
33234
33453
|
annotations: {
|
|
33235
33454
|
title: "Create OAuth app",
|
|
33236
33455
|
readOnlyHint: false,
|
|
@@ -33241,7 +33460,9 @@ var keyTools = [
|
|
|
33241
33460
|
inputSchema: external_exports.object({
|
|
33242
33461
|
name: external_exports.string().min(1).describe("Human-readable name for the OAuth app, shown on the consent screen"),
|
|
33243
33462
|
redirectUris: external_exports.array(external_exports.url()).min(1).describe("Allowed redirect URIs for the authorization-code flow (e.g. ['https://example.com/callback'])"),
|
|
33244
|
-
scopes: external_exports.array(external_exports.string()).min(1).describe(
|
|
33463
|
+
scopes: external_exports.array(external_exports.string()).min(1).describe(
|
|
33464
|
+
"Scopes to grant. Use 'auth_keys:create:once', the scope the device-provisioning guide documents; the API reference's example shows 'auth_keys:create'. Not restricted here."
|
|
33465
|
+
),
|
|
33245
33466
|
allowedNodeAttributes: external_exports.array(external_exports.string()).optional().describe("Optional node attributes the app may request when provisioning a device")
|
|
33246
33467
|
}),
|
|
33247
33468
|
handler: async (input) => {
|
|
@@ -33320,14 +33541,57 @@ import * as net2 from "node:net";
|
|
|
33320
33541
|
|
|
33321
33542
|
// src/local-cli.ts
|
|
33322
33543
|
import { execFile as execFileCb } from "node:child_process";
|
|
33544
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
33323
33545
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
33324
33546
|
var MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
33547
|
+
var DARWIN_CANDIDATES = [
|
|
33548
|
+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
|
33549
|
+
"/opt/homebrew/bin/tailscale",
|
|
33550
|
+
"/usr/local/bin/tailscale"
|
|
33551
|
+
];
|
|
33552
|
+
var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
|
|
33325
33553
|
var execFileImpl = execFileCb;
|
|
33326
|
-
function
|
|
33327
|
-
|
|
33554
|
+
function binaryCandidates(platform) {
|
|
33555
|
+
if (platform === "darwin") return DARWIN_CANDIDATES;
|
|
33556
|
+
if (platform === "linux") return LINUX_CANDIDATES;
|
|
33557
|
+
return [];
|
|
33558
|
+
}
|
|
33559
|
+
function resolveBinary(platform = process.platform, exists = existsSync) {
|
|
33560
|
+
const override = process.env.TAILSCALE_BINARY;
|
|
33561
|
+
if (override) return override;
|
|
33562
|
+
for (const candidate of binaryCandidates(platform)) {
|
|
33563
|
+
if (exists(candidate)) return candidate;
|
|
33564
|
+
}
|
|
33565
|
+
return "tailscale";
|
|
33566
|
+
}
|
|
33567
|
+
function looksLikeWsl(platform, procVersion = readProcVersion) {
|
|
33568
|
+
if (platform !== "linux") return false;
|
|
33569
|
+
if (process.env.WSL_DISTRO_NAME) return true;
|
|
33570
|
+
return /microsoft/i.test(procVersion());
|
|
33571
|
+
}
|
|
33572
|
+
function readProcVersion() {
|
|
33573
|
+
try {
|
|
33574
|
+
return readFileSync2("/proc/version", "utf-8");
|
|
33575
|
+
} catch {
|
|
33576
|
+
return "";
|
|
33577
|
+
}
|
|
33578
|
+
}
|
|
33579
|
+
function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
33580
|
+
if (fromEnv) {
|
|
33581
|
+
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 -- not a directory, and not a shell alias or function.`;
|
|
33582
|
+
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}`;
|
|
33583
|
+
}
|
|
33584
|
+
if (platform === "darwin") {
|
|
33585
|
+
return `Could not find the 'tailscale' binary in PATH, or at ${DARWIN_CANDIDATES.join(", ")}. A default macOS install keeps the CLI inside the app bundle and puts nothing on PATH, and an MCP client launched from the Dock or Spotlight sees a minimal PATH rather than your shell's. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path, usually /Applications/Tailscale.app/Contents/MacOS/Tailscale.`;
|
|
33586
|
+
}
|
|
33587
|
+
if (wsl) {
|
|
33588
|
+
return `Could not find the 'tailscale' binary in PATH, or at ${LINUX_CANDIDATES.join(", ")}. This looks like WSL, where the only tailscale in reach is usually the Windows one: a Linux process cannot exec tailscale.exe, and pointing TAILSCALE_BINARY at it would report the WINDOWS host's tailnet rather than this machine's, which is what these tools describe. Install Tailscale inside the distro (https://tailscale.com/download/linux) and run tailscaled there.`;
|
|
33589
|
+
}
|
|
33590
|
+
return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
|
|
33328
33591
|
}
|
|
33329
33592
|
async function runTailscaleCli(args, options = {}) {
|
|
33330
|
-
const binary =
|
|
33593
|
+
const binary = resolveBinary();
|
|
33594
|
+
const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
|
|
33331
33595
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33332
33596
|
return new Promise((resolve) => {
|
|
33333
33597
|
execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
|
|
@@ -33338,7 +33602,7 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33338
33602
|
if (errno.code === "ENOENT") {
|
|
33339
33603
|
resolve({
|
|
33340
33604
|
ok: false,
|
|
33341
|
-
error:
|
|
33605
|
+
error: describeMissingBinary(binary, fromEnv, process.platform, looksLikeWsl(process.platform))
|
|
33342
33606
|
});
|
|
33343
33607
|
return;
|
|
33344
33608
|
}
|
|
@@ -33394,7 +33658,7 @@ function isValidPingTarget(s) {
|
|
|
33394
33658
|
var localCliTools = [
|
|
33395
33659
|
{
|
|
33396
33660
|
name: "tailscale_local_status",
|
|
33397
|
-
description: "Get this machine's view of its tailnet -- own connection state, peers it can see, DERP region, MagicDNS suffix, etc. Shells out to the local `tailscale` binary; distinct from `tailscale_status`, which queries the admin API for tailnet-wide info. Requires the tailscale CLI installed locally and TAILSCALE_LOCAL_CLI=1.",
|
|
33661
|
+
description: "Get this machine's view of its tailnet -- own connection state, peers it can see, DERP region, MagicDNS suffix, etc. Shells out to the local `tailscale` binary; distinct from `tailscale_status`, which queries the admin API for tailnet-wide info. Read a peer's path in decision order: direct when `CurAddr` is set, peer-relayed when `PeerRelay` is set, otherwise DERP via the region named in `Relay`. `Relay` is the peer's home DERP region and is populated either way, so a non-empty `Relay` on its own does not mean the traffic is relayed. The `Peer` map is what makes this response scale with the tailnet rather than with the request; `peers` and `activeOnly` narrow it. Requires the tailscale CLI installed locally and TAILSCALE_LOCAL_CLI=1.",
|
|
33398
33662
|
annotations: {
|
|
33399
33663
|
title: "Local tailscale status",
|
|
33400
33664
|
readOnlyHint: true,
|
|
@@ -33402,8 +33666,30 @@ var localCliTools = [
|
|
|
33402
33666
|
idempotentHint: true,
|
|
33403
33667
|
openWorldHint: true
|
|
33404
33668
|
},
|
|
33405
|
-
inputSchema: external_exports.object({
|
|
33406
|
-
|
|
33669
|
+
inputSchema: external_exports.object({
|
|
33670
|
+
peers: external_exports.boolean().optional().describe(
|
|
33671
|
+
"Set false to omit the `Peer` map (`--peers=false`), leaving this node's own state -- `Self`, `BackendState`, `Health`, `CurrentTailnet` and the rest of the top level. Omit it for the CLI's default, which includes peers."
|
|
33672
|
+
),
|
|
33673
|
+
activeOnly: external_exports.boolean().optional().describe(
|
|
33674
|
+
"Set true to keep only peers with an active session (`--active`) -- upstream defines that as a packet sent to the peer in roughly the last two minutes. Omit it to get every peer."
|
|
33675
|
+
)
|
|
33676
|
+
}),
|
|
33677
|
+
handler: async (input = {}) => {
|
|
33678
|
+
const args = ["status", "--json"];
|
|
33679
|
+
if (input.peers === false) args.push("--peers=false");
|
|
33680
|
+
if (input.activeOnly) args.push("--active");
|
|
33681
|
+
const result = await runTailscaleCli(args, { parseJson: true });
|
|
33682
|
+
if (!result.ok && result.error?.includes("output limit")) {
|
|
33683
|
+
return { ...result, error: `${result.error} Retry with peers:false or activeOnly:true.` };
|
|
33684
|
+
}
|
|
33685
|
+
if (!result.ok && result.error?.includes("timed out after")) {
|
|
33686
|
+
return {
|
|
33687
|
+
...result,
|
|
33688
|
+
error: `${result.error}. On a large tailnet this is usually the peer map -- retry with peers:false.`
|
|
33689
|
+
};
|
|
33690
|
+
}
|
|
33691
|
+
return result;
|
|
33692
|
+
}
|
|
33407
33693
|
},
|
|
33408
33694
|
{
|
|
33409
33695
|
name: "tailscale_ping",
|
|
@@ -33667,17 +33953,30 @@ var logStreamingTools = [
|
|
|
33667
33953
|
},
|
|
33668
33954
|
{
|
|
33669
33955
|
name: "tailscale_create_aws_external_id",
|
|
33670
|
-
description: "Create or get
|
|
33956
|
+
description: "Create or get the AWS external ID Tailscale presents when assuming your IAM role for S3 log streaming. Put it in the role trust policy's sts:ExternalId condition, then check it with tailscale_validate_aws_trust_policy.",
|
|
33671
33957
|
annotations: {
|
|
33672
33958
|
title: "Create AWS external ID",
|
|
33673
33959
|
readOnlyHint: false,
|
|
33674
33960
|
destructiveHint: false,
|
|
33675
|
-
|
|
33961
|
+
// Not idempotent: a static hint has to hold for every accepted input, and
|
|
33962
|
+
// reusable:false mints a distinct ID by design. Even reusable:true mints
|
|
33963
|
+
// a new one once the previous ID has been linked to an AWS account.
|
|
33964
|
+
idempotentHint: false,
|
|
33676
33965
|
openWorldHint: true
|
|
33677
33966
|
},
|
|
33678
|
-
inputSchema: external_exports.object({
|
|
33679
|
-
|
|
33680
|
-
|
|
33967
|
+
inputSchema: external_exports.object({
|
|
33968
|
+
reusable: external_exports.boolean().optional().describe(
|
|
33969
|
+
"Default true: Tailscale returns the SAME external ID on repeat calls until that ID has been linked to an AWS account, so asking again does not invalidate the ID already pasted into an IAM trust policy. Set false to force a fresh ID (what Tailscale's Terraform provider does, one ID per resource)."
|
|
33970
|
+
)
|
|
33971
|
+
}),
|
|
33972
|
+
// The flag was previously never sent at all, so the server applied whatever
|
|
33973
|
+
// default it applies to an absent body -- undocumented either way. It now
|
|
33974
|
+
// goes on the wire explicitly, as the Go client always does. The default
|
|
33975
|
+
// lives here rather than in a Zod `.default()` because handlers are called
|
|
33976
|
+
// with the client's raw input, and `??` rather than `||` because false is a
|
|
33977
|
+
// meaningful value.
|
|
33978
|
+
handler: async (input) => {
|
|
33979
|
+
return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
|
|
33681
33980
|
}
|
|
33682
33981
|
},
|
|
33683
33982
|
{
|
|
@@ -33776,14 +34075,16 @@ var postureTools = [
|
|
|
33776
34075
|
"The posture provider slug: falcon (CrowdStrike Falcon), fleet, huntress, intune (Microsoft Intune), jamfpro (Jamf Pro), kandji (Iru, formerly Kandji), kolide (1Password XAM, formerly Kolide), sentinelone"
|
|
33777
34076
|
),
|
|
33778
34077
|
clientId: external_exports.string().optional().describe(
|
|
33779
|
-
"Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id;
|
|
34078
|
+
"Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank). Fleet and Huntress: Tailscale does not document how their credentials map onto these API fields -- the admin console asks for a Fleet URL + API token (Fleet) and an API key + API secret, plus an optional organization ID (Huntress). Do not assume this can be left blank; confirm the mapping first."
|
|
33780
34079
|
),
|
|
33781
34080
|
clientSecret: external_exports.string().describe(
|
|
33782
34081
|
"The secret (auth key, token, etc.) used to authenticate with the provider. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
33783
34082
|
),
|
|
33784
|
-
tenantId: external_exports.string().optional().describe(
|
|
34083
|
+
tenantId: external_exports.string().optional().describe(
|
|
34084
|
+
"Microsoft Intune directory (tenant) ID. Other providers leave blank. Fleet/Huntress: undocumented upstream (see clientId)."
|
|
34085
|
+
),
|
|
33785
34086
|
cloudId: external_exports.string().optional().describe(
|
|
33786
|
-
"Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank."
|
|
34087
|
+
"Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank. Fleet/Huntress: undocumented upstream (see clientId)."
|
|
33787
34088
|
)
|
|
33788
34089
|
}),
|
|
33789
34090
|
handler: async (input) => {
|
|
@@ -33961,7 +34262,7 @@ var serviceTools = [
|
|
|
33961
34262
|
},
|
|
33962
34263
|
inputSchema: external_exports.object({
|
|
33963
34264
|
serviceName: external_exports.string().describe("The service name"),
|
|
33964
|
-
deviceId: external_exports.string().describe(
|
|
34265
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`)
|
|
33965
34266
|
}),
|
|
33966
34267
|
handler: async (input) => {
|
|
33967
34268
|
return apiGet(
|
|
@@ -33981,7 +34282,7 @@ var serviceTools = [
|
|
|
33981
34282
|
},
|
|
33982
34283
|
inputSchema: external_exports.object({
|
|
33983
34284
|
serviceName: external_exports.string().describe("The service name"),
|
|
33984
|
-
deviceId: external_exports.string().describe(
|
|
34285
|
+
deviceId: external_exports.string().describe(`The device ID. ${DEVICE_ID_HINT}`),
|
|
33985
34286
|
approved: external_exports.boolean().describe("Whether to approve (true) or reject (false) the device")
|
|
33986
34287
|
}),
|
|
33987
34288
|
handler: async (input) => {
|
|
@@ -34202,17 +34503,24 @@ var tailnetsTools = [
|
|
|
34202
34503
|
},
|
|
34203
34504
|
{
|
|
34204
34505
|
name: "tailscale_create_org_tailnet",
|
|
34205
|
-
description: "Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.\n\nSECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nRequires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.",
|
|
34506
|
+
description: "Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.\n\nSECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.\n\nRequires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.\n\nOrganizations are limited to 10 tailnets including the original unless Tailscale sales has raised the limit.\n\nThe response may include `alreadyExists: true`; Tailscale's spec ALSO documents a 400 for a name already in use and neither has been observed, so after a timeout call tailscale_list_org_tailnets before retrying rather than assuming either.",
|
|
34206
34507
|
annotations: {
|
|
34207
34508
|
title: "Create organization tailnet",
|
|
34208
34509
|
readOnlyHint: false,
|
|
34209
34510
|
destructiveHint: false,
|
|
34210
|
-
//
|
|
34511
|
+
// No idempotency key. The spec's `alreadyExists` field hints that a
|
|
34512
|
+
// duplicate displayName may return the existing tailnet, but the same
|
|
34513
|
+
// spec documents a 400 for that case and neither is observed --
|
|
34514
|
+
// idempotentHint stays false until one is.
|
|
34211
34515
|
idempotentHint: false,
|
|
34212
34516
|
openWorldHint: true
|
|
34213
34517
|
},
|
|
34214
34518
|
inputSchema: external_exports.object({
|
|
34215
|
-
|
|
34519
|
+
// No charset regex: the rule below is Tailscale's and Tailscale enforces
|
|
34520
|
+
// it, so a local copy would only drift out of step with its own error.
|
|
34521
|
+
displayName: external_exports.string().trim().min(1).describe(
|
|
34522
|
+
"Human-readable name for the new tailnet. May contain letters, numbers, spaces, apostrophes and hyphens, and must be unique within the organization."
|
|
34523
|
+
),
|
|
34216
34524
|
organization: organizationSchema
|
|
34217
34525
|
}),
|
|
34218
34526
|
handler: async (input) => {
|
|
@@ -34413,11 +34721,12 @@ var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
|
34413
34721
|
"subnetIPForwardingNotEnabled",
|
|
34414
34722
|
"exitNodeIPForwardingNotEnabled"
|
|
34415
34723
|
];
|
|
34724
|
+
var WEBHOOK_CATEGORY_SUBSCRIPTIONS = ["categoryTailnetManagement", "categoryDeviceMisconfigurations"];
|
|
34416
34725
|
function getAllowedWebhookEvents() {
|
|
34417
34726
|
const raw = process.env.TAILSCALE_EXTRA_WEBHOOK_EVENTS;
|
|
34418
|
-
if (!raw) return new Set(STATIC_WEBHOOK_EVENT_TYPES);
|
|
34727
|
+
if (!raw) return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS]);
|
|
34419
34728
|
const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
34420
|
-
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
|
|
34729
|
+
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
|
|
34421
34730
|
}
|
|
34422
34731
|
var endpointUrlSchema = external_exports.url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://");
|
|
34423
34732
|
var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
|
|
@@ -34474,7 +34783,7 @@ var webhookTools = [
|
|
|
34474
34783
|
},
|
|
34475
34784
|
{
|
|
34476
34785
|
name: "tailscale_create_webhook",
|
|
34477
|
-
description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately.\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.",
|
|
34786
|
+
description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately. Set providerType when the endpoint is a Slack, Mattermost, Google Chat or Discord incoming-webhook URL, so the events arrive in the format that provider renders.\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.",
|
|
34478
34787
|
annotations: {
|
|
34479
34788
|
title: "Create webhook",
|
|
34480
34789
|
readOnlyHint: false,
|
|
@@ -34484,13 +34793,20 @@ var webhookTools = [
|
|
|
34484
34793
|
},
|
|
34485
34794
|
inputSchema: external_exports.object({
|
|
34486
34795
|
endpointUrl: endpointUrlSchema.describe("The HTTPS URL to send webhook events to"),
|
|
34487
|
-
|
|
34796
|
+
providerType: external_exports.enum(["slack", "mattermost", "googlechat", "discord"]).optional().describe(
|
|
34797
|
+
"Format deliveries for a chat provider's incoming-webhook URL. Omit for raw Tailscale JSON -- the default, and what a custom receiver verifying signatures wants. Set once: it cannot be changed after creation."
|
|
34798
|
+
),
|
|
34799
|
+
subscriptions: webhookSubscriptionsSchema.describe(
|
|
34800
|
+
"Event types to subscribe to (at least one). 'categoryTailnetManagement' and 'categoryDeviceMisconfigurations' subscribe to a whole category, including events Tailscale adds to it later."
|
|
34801
|
+
)
|
|
34488
34802
|
}),
|
|
34489
34803
|
handler: async (input) => {
|
|
34490
|
-
|
|
34804
|
+
const body = {
|
|
34491
34805
|
endpointUrl: input.endpointUrl,
|
|
34492
34806
|
subscriptions: input.subscriptions
|
|
34493
|
-
}
|
|
34807
|
+
};
|
|
34808
|
+
if (input.providerType !== void 0) body.providerType = input.providerType;
|
|
34809
|
+
return apiPost(`/tailnet/${getTailnet()}/webhooks`, body);
|
|
34494
34810
|
}
|
|
34495
34811
|
},
|
|
34496
34812
|
{
|
|
@@ -34506,7 +34822,9 @@ var webhookTools = [
|
|
|
34506
34822
|
inputSchema: external_exports.object({
|
|
34507
34823
|
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
34508
34824
|
endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
|
|
34509
|
-
subscriptions: webhookSubscriptionsSchema.optional().describe(
|
|
34825
|
+
subscriptions: webhookSubscriptionsSchema.optional().describe(
|
|
34826
|
+
"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."
|
|
34827
|
+
)
|
|
34510
34828
|
}),
|
|
34511
34829
|
handler: async (input) => {
|
|
34512
34830
|
const body = {};
|
|
@@ -34601,7 +34919,13 @@ var LARGE_RESULT_TOOLS = [
|
|
|
34601
34919
|
"tailscale_get_audit_log",
|
|
34602
34920
|
"tailscale_get_network_flow_logs",
|
|
34603
34921
|
"tailscale_list_devices",
|
|
34604
|
-
"tailscale_list_users"
|
|
34922
|
+
"tailscale_list_users",
|
|
34923
|
+
// The one opt-in entry: it registers only under TAILSCALE_LOCAL_CLI=1, so a
|
|
34924
|
+
// test comparing this list against a session's `_meta` has to filter it to
|
|
34925
|
+
// the names that session registered. Its payload is the local client's whole
|
|
34926
|
+
// peer map, which scales with the tailnet exactly as the admin reads above
|
|
34927
|
+
// do -- the tool takes `peers` and `activeOnly` for callers who want less.
|
|
34928
|
+
"tailscale_local_status"
|
|
34605
34929
|
];
|
|
34606
34930
|
function buildToolMeta(toolName, options) {
|
|
34607
34931
|
const meta3 = {};
|
|
@@ -34882,7 +35206,26 @@ function buildMetaTools(state) {
|
|
|
34882
35206
|
}
|
|
34883
35207
|
|
|
34884
35208
|
// src/index.ts
|
|
34885
|
-
var
|
|
35209
|
+
var NODE_MIN = [20, 11, 0];
|
|
35210
|
+
function belowNodeFloor(reported) {
|
|
35211
|
+
const found = /(\d+)\.(\d+)\.(\d+)/.exec(reported);
|
|
35212
|
+
if (!found) return false;
|
|
35213
|
+
for (const [i, min] of NODE_MIN.entries()) {
|
|
35214
|
+
const part = Number(found[i + 1]);
|
|
35215
|
+
if (part > min) return false;
|
|
35216
|
+
if (part < min) return true;
|
|
35217
|
+
}
|
|
35218
|
+
return false;
|
|
35219
|
+
}
|
|
35220
|
+
if (process.versions.oam === void 0 && belowNodeFloor(process.versions.node)) {
|
|
35221
|
+
process.stderr.write(
|
|
35222
|
+
`tailscale-mcp: needs Node ${NODE_MIN.join(".")} or newer, found ${process.versions.node}.
|
|
35223
|
+
Install a newer Node (https://nodejs.org/en/download), or point your MCP client's "command" at one.
|
|
35224
|
+
`
|
|
35225
|
+
);
|
|
35226
|
+
process.exit(1);
|
|
35227
|
+
}
|
|
35228
|
+
var version2 = true ? "0.21.0" : resolveVersionFallback();
|
|
34886
35229
|
var subcommand = process.argv[2];
|
|
34887
35230
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
34888
35231
|
|