@yawlabs/tailscale-mcp 0.21.1 → 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 +14 -8
- package/bin/tailscale-mcp.mjs +218 -46
- package/dist/index.js +515 -245
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -4148,13 +4148,14 @@ var require_fast_uri = __commonJS({
|
|
|
4148
4148
|
if (!malformedIPLiteral) {
|
|
4149
4149
|
malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP2);
|
|
4150
4150
|
}
|
|
4151
|
-
if (
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
parsed.host = reescapeHostDelimiters(host, isIP2);
|
|
4156
|
-
}
|
|
4151
|
+
if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) {
|
|
4152
|
+
let host = isIP2 ? parsed.host : normalizePercentEncoding(parsed.host, true);
|
|
4153
|
+
if (!isIP2) {
|
|
4154
|
+
host = normalizePercentEncoding(host.toLowerCase());
|
|
4157
4155
|
}
|
|
4156
|
+
parsed.host = reescapeHostDelimiters(host, isIP2);
|
|
4157
|
+
}
|
|
4158
|
+
if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
|
|
4158
4159
|
if (parsed.path) {
|
|
4159
4160
|
parsed.path = normalizePathEncoding(parsed.path);
|
|
4160
4161
|
}
|
|
@@ -28700,6 +28701,9 @@ var Protocol = class {
|
|
|
28700
28701
|
this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
|
|
28701
28702
|
const handleTaskResult = async () => {
|
|
28702
28703
|
const taskId = request.params.taskId;
|
|
28704
|
+
if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
|
|
28705
|
+
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
|
28706
|
+
}
|
|
28703
28707
|
if (this._taskMessageQueue) {
|
|
28704
28708
|
let queuedMessage;
|
|
28705
28709
|
while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
|
|
@@ -28730,12 +28734,12 @@ var Protocol = class {
|
|
|
28730
28734
|
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
|
28731
28735
|
}
|
|
28732
28736
|
if (!isTerminal(task.status)) {
|
|
28733
|
-
await this._waitForTaskUpdate(taskId, extra.signal);
|
|
28737
|
+
await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
|
|
28734
28738
|
return await handleTaskResult();
|
|
28735
28739
|
}
|
|
28736
28740
|
if (isTerminal(task.status)) {
|
|
28737
28741
|
const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
|
|
28738
|
-
this._clearTaskQueue(taskId);
|
|
28742
|
+
this._clearTaskQueue(taskId, extra.sessionId);
|
|
28739
28743
|
return {
|
|
28740
28744
|
...result,
|
|
28741
28745
|
_meta: {
|
|
@@ -28772,7 +28776,7 @@ var Protocol = class {
|
|
|
28772
28776
|
throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
|
|
28773
28777
|
}
|
|
28774
28778
|
await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
|
|
28775
|
-
this._clearTaskQueue(request.params.taskId);
|
|
28779
|
+
this._clearTaskQueue(request.params.taskId, extra.sessionId);
|
|
28776
28780
|
const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
|
|
28777
28781
|
if (!cancelledTask) {
|
|
28778
28782
|
throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
|
|
@@ -28900,6 +28904,19 @@ var Protocol = class {
|
|
|
28900
28904
|
const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
|
|
28901
28905
|
const capturedTransport = this._transport;
|
|
28902
28906
|
const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
|
|
28907
|
+
const sessionId = capturedTransport?.sessionId;
|
|
28908
|
+
const store = this._taskStore;
|
|
28909
|
+
let relatedTaskFound = true;
|
|
28910
|
+
let relatedTaskLookup;
|
|
28911
|
+
if (relatedTaskId && store && this._taskMessageQueue && sessionId !== void 0) {
|
|
28912
|
+
relatedTaskFound = false;
|
|
28913
|
+
relatedTaskLookup = (async () => {
|
|
28914
|
+
if (!await store.getTask(relatedTaskId, sessionId)) {
|
|
28915
|
+
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
|
|
28916
|
+
}
|
|
28917
|
+
relatedTaskFound = true;
|
|
28918
|
+
})();
|
|
28919
|
+
}
|
|
28903
28920
|
if (handler === void 0) {
|
|
28904
28921
|
const errorResponse = {
|
|
28905
28922
|
jsonrpc: "2.0",
|
|
@@ -28909,7 +28926,10 @@ var Protocol = class {
|
|
|
28909
28926
|
message: "Method not found"
|
|
28910
28927
|
}
|
|
28911
28928
|
};
|
|
28912
|
-
if (relatedTaskId &&
|
|
28929
|
+
if (relatedTaskId && relatedTaskLookup) {
|
|
28930
|
+
const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
|
|
28931
|
+
relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error51) => this._onerror(new Error(`Failed to send an error response: ${error51}`)));
|
|
28932
|
+
} else if (relatedTaskId && this._taskMessageQueue) {
|
|
28913
28933
|
this._enqueueTaskMessage(relatedTaskId, {
|
|
28914
28934
|
type: "error",
|
|
28915
28935
|
message: errorResponse,
|
|
@@ -28960,7 +28980,10 @@ var Protocol = class {
|
|
|
28960
28980
|
closeSSEStream: extra?.closeSSEStream,
|
|
28961
28981
|
closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
|
|
28962
28982
|
};
|
|
28963
|
-
Promise.resolve().then(() => {
|
|
28983
|
+
(relatedTaskLookup ?? Promise.resolve()).then(() => {
|
|
28984
|
+
if (relatedTaskLookup && abortController.signal.aborted) {
|
|
28985
|
+
throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
|
|
28986
|
+
}
|
|
28964
28987
|
if (taskCreationParams) {
|
|
28965
28988
|
this.assertTaskHandlerCapability(request.method);
|
|
28966
28989
|
}
|
|
@@ -28995,7 +29018,7 @@ var Protocol = class {
|
|
|
28995
29018
|
...error51["data"] !== void 0 && { data: error51["data"] }
|
|
28996
29019
|
}
|
|
28997
29020
|
};
|
|
28998
|
-
if (relatedTaskId && this._taskMessageQueue) {
|
|
29021
|
+
if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
|
|
28999
29022
|
await this._enqueueTaskMessage(relatedTaskId, {
|
|
29000
29023
|
type: "error",
|
|
29001
29024
|
message: errorResponse,
|
|
@@ -29475,7 +29498,7 @@ var Protocol = class {
|
|
|
29475
29498
|
throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
|
|
29476
29499
|
}
|
|
29477
29500
|
const maxQueueSize = this._options?.maxTaskQueueSize;
|
|
29478
|
-
await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
|
|
29501
|
+
await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
|
|
29479
29502
|
}
|
|
29480
29503
|
/**
|
|
29481
29504
|
* Clears the message queue for a task and rejects any pending request resolvers.
|
|
@@ -29504,12 +29527,13 @@ var Protocol = class {
|
|
|
29504
29527
|
* Uses polling to check for updates at the task's configured poll interval.
|
|
29505
29528
|
* @param taskId The task ID to wait for
|
|
29506
29529
|
* @param signal Abort signal to cancel the wait
|
|
29530
|
+
* @param sessionId Session of the request that waits, passed to the task store
|
|
29507
29531
|
* @returns Promise that resolves when an update occurs or rejects if aborted
|
|
29508
29532
|
*/
|
|
29509
|
-
async _waitForTaskUpdate(taskId, signal) {
|
|
29533
|
+
async _waitForTaskUpdate(taskId, signal, sessionId) {
|
|
29510
29534
|
let interval = this._options?.defaultTaskPollInterval ?? 1e3;
|
|
29511
29535
|
try {
|
|
29512
|
-
const task = await this._taskStore?.getTask(taskId);
|
|
29536
|
+
const task = await this._taskStore?.getTask(taskId, sessionId);
|
|
29513
29537
|
if (task?.pollInterval) {
|
|
29514
29538
|
interval = task.pollInterval;
|
|
29515
29539
|
}
|
|
@@ -30388,6 +30412,42 @@ var ExperimentalMcpServerTasks = class {
|
|
|
30388
30412
|
};
|
|
30389
30413
|
|
|
30390
30414
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
|
|
30415
|
+
function toolInputElementCount(value, max) {
|
|
30416
|
+
let count = 0;
|
|
30417
|
+
const stack = [value];
|
|
30418
|
+
while (stack.length > 0) {
|
|
30419
|
+
const node = stack.pop();
|
|
30420
|
+
if (node === null || typeof node !== "object")
|
|
30421
|
+
continue;
|
|
30422
|
+
if (Array.isArray(node)) {
|
|
30423
|
+
for (const child of node) {
|
|
30424
|
+
if (++count > max)
|
|
30425
|
+
return count;
|
|
30426
|
+
if (child !== null && typeof child === "object")
|
|
30427
|
+
stack.push(child);
|
|
30428
|
+
}
|
|
30429
|
+
} else {
|
|
30430
|
+
for (const key in node) {
|
|
30431
|
+
if (!Object.prototype.hasOwnProperty.call(node, key))
|
|
30432
|
+
continue;
|
|
30433
|
+
if (++count > max)
|
|
30434
|
+
return count;
|
|
30435
|
+
const child = node[key];
|
|
30436
|
+
if (child !== null && typeof child === "object")
|
|
30437
|
+
stack.push(child);
|
|
30438
|
+
}
|
|
30439
|
+
}
|
|
30440
|
+
}
|
|
30441
|
+
return count;
|
|
30442
|
+
}
|
|
30443
|
+
function resolveMaxToolInputElements(value) {
|
|
30444
|
+
if (value === void 0 || value === Infinity)
|
|
30445
|
+
return void 0;
|
|
30446
|
+
if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
|
|
30447
|
+
throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
|
|
30448
|
+
}
|
|
30449
|
+
return value;
|
|
30450
|
+
}
|
|
30391
30451
|
var McpServer = class {
|
|
30392
30452
|
constructor(serverInfo, options) {
|
|
30393
30453
|
this._registeredResources = {};
|
|
@@ -30399,6 +30459,7 @@ var McpServer = class {
|
|
|
30399
30459
|
this._resourceHandlersInitialized = false;
|
|
30400
30460
|
this._promptHandlersInitialized = false;
|
|
30401
30461
|
this.server = new Server(serverInfo, options);
|
|
30462
|
+
this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
|
|
30402
30463
|
}
|
|
30403
30464
|
/**
|
|
30404
30465
|
* Access experimental features.
|
|
@@ -30529,12 +30590,15 @@ var McpServer = class {
|
|
|
30529
30590
|
* Validates tool input arguments against the tool's input schema.
|
|
30530
30591
|
*/
|
|
30531
30592
|
async validateToolInput(tool, args, toolName) {
|
|
30593
|
+
if (this._maxToolInputElements !== void 0 && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
|
|
30594
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
|
|
30595
|
+
}
|
|
30532
30596
|
if (!tool.inputSchema) {
|
|
30533
30597
|
return void 0;
|
|
30534
30598
|
}
|
|
30535
30599
|
const inputObj = normalizeObjectSchema(tool.inputSchema);
|
|
30536
30600
|
const schemaToParse = inputObj ?? tool.inputSchema;
|
|
30537
|
-
const parseResult = await safeParseAsync2(schemaToParse, args);
|
|
30601
|
+
const parseResult = await safeParseAsync2(schemaToParse, args ?? {});
|
|
30538
30602
|
if (!parseResult.success) {
|
|
30539
30603
|
const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
30540
30604
|
const errorMessage = getParseErrorMessage(error51);
|
|
@@ -30772,7 +30836,7 @@ var McpServer = class {
|
|
|
30772
30836
|
}
|
|
30773
30837
|
if (prompt.argsSchema) {
|
|
30774
30838
|
const argsObj = normalizeObjectSchema(prompt.argsSchema);
|
|
30775
|
-
const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
|
|
30839
|
+
const parseResult = await safeParseAsync2(argsObj, request.params.arguments ?? {});
|
|
30776
30840
|
if (!parseResult.success) {
|
|
30777
30841
|
const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
30778
30842
|
const errorMessage = getParseErrorMessage(error51);
|
|
@@ -31286,9 +31350,6 @@ var StdioServerTransport = class {
|
|
|
31286
31350
|
}
|
|
31287
31351
|
};
|
|
31288
31352
|
|
|
31289
|
-
// src/cli.ts
|
|
31290
|
-
import { readFileSync } from "node:fs";
|
|
31291
|
-
|
|
31292
31353
|
// src/api.ts
|
|
31293
31354
|
var BASE_URL = "https://api.tailscale.com/api/v2";
|
|
31294
31355
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
@@ -31326,17 +31387,27 @@ function getAuthConfig() {
|
|
|
31326
31387
|
`No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
|
|
31327
31388
|
);
|
|
31328
31389
|
}
|
|
31390
|
+
function hasUsableCredentials() {
|
|
31391
|
+
try {
|
|
31392
|
+
getAuthConfig();
|
|
31393
|
+
return true;
|
|
31394
|
+
} catch {
|
|
31395
|
+
return false;
|
|
31396
|
+
}
|
|
31397
|
+
}
|
|
31329
31398
|
function getOAuthTailnet() {
|
|
31330
31399
|
const raw = process.env.TAILSCALE_OAUTH_TAILNET?.trim();
|
|
31331
31400
|
return raw ? raw : void 0;
|
|
31332
31401
|
}
|
|
31333
|
-
|
|
31402
|
+
var OAUTH_FALLBACK_EXPIRES_IN_SEC = 300;
|
|
31403
|
+
async function getOAuthAccessToken(clientId, clientSecret, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
31334
31404
|
if (oauthToken && Date.now() < oauthToken.expires_at - 6e4) {
|
|
31335
31405
|
return oauthToken.access_token;
|
|
31336
31406
|
}
|
|
31337
31407
|
if (oauthRefreshPromise) {
|
|
31338
31408
|
return oauthRefreshPromise;
|
|
31339
31409
|
}
|
|
31410
|
+
const exchangeTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, Math.max(1, timeoutMs));
|
|
31340
31411
|
oauthRefreshPromise = (async () => {
|
|
31341
31412
|
try {
|
|
31342
31413
|
const oauthTailnet = getOAuthTailnet();
|
|
@@ -31349,7 +31420,7 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31349
31420
|
client_secret: clientSecret,
|
|
31350
31421
|
grant_type: "client_credentials"
|
|
31351
31422
|
}),
|
|
31352
|
-
signal: AbortSignal.timeout(
|
|
31423
|
+
signal: AbortSignal.timeout(exchangeTimeoutMs)
|
|
31353
31424
|
});
|
|
31354
31425
|
if (!res.ok) {
|
|
31355
31426
|
const body = await res.text();
|
|
@@ -31357,9 +31428,13 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31357
31428
|
throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
|
|
31358
31429
|
}
|
|
31359
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;
|
|
31360
31435
|
oauthToken = {
|
|
31361
31436
|
access_token: data.access_token,
|
|
31362
|
-
expires_at: Date.now() +
|
|
31437
|
+
expires_at: Date.now() + expiresInSec * 1e3
|
|
31363
31438
|
};
|
|
31364
31439
|
return oauthToken.access_token;
|
|
31365
31440
|
} finally {
|
|
@@ -31373,12 +31448,12 @@ function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
|
|
|
31373
31448
|
if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
|
|
31374
31449
|
oauthToken = null;
|
|
31375
31450
|
}
|
|
31376
|
-
async function getAuthHeader() {
|
|
31451
|
+
async function getAuthHeader(timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
31377
31452
|
const config2 = getAuthConfig();
|
|
31378
31453
|
if (config2.kind === "apiKey") {
|
|
31379
31454
|
return `Basic ${Buffer.from(`${config2.apiKey}:`).toString("base64")}`;
|
|
31380
31455
|
}
|
|
31381
|
-
const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret);
|
|
31456
|
+
const token = await getOAuthAccessToken(config2.clientId, config2.clientSecret, timeoutMs);
|
|
31382
31457
|
return `Bearer ${token}`;
|
|
31383
31458
|
}
|
|
31384
31459
|
function getTailnet() {
|
|
@@ -31598,11 +31673,22 @@ async function executeFetch(method, url2, headers, body, timeoutMs) {
|
|
|
31598
31673
|
signal: AbortSignal.timeout(timeoutMs)
|
|
31599
31674
|
});
|
|
31600
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
|
+
}
|
|
31601
31685
|
function describeTransportError(err, method, attemptTimeoutMs) {
|
|
31602
31686
|
if (err instanceof Error) {
|
|
31603
31687
|
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
31604
31688
|
return `${method} request timed out after ${attemptTimeoutMs}ms`;
|
|
31605
31689
|
}
|
|
31690
|
+
const phase = undiciTimeoutPhase(err);
|
|
31691
|
+
if (phase) return `${method} request timed out ${phase}`;
|
|
31606
31692
|
const cause = err.cause;
|
|
31607
31693
|
if (cause instanceof Error && cause.message) {
|
|
31608
31694
|
return `${method} request failed: ${err.message} (${cause.message})`;
|
|
@@ -31621,7 +31707,8 @@ function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
|
|
|
31621
31707
|
return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
|
|
31622
31708
|
}
|
|
31623
31709
|
function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, priorTransportError) {
|
|
31624
|
-
if (status !== 404
|
|
31710
|
+
if (status !== 404 && status !== 0) return error51;
|
|
31711
|
+
if (method.toUpperCase() !== "DELETE") return error51;
|
|
31625
31712
|
const causes = [];
|
|
31626
31713
|
if (priorGatewayStatus !== void 0) causes.push(`returned HTTP ${priorGatewayStatus}`);
|
|
31627
31714
|
if (priorTransportError !== void 0) causes.push(`never returned a response (${priorTransportError})`);
|
|
@@ -31629,6 +31716,11 @@ function annotateAmbiguousDelete(error51, status, method, priorGatewayStatus, pr
|
|
|
31629
31716
|
const subject = causes.length > 1 ? "earlier attempts" : "an earlier attempt";
|
|
31630
31717
|
return `${error51 || `HTTP ${status}`} (${subject} ${causes.join(" and ")}; the delete may already have succeeded)`;
|
|
31631
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
|
+
}
|
|
31632
31724
|
async function apiRequest(method, path, body, options) {
|
|
31633
31725
|
const headers = {};
|
|
31634
31726
|
if (options?.accept) {
|
|
@@ -31659,11 +31751,14 @@ async function apiRequest(method, path, body, options) {
|
|
|
31659
31751
|
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
31660
31752
|
const requestBudgetMs = getRequestBudgetMs();
|
|
31661
31753
|
return withConcurrencyLimit(async (queuedForMs) => {
|
|
31662
|
-
|
|
31754
|
+
const authTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, requestBudgetMs - (Date.now() - startedAt));
|
|
31755
|
+
headers.Authorization = await getAuthHeader(authTimeoutMs);
|
|
31663
31756
|
let res;
|
|
31664
31757
|
let lastTransportError;
|
|
31665
31758
|
let priorGatewayStatus;
|
|
31666
31759
|
let priorTransportError;
|
|
31760
|
+
let priorRateLimited = false;
|
|
31761
|
+
const withRateLimitNote = (error51) => priorRateLimited ? `${error51} (an earlier attempt was rate-limited: HTTP 429)` : error51;
|
|
31667
31762
|
let budgetMs = requestBudgetMs;
|
|
31668
31763
|
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
31669
31764
|
const remaining = budgetMs - (Date.now() - startedAt);
|
|
@@ -31671,7 +31766,14 @@ async function apiRequest(method, path, body, options) {
|
|
|
31671
31766
|
return {
|
|
31672
31767
|
ok: false,
|
|
31673
31768
|
status: 0,
|
|
31674
|
-
error:
|
|
31769
|
+
error: withRateLimitNote(
|
|
31770
|
+
annotateTransportFailure(
|
|
31771
|
+
describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError),
|
|
31772
|
+
method,
|
|
31773
|
+
priorGatewayStatus,
|
|
31774
|
+
priorTransportError
|
|
31775
|
+
)
|
|
31776
|
+
)
|
|
31675
31777
|
};
|
|
31676
31778
|
}
|
|
31677
31779
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
@@ -31683,12 +31785,27 @@ async function apiRequest(method, path, body, options) {
|
|
|
31683
31785
|
const desc = describeTransportError(err, method, attemptTimeoutMs);
|
|
31684
31786
|
lastTransportError = desc;
|
|
31685
31787
|
if (!isRetryable || attempt === MAX_429_RETRIES) {
|
|
31686
|
-
return {
|
|
31788
|
+
return {
|
|
31789
|
+
ok: false,
|
|
31790
|
+
status: 0,
|
|
31791
|
+
error: withRateLimitNote(annotateTransportFailure(desc, method, priorGatewayStatus, priorTransportError))
|
|
31792
|
+
};
|
|
31687
31793
|
}
|
|
31688
31794
|
const delay2 = compute429DelayMs(null, attempt);
|
|
31689
31795
|
const elapsed3 = Date.now() - startedAt;
|
|
31690
31796
|
if (budgetMs - elapsed3 - delay2 <= 0) {
|
|
31691
|
-
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
|
+
};
|
|
31692
31809
|
}
|
|
31693
31810
|
debugLog(
|
|
31694
31811
|
` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
|
|
@@ -31712,7 +31829,9 @@ async function apiRequest(method, path, body, options) {
|
|
|
31712
31829
|
break;
|
|
31713
31830
|
}
|
|
31714
31831
|
debugLog(` -> ${res.status} (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
31715
|
-
if (res.status
|
|
31832
|
+
if (res.status === 429) {
|
|
31833
|
+
priorRateLimited = true;
|
|
31834
|
+
} else {
|
|
31716
31835
|
priorGatewayStatus = res.status;
|
|
31717
31836
|
budgetMs = retryCeilingMs;
|
|
31718
31837
|
}
|
|
@@ -31754,13 +31873,17 @@ async function apiRequest(method, path, body, options) {
|
|
|
31754
31873
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
31755
31874
|
return { ok: true, status: response.status, etag };
|
|
31756
31875
|
}
|
|
31757
|
-
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);
|
|
31758
31881
|
return { ok: true, status: response.status, data, etag };
|
|
31759
31882
|
} catch (err) {
|
|
31760
31883
|
return {
|
|
31761
31884
|
ok: false,
|
|
31762
31885
|
status: response.status,
|
|
31763
|
-
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)}`,
|
|
31764
31887
|
etag
|
|
31765
31888
|
};
|
|
31766
31889
|
}
|
|
@@ -31783,6 +31906,7 @@ async function apiDelete(path, options) {
|
|
|
31783
31906
|
}
|
|
31784
31907
|
|
|
31785
31908
|
// src/cli.ts
|
|
31909
|
+
import { readFileSync } from "node:fs";
|
|
31786
31910
|
function isWarningsOnly(message, data) {
|
|
31787
31911
|
if (!/^warning/i.test(message)) return false;
|
|
31788
31912
|
if (!Array.isArray(data) || data.length === 0) return false;
|
|
@@ -31816,13 +31940,22 @@ function parseValidationError(rawBody) {
|
|
|
31816
31940
|
...details ? { details } : {}
|
|
31817
31941
|
};
|
|
31818
31942
|
}
|
|
31943
|
+
var CliFailure = class extends Error {
|
|
31944
|
+
constructor() {
|
|
31945
|
+
super("CLI failure reported on stderr");
|
|
31946
|
+
this.name = "CliFailure";
|
|
31947
|
+
}
|
|
31948
|
+
};
|
|
31819
31949
|
function readPolicyFile(filePath) {
|
|
31820
31950
|
try {
|
|
31821
31951
|
const text = readFileSync(filePath, "utf-8");
|
|
31822
31952
|
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
31823
31953
|
} catch (err) {
|
|
31824
|
-
|
|
31825
|
-
|
|
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();
|
|
31826
31959
|
}
|
|
31827
31960
|
}
|
|
31828
31961
|
async function validatePolicy(policy) {
|
|
@@ -31834,13 +31967,15 @@ async function validatePolicy(policy) {
|
|
|
31834
31967
|
});
|
|
31835
31968
|
if (!validateRes.ok) {
|
|
31836
31969
|
console.error(`ACL validation failed: ${validateRes.error}`);
|
|
31837
|
-
process.
|
|
31970
|
+
process.exitCode = 1;
|
|
31971
|
+
throw new CliFailure();
|
|
31838
31972
|
}
|
|
31839
31973
|
const validation = parseValidationError(validateRes.rawBody);
|
|
31840
31974
|
if (validation.kind !== "valid") {
|
|
31841
31975
|
console.error(`ACL validation failed: ${validation.message}${validation.details ? `
|
|
31842
31976
|
${validation.details}` : ""}`);
|
|
31843
|
-
process.
|
|
31977
|
+
process.exitCode = 1;
|
|
31978
|
+
throw new CliFailure();
|
|
31844
31979
|
}
|
|
31845
31980
|
}
|
|
31846
31981
|
async function validateAcl(filePath) {
|
|
@@ -31850,12 +31985,13 @@ async function validateAcl(filePath) {
|
|
|
31850
31985
|
}
|
|
31851
31986
|
async function deployAcl(filePath) {
|
|
31852
31987
|
const policy = readPolicyFile(filePath);
|
|
31988
|
+
await validatePolicy(policy);
|
|
31853
31989
|
const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
|
|
31854
31990
|
if (!getRes.ok || !getRes.etag) {
|
|
31855
31991
|
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31856
|
-
process.
|
|
31992
|
+
process.exitCode = 1;
|
|
31993
|
+
throw new CliFailure();
|
|
31857
31994
|
}
|
|
31858
|
-
await validatePolicy(policy);
|
|
31859
31995
|
const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31860
31996
|
rawBody: policy,
|
|
31861
31997
|
contentType: "application/hujson",
|
|
@@ -31871,7 +32007,8 @@ async function deployAcl(filePath) {
|
|
|
31871
32007
|
} else {
|
|
31872
32008
|
console.error(`ACL deploy failed: ${deployRes.error}`);
|
|
31873
32009
|
}
|
|
31874
|
-
process.
|
|
32010
|
+
process.exitCode = 1;
|
|
32011
|
+
throw new CliFailure();
|
|
31875
32012
|
}
|
|
31876
32013
|
console.log("ACL deployed successfully");
|
|
31877
32014
|
}
|
|
@@ -31991,13 +32128,13 @@ async function previewAccess(policy, principal) {
|
|
|
31991
32128
|
}
|
|
31992
32129
|
return {
|
|
31993
32130
|
ok: true,
|
|
31994
|
-
access: accessSet(parsed.matches, parsed.postures, false),
|
|
31995
32131
|
hasPostureDefs: !!parsed.postures,
|
|
31996
32132
|
matches: parsed.matches,
|
|
31997
32133
|
postureDefs: parsed.postures
|
|
31998
32134
|
};
|
|
31999
32135
|
}
|
|
32000
32136
|
var ETAG_FOOTER_MARKER = "// ETag: ";
|
|
32137
|
+
var NO_ETAG_MARKER = "// WARNING: the API returned no ETag";
|
|
32001
32138
|
function stripEtagFooter(body) {
|
|
32002
32139
|
const lines = body.split("\n");
|
|
32003
32140
|
let cut = lines.length;
|
|
@@ -32005,7 +32142,7 @@ function stripEtagFooter(body) {
|
|
|
32005
32142
|
const line = lines[i].trim();
|
|
32006
32143
|
if (line === "") continue;
|
|
32007
32144
|
if (!line.startsWith("//")) break;
|
|
32008
|
-
if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
|
|
32145
|
+
if (line.startsWith(ETAG_FOOTER_MARKER) || line.startsWith(NO_ETAG_MARKER)) cut = i;
|
|
32009
32146
|
}
|
|
32010
32147
|
return lines.slice(0, cut).join("\n");
|
|
32011
32148
|
}
|
|
@@ -32023,7 +32160,7 @@ function normalizeIfMatch(etag) {
|
|
|
32023
32160
|
var aclTools = [
|
|
32024
32161
|
{
|
|
32025
32162
|
name: "tailscale_get_acl",
|
|
32026
|
-
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.",
|
|
32027
32164
|
annotations: {
|
|
32028
32165
|
title: "Get ACL policy",
|
|
32029
32166
|
readOnlyHint: true,
|
|
@@ -32042,11 +32179,20 @@ var aclTools = [
|
|
|
32042
32179
|
"",
|
|
32043
32180
|
`${ETAG_FOOTER_MARKER}${res.etag}`,
|
|
32044
32181
|
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
32045
|
-
"// (HuJSON treats // as a comment
|
|
32182
|
+
"// (HuJSON treats // as a comment -- safe to leave in or strip before re-submitting.)",
|
|
32046
32183
|
""
|
|
32047
32184
|
].join("\n");
|
|
32048
32185
|
return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
|
|
32049
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
|
+
}
|
|
32050
32196
|
return res;
|
|
32051
32197
|
}
|
|
32052
32198
|
},
|
|
@@ -32083,9 +32229,12 @@ var aclTools = [
|
|
|
32083
32229
|
// this is the code that builds the header, and because the handlers are what
|
|
32084
32230
|
// the tests call directly -- a transform on the schema would be invisible to
|
|
32085
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.
|
|
32086
32235
|
handler: async (input) => {
|
|
32087
32236
|
return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
32088
|
-
rawBody: input.policy,
|
|
32237
|
+
rawBody: stripEtagFooter(input.policy),
|
|
32089
32238
|
contentType: "application/hujson",
|
|
32090
32239
|
ifMatch: normalizeIfMatch(input.etag),
|
|
32091
32240
|
acceptRaw: true,
|
|
@@ -32149,7 +32298,7 @@ var aclTools = [
|
|
|
32149
32298
|
},
|
|
32150
32299
|
{
|
|
32151
32300
|
name: "tailscale_diff_acl_access",
|
|
32152
|
-
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.",
|
|
32153
32302
|
annotations: {
|
|
32154
32303
|
title: "Diff ACL access",
|
|
32155
32304
|
readOnlyHint: true,
|
|
@@ -32306,7 +32455,9 @@ var aclTools = [
|
|
|
32306
32455
|
// src/tools/audit.ts
|
|
32307
32456
|
function assertRFC3339(value, label) {
|
|
32308
32457
|
const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
32309
|
-
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
|
+
);
|
|
32310
32461
|
if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
|
|
32311
32462
|
throw err();
|
|
32312
32463
|
}
|
|
@@ -32390,7 +32541,7 @@ var auditTools = [
|
|
|
32390
32541
|
},
|
|
32391
32542
|
{
|
|
32392
32543
|
name: "tailscale_get_network_flow_logs",
|
|
32393
|
-
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.",
|
|
32394
32545
|
annotations: {
|
|
32395
32546
|
title: "Get network flow logs",
|
|
32396
32547
|
readOnlyHint: true,
|
|
@@ -32427,7 +32578,7 @@ function isCidr(s) {
|
|
|
32427
32578
|
if (!/^\d+$/.test(prefix)) return false;
|
|
32428
32579
|
const prefixN = Number(prefix);
|
|
32429
32580
|
if (net.isIPv4(addr)) return prefixN <= 32;
|
|
32430
|
-
if (net.isIPv6(addr)) return prefixN <= 128;
|
|
32581
|
+
if (net.isIPv6(addr)) return !addr.includes("%") && prefixN <= 128;
|
|
32431
32582
|
return false;
|
|
32432
32583
|
}
|
|
32433
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.";
|
|
@@ -32523,7 +32674,7 @@ var deviceTools = [
|
|
|
32523
32674
|
},
|
|
32524
32675
|
{
|
|
32525
32676
|
name: "tailscale_delete_device",
|
|
32526
|
-
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.",
|
|
32527
32678
|
annotations: {
|
|
32528
32679
|
title: "Delete device",
|
|
32529
32680
|
readOnlyHint: false,
|
|
@@ -32532,7 +32683,10 @@ var deviceTools = [
|
|
|
32532
32683
|
openWorldHint: true
|
|
32533
32684
|
},
|
|
32534
32685
|
inputSchema: external_exports.object({
|
|
32535
|
-
|
|
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}`)
|
|
32536
32690
|
}),
|
|
32537
32691
|
handler: async (input) => {
|
|
32538
32692
|
return apiDelete(`/device/${encPath(input.deviceId)}`);
|
|
@@ -32594,7 +32748,7 @@ var deviceTools = [
|
|
|
32594
32748
|
},
|
|
32595
32749
|
{
|
|
32596
32750
|
name: "tailscale_set_device_routes",
|
|
32597
|
-
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.",
|
|
32598
32752
|
annotations: {
|
|
32599
32753
|
title: "Set device routes",
|
|
32600
32754
|
readOnlyHint: false,
|
|
@@ -32692,7 +32846,7 @@ var deviceTools = [
|
|
|
32692
32846
|
},
|
|
32693
32847
|
{
|
|
32694
32848
|
name: "tailscale_set_device_tags",
|
|
32695
|
-
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.",
|
|
32696
32850
|
annotations: {
|
|
32697
32851
|
title: "Set device tags",
|
|
32698
32852
|
readOnlyHint: false,
|
|
@@ -32751,7 +32905,7 @@ var deviceTools = [
|
|
|
32751
32905
|
},
|
|
32752
32906
|
{
|
|
32753
32907
|
name: "tailscale_set_devices_authorized",
|
|
32754
|
-
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.",
|
|
32755
32909
|
annotations: {
|
|
32756
32910
|
title: "Set devices authorized (bulk)",
|
|
32757
32911
|
readOnlyHint: false,
|
|
@@ -32793,7 +32947,7 @@ var deviceTools = [
|
|
|
32793
32947
|
},
|
|
32794
32948
|
{
|
|
32795
32949
|
name: "tailscale_batch_update_posture_attributes",
|
|
32796
|
-
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.",
|
|
32797
32951
|
annotations: {
|
|
32798
32952
|
title: "Batch update posture attributes",
|
|
32799
32953
|
readOnlyHint: false,
|
|
@@ -33021,7 +33175,7 @@ var dnsTools = [
|
|
|
33021
33175
|
},
|
|
33022
33176
|
{
|
|
33023
33177
|
name: "tailscale_set_dns_configuration",
|
|
33024
|
-
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.",
|
|
33025
33179
|
annotations: {
|
|
33026
33180
|
title: "Set DNS configuration (unified)",
|
|
33027
33181
|
readOnlyHint: false,
|
|
@@ -33034,9 +33188,16 @@ var dnsTools = [
|
|
|
33034
33188
|
inputSchema: external_exports.object({
|
|
33035
33189
|
dns: external_exports.array(external_exports.string()).optional().describe("List of DNS server IP addresses"),
|
|
33036
33190
|
searchPaths: external_exports.array(external_exports.string()).optional().describe("List of DNS search domains"),
|
|
33037
|
-
|
|
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"),
|
|
33038
33194
|
magicDNS: external_exports.boolean().optional().describe("Whether to enable MagicDNS")
|
|
33039
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.
|
|
33040
33201
|
handler: async (input) => {
|
|
33041
33202
|
const body = {};
|
|
33042
33203
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -33050,6 +33211,155 @@ var dnsTools = [
|
|
|
33050
33211
|
}
|
|
33051
33212
|
];
|
|
33052
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
|
+
|
|
33053
33363
|
// src/tools/invites.ts
|
|
33054
33364
|
var inviteTools = [
|
|
33055
33365
|
// --- Device Invites ---
|
|
@@ -33091,6 +33401,9 @@ var inviteTools = [
|
|
|
33091
33401
|
if (input.multiUse !== void 0) body.multiUse = input.multiUse;
|
|
33092
33402
|
if (input.allowExitNode !== void 0) body.allowExitNode = input.allowExitNode;
|
|
33093
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
|
+
}
|
|
33094
33407
|
return apiPost(`/device/${encPath(input.deviceId)}/device-invites`, body);
|
|
33095
33408
|
}
|
|
33096
33409
|
},
|
|
@@ -33113,7 +33426,7 @@ var inviteTools = [
|
|
|
33113
33426
|
},
|
|
33114
33427
|
{
|
|
33115
33428
|
name: "tailscale_delete_device_invite",
|
|
33116
|
-
description: "Delete a device invite. This is irreversible
|
|
33429
|
+
description: "Delete a device invite. This is irreversible -- the invite link will stop working.",
|
|
33117
33430
|
annotations: {
|
|
33118
33431
|
title: "Delete device invite",
|
|
33119
33432
|
readOnlyHint: false,
|
|
@@ -33173,12 +33486,15 @@ var inviteTools = [
|
|
|
33173
33486
|
},
|
|
33174
33487
|
inputSchema: external_exports.object({
|
|
33175
33488
|
email: external_exports.email().optional().describe("Email address to send the invite to"),
|
|
33176
|
-
role: external_exports.enum(
|
|
33489
|
+
role: external_exports.enum(INVITABLE_ROLES).optional().describe("Role to assign to the invited user (default: member)")
|
|
33177
33490
|
}),
|
|
33178
33491
|
handler: async (input) => {
|
|
33179
33492
|
const body = {};
|
|
33180
33493
|
if (input.email !== void 0) body.email = input.email;
|
|
33181
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
|
+
}
|
|
33182
33498
|
return apiPost(`/tailnet/${getTailnet()}/user-invites`, body);
|
|
33183
33499
|
}
|
|
33184
33500
|
},
|
|
@@ -33201,7 +33517,7 @@ var inviteTools = [
|
|
|
33201
33517
|
},
|
|
33202
33518
|
{
|
|
33203
33519
|
name: "tailscale_delete_user_invite",
|
|
33204
|
-
description: "Delete a user invite. This is irreversible
|
|
33520
|
+
description: "Delete a user invite. This is irreversible -- the invite link will stop working.",
|
|
33205
33521
|
annotations: {
|
|
33206
33522
|
title: "Delete user invite",
|
|
33207
33523
|
readOnlyHint: false,
|
|
@@ -33285,7 +33601,7 @@ var keyTools = [
|
|
|
33285
33601
|
openWorldHint: true
|
|
33286
33602
|
},
|
|
33287
33603
|
inputSchema: external_exports.object({
|
|
33288
|
-
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)")
|
|
33289
33605
|
}),
|
|
33290
33606
|
handler: async (input) => {
|
|
33291
33607
|
return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33310,7 +33626,8 @@ var keyTools = [
|
|
|
33310
33626
|
reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
|
|
33311
33627
|
ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
|
|
33312
33628
|
preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
|
|
33313
|
-
|
|
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)"),
|
|
33314
33631
|
// Shared fields
|
|
33315
33632
|
tags: external_exports.array(external_exports.string()).optional().describe(
|
|
33316
33633
|
"ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
|
|
@@ -33390,7 +33707,14 @@ var keyTools = [
|
|
|
33390
33707
|
openWorldHint: true
|
|
33391
33708
|
},
|
|
33392
33709
|
inputSchema: external_exports.object({
|
|
33393
|
-
|
|
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)")
|
|
33394
33718
|
}),
|
|
33395
33719
|
handler: async (input) => {
|
|
33396
33720
|
return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
|
|
@@ -33398,7 +33722,7 @@ var keyTools = [
|
|
|
33398
33722
|
},
|
|
33399
33723
|
{
|
|
33400
33724
|
name: "tailscale_update_key",
|
|
33401
|
-
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.",
|
|
33402
33726
|
annotations: {
|
|
33403
33727
|
title: "Update key",
|
|
33404
33728
|
readOnlyHint: false,
|
|
@@ -33407,7 +33731,7 @@ var keyTools = [
|
|
|
33407
33731
|
openWorldHint: true
|
|
33408
33732
|
},
|
|
33409
33733
|
inputSchema: external_exports.object({
|
|
33410
|
-
keyId: external_exports.string().describe("The key ID to update"),
|
|
33734
|
+
keyId: external_exports.string().trim().min(1).describe("The key ID to update"),
|
|
33411
33735
|
description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
|
|
33412
33736
|
scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
|
|
33413
33737
|
tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
|
|
@@ -33551,6 +33875,10 @@ var DARWIN_CANDIDATES = [
|
|
|
33551
33875
|
];
|
|
33552
33876
|
var LINUX_CANDIDATES = ["/usr/bin/tailscale", "/snap/bin/tailscale"];
|
|
33553
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
|
+
}
|
|
33554
33882
|
function binaryCandidates(platform) {
|
|
33555
33883
|
if (platform === "darwin") return DARWIN_CANDIDATES;
|
|
33556
33884
|
if (platform === "linux") return LINUX_CANDIDATES;
|
|
@@ -33578,7 +33906,7 @@ function readProcVersion() {
|
|
|
33578
33906
|
}
|
|
33579
33907
|
function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
33580
33908
|
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
|
|
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.`;
|
|
33582
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}`;
|
|
33583
33911
|
}
|
|
33584
33912
|
if (platform === "darwin") {
|
|
@@ -33589,12 +33917,30 @@ function describeMissingBinary(binary, fromEnv, platform, wsl) {
|
|
|
33589
33917
|
}
|
|
33590
33918
|
return `Could not find the 'tailscale' binary in PATH. Install Tailscale (https://tailscale.com/download) or set TAILSCALE_BINARY to its absolute path.`;
|
|
33591
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
|
+
}
|
|
33592
33936
|
async function runTailscaleCli(args, options = {}) {
|
|
33593
33937
|
const binary = resolveBinary();
|
|
33594
33938
|
const fromEnv = Boolean(process.env.TAILSCALE_BINARY);
|
|
33595
33939
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33940
|
+
await acquireCliSlot();
|
|
33596
33941
|
return new Promise((resolve) => {
|
|
33597
33942
|
execFileImpl(binary, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES }, (err, stdout, stderr) => {
|
|
33943
|
+
releaseCliSlot();
|
|
33598
33944
|
const stdoutStr = stdout == null ? "" : String(stdout);
|
|
33599
33945
|
const stderrStr = stderr == null ? "" : String(stderr);
|
|
33600
33946
|
if (err) {
|
|
@@ -33606,6 +33952,10 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
33606
33952
|
});
|
|
33607
33953
|
return;
|
|
33608
33954
|
}
|
|
33955
|
+
if (errno.code === "EACCES") {
|
|
33956
|
+
resolve({ ok: false, error: describeUnexecutableBinary(binary, fromEnv) });
|
|
33957
|
+
return;
|
|
33958
|
+
}
|
|
33609
33959
|
if (errno.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
33610
33960
|
resolve({
|
|
33611
33961
|
ok: false,
|
|
@@ -33800,10 +34150,12 @@ var logStreamingTools = [
|
|
|
33800
34150
|
apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
|
|
33801
34151
|
apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
|
|
33802
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}`;
|
|
33803
34154
|
const errors = {};
|
|
33804
|
-
if (!configuration.ok) errors.configuration = configuration
|
|
33805
|
-
if (!network.ok) errors.network = network
|
|
33806
|
-
|
|
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)) {
|
|
33807
34159
|
return {
|
|
33808
34160
|
ok: false,
|
|
33809
34161
|
status: configuration.status || network.status || 500,
|
|
@@ -33848,7 +34200,7 @@ var logStreamingTools = [
|
|
|
33848
34200
|
inputSchema: external_exports.object({
|
|
33849
34201
|
logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
|
|
33850
34202
|
destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
|
|
33851
|
-
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)"),
|
|
33852
34204
|
token: external_exports.string().optional().describe(
|
|
33853
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."
|
|
33854
34206
|
),
|
|
@@ -33875,11 +34227,19 @@ var logStreamingTools = [
|
|
|
33875
34227
|
if (!input.s3Bucket) missing.push("s3Bucket");
|
|
33876
34228
|
if (!input.s3Region) missing.push("s3Region");
|
|
33877
34229
|
if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
|
|
34230
|
+
let wrongAuthFields = [];
|
|
33878
34231
|
if (input.s3AuthenticationType === "accesskey") {
|
|
33879
34232
|
if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
|
|
33880
34233
|
if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
|
|
34234
|
+
if (input.s3RoleArn !== void 0) wrongAuthFields = ["s3RoleArn"];
|
|
33881
34235
|
} else if (input.s3AuthenticationType === "rolearn") {
|
|
33882
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
|
+
);
|
|
33883
34243
|
}
|
|
33884
34244
|
if (missing.length > 0) {
|
|
33885
34245
|
throw new Error(
|
|
@@ -33972,9 +34332,11 @@ var logStreamingTools = [
|
|
|
33972
34332
|
// The flag was previously never sent at all, so the server applied whatever
|
|
33973
34333
|
// default it applies to an absent body -- undocumented either way. It now
|
|
33974
34334
|
// goes on the wire explicitly, as the Go client always does. The default
|
|
33975
|
-
// lives here rather than in a Zod `.default()` because
|
|
33976
|
-
//
|
|
33977
|
-
//
|
|
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.
|
|
33978
34340
|
handler: async (input) => {
|
|
33979
34341
|
return apiPost(`/tailnet/${getTailnet()}/aws-external-id`, { reusable: input?.reusable ?? true });
|
|
33980
34342
|
}
|
|
@@ -34152,7 +34514,7 @@ var postureTools = [
|
|
|
34152
34514
|
var serviceTools = [
|
|
34153
34515
|
{
|
|
34154
34516
|
name: "tailscale_list_services",
|
|
34155
|
-
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.",
|
|
34156
34518
|
annotations: {
|
|
34157
34519
|
title: "List services",
|
|
34158
34520
|
readOnlyHint: true,
|
|
@@ -34218,7 +34580,7 @@ var serviceTools = [
|
|
|
34218
34580
|
},
|
|
34219
34581
|
{
|
|
34220
34582
|
name: "tailscale_delete_service",
|
|
34221
|
-
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.",
|
|
34222
34584
|
annotations: {
|
|
34223
34585
|
title: "Delete service",
|
|
34224
34586
|
readOnlyHint: false,
|
|
@@ -34227,7 +34589,10 @@ var serviceTools = [
|
|
|
34227
34589
|
openWorldHint: true
|
|
34228
34590
|
},
|
|
34229
34591
|
inputSchema: external_exports.object({
|
|
34230
|
-
|
|
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")
|
|
34231
34596
|
}),
|
|
34232
34597
|
handler: async (input) => {
|
|
34233
34598
|
return apiDelete(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}`);
|
|
@@ -34308,6 +34673,12 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
|
|
|
34308
34673
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
34309
34674
|
return data;
|
|
34310
34675
|
}
|
|
34676
|
+
function fetchTailnetStatusInputs() {
|
|
34677
|
+
return Promise.all([
|
|
34678
|
+
apiGet(`/tailnet/${getTailnet()}/devices?fields=default`),
|
|
34679
|
+
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34680
|
+
]);
|
|
34681
|
+
}
|
|
34311
34682
|
var statusTools = [
|
|
34312
34683
|
{
|
|
34313
34684
|
name: "tailscale_status",
|
|
@@ -34321,10 +34692,7 @@ var statusTools = [
|
|
|
34321
34692
|
},
|
|
34322
34693
|
inputSchema: external_exports.object({}),
|
|
34323
34694
|
handler: async () => {
|
|
34324
|
-
const [devicesRes, settingsRes] = await
|
|
34325
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
34326
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
34327
|
-
]);
|
|
34695
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
34328
34696
|
if (!devicesRes.ok && !settingsRes.ok) {
|
|
34329
34697
|
return devicesRes;
|
|
34330
34698
|
}
|
|
@@ -34367,7 +34735,7 @@ var tailnetTools = [
|
|
|
34367
34735
|
inputSchema: external_exports.object({
|
|
34368
34736
|
devicesApprovalOn: external_exports.boolean().optional().describe("Whether device approval is required"),
|
|
34369
34737
|
devicesAutoUpdatesOn: external_exports.boolean().optional().describe("Whether auto-updates are enabled"),
|
|
34370
|
-
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)"),
|
|
34371
34739
|
usersApprovalOn: external_exports.boolean().optional().describe("Whether user approval is required"),
|
|
34372
34740
|
usersRoleAllowedToJoinExternalTailnets: external_exports.enum(["none", "admin", "member"]).optional().describe("Which user roles can join external tailnets"),
|
|
34373
34741
|
networkFlowLoggingOn: external_exports.boolean().optional().describe("Whether network flow logging is enabled"),
|
|
@@ -34435,7 +34803,7 @@ var tailnetTools = [
|
|
|
34435
34803
|
const applied = {};
|
|
34436
34804
|
const failed = {};
|
|
34437
34805
|
for (const { contactType, res } of results) {
|
|
34438
|
-
if (res.ok) applied[contactType] = res.data;
|
|
34806
|
+
if (res.ok) applied[contactType] = res.data ?? { status: res.status };
|
|
34439
34807
|
else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
34440
34808
|
}
|
|
34441
34809
|
const hasFailed = Object.keys(failed).length > 0;
|
|
@@ -34571,135 +34939,6 @@ var tailnetsTools = [
|
|
|
34571
34939
|
}
|
|
34572
34940
|
];
|
|
34573
34941
|
|
|
34574
|
-
// src/tools/users.ts
|
|
34575
|
-
var userTools = [
|
|
34576
|
-
{
|
|
34577
|
-
name: "tailscale_list_users",
|
|
34578
|
-
description: "List all users in your tailnet.",
|
|
34579
|
-
annotations: {
|
|
34580
|
-
title: "List users",
|
|
34581
|
-
readOnlyHint: true,
|
|
34582
|
-
destructiveHint: false,
|
|
34583
|
-
idempotentHint: true,
|
|
34584
|
-
openWorldHint: true
|
|
34585
|
-
},
|
|
34586
|
-
inputSchema: external_exports.object({
|
|
34587
|
-
type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
|
|
34588
|
-
role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).optional().describe("Filter by user role")
|
|
34589
|
-
}),
|
|
34590
|
-
handler: async (input) => {
|
|
34591
|
-
const params = new URLSearchParams();
|
|
34592
|
-
if (input.type) params.set("type", input.type);
|
|
34593
|
-
if (input.role) params.set("role", input.role);
|
|
34594
|
-
const qs = params.toString();
|
|
34595
|
-
return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
|
|
34596
|
-
}
|
|
34597
|
-
},
|
|
34598
|
-
{
|
|
34599
|
-
name: "tailscale_get_user",
|
|
34600
|
-
description: "Get details for a specific user.",
|
|
34601
|
-
annotations: {
|
|
34602
|
-
title: "Get user",
|
|
34603
|
-
readOnlyHint: true,
|
|
34604
|
-
destructiveHint: false,
|
|
34605
|
-
idempotentHint: true,
|
|
34606
|
-
openWorldHint: true
|
|
34607
|
-
},
|
|
34608
|
-
inputSchema: external_exports.object({
|
|
34609
|
-
userId: external_exports.string().describe("The user ID")
|
|
34610
|
-
}),
|
|
34611
|
-
handler: async (input) => {
|
|
34612
|
-
return apiGet(`/users/${encPath(input.userId)}`);
|
|
34613
|
-
}
|
|
34614
|
-
},
|
|
34615
|
-
{
|
|
34616
|
-
name: "tailscale_approve_user",
|
|
34617
|
-
description: "Approve a pending user, granting them access to the tailnet.",
|
|
34618
|
-
annotations: {
|
|
34619
|
-
title: "Approve user",
|
|
34620
|
-
readOnlyHint: false,
|
|
34621
|
-
destructiveHint: false,
|
|
34622
|
-
idempotentHint: true,
|
|
34623
|
-
openWorldHint: true
|
|
34624
|
-
},
|
|
34625
|
-
inputSchema: external_exports.object({
|
|
34626
|
-
userId: external_exports.string().describe("The user ID to approve")
|
|
34627
|
-
}),
|
|
34628
|
-
handler: async (input) => {
|
|
34629
|
-
return apiPost(`/users/${encPath(input.userId)}/approve`);
|
|
34630
|
-
}
|
|
34631
|
-
},
|
|
34632
|
-
{
|
|
34633
|
-
name: "tailscale_suspend_user",
|
|
34634
|
-
description: "Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.",
|
|
34635
|
-
annotations: {
|
|
34636
|
-
title: "Suspend user",
|
|
34637
|
-
readOnlyHint: false,
|
|
34638
|
-
destructiveHint: true,
|
|
34639
|
-
idempotentHint: true,
|
|
34640
|
-
openWorldHint: true
|
|
34641
|
-
},
|
|
34642
|
-
inputSchema: external_exports.object({
|
|
34643
|
-
userId: external_exports.string().describe("The user ID to suspend")
|
|
34644
|
-
}),
|
|
34645
|
-
handler: async (input) => {
|
|
34646
|
-
return apiPost(`/users/${encPath(input.userId)}/suspend`);
|
|
34647
|
-
}
|
|
34648
|
-
},
|
|
34649
|
-
{
|
|
34650
|
-
name: "tailscale_restore_user",
|
|
34651
|
-
description: "Restore a previously suspended user, re-granting them access to the tailnet.",
|
|
34652
|
-
annotations: {
|
|
34653
|
-
title: "Restore user",
|
|
34654
|
-
readOnlyHint: false,
|
|
34655
|
-
destructiveHint: false,
|
|
34656
|
-
idempotentHint: true,
|
|
34657
|
-
openWorldHint: true
|
|
34658
|
-
},
|
|
34659
|
-
inputSchema: external_exports.object({
|
|
34660
|
-
userId: external_exports.string().describe("The user ID to restore")
|
|
34661
|
-
}),
|
|
34662
|
-
handler: async (input) => {
|
|
34663
|
-
return apiPost(`/users/${encPath(input.userId)}/restore`);
|
|
34664
|
-
}
|
|
34665
|
-
},
|
|
34666
|
-
{
|
|
34667
|
-
name: "tailscale_update_user_role",
|
|
34668
|
-
description: "Update a user's role in the tailnet.",
|
|
34669
|
-
annotations: {
|
|
34670
|
-
title: "Update user role",
|
|
34671
|
-
readOnlyHint: false,
|
|
34672
|
-
destructiveHint: false,
|
|
34673
|
-
idempotentHint: true,
|
|
34674
|
-
openWorldHint: true
|
|
34675
|
-
},
|
|
34676
|
-
inputSchema: external_exports.object({
|
|
34677
|
-
userId: external_exports.string().describe("The user ID"),
|
|
34678
|
-
role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).describe("The new role to assign")
|
|
34679
|
-
}),
|
|
34680
|
-
handler: async (input) => {
|
|
34681
|
-
return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
|
|
34682
|
-
}
|
|
34683
|
-
},
|
|
34684
|
-
{
|
|
34685
|
-
name: "tailscale_delete_user",
|
|
34686
|
-
description: "Delete a user from the tailnet. This is irreversible \u2014 the user and all their devices will be removed.",
|
|
34687
|
-
annotations: {
|
|
34688
|
-
title: "Delete user",
|
|
34689
|
-
readOnlyHint: false,
|
|
34690
|
-
destructiveHint: true,
|
|
34691
|
-
idempotentHint: true,
|
|
34692
|
-
openWorldHint: true
|
|
34693
|
-
},
|
|
34694
|
-
inputSchema: external_exports.object({
|
|
34695
|
-
userId: external_exports.string().describe("The user ID to delete")
|
|
34696
|
-
}),
|
|
34697
|
-
handler: async (input) => {
|
|
34698
|
-
return apiPost(`/users/${encPath(input.userId)}/delete`);
|
|
34699
|
-
}
|
|
34700
|
-
}
|
|
34701
|
-
];
|
|
34702
|
-
|
|
34703
34942
|
// src/tools/webhooks.ts
|
|
34704
34943
|
var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
34705
34944
|
"nodeCreated",
|
|
@@ -34728,7 +34967,13 @@ function getAllowedWebhookEvents() {
|
|
|
34728
34967
|
const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
34729
34968
|
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...WEBHOOK_CATEGORY_SUBSCRIPTIONS, ...extras]);
|
|
34730
34969
|
}
|
|
34731
|
-
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://");
|
|
34732
34977
|
var webhookSubscriptionsSchema = external_exports.array(external_exports.string().meta({ enum: [...getAllowedWebhookEvents()].sort() })).min(1).superRefine((arr, ctx) => {
|
|
34733
34978
|
const allowed = getAllowedWebhookEvents();
|
|
34734
34979
|
let knownEventsList = null;
|
|
@@ -34775,7 +35020,7 @@ var webhookTools = [
|
|
|
34775
35020
|
openWorldHint: true
|
|
34776
35021
|
},
|
|
34777
35022
|
inputSchema: external_exports.object({
|
|
34778
|
-
webhookId: external_exports.string().describe("The webhook ID")
|
|
35023
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID")
|
|
34779
35024
|
}),
|
|
34780
35025
|
handler: async (input) => {
|
|
34781
35026
|
return apiGet(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34820,7 +35065,7 @@ var webhookTools = [
|
|
|
34820
35065
|
openWorldHint: true
|
|
34821
35066
|
},
|
|
34822
35067
|
inputSchema: external_exports.object({
|
|
34823
|
-
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
35068
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to update"),
|
|
34824
35069
|
endpointUrl: endpointUrlSchema.optional().describe("New HTTPS URL to send webhook events to"),
|
|
34825
35070
|
subscriptions: webhookSubscriptionsSchema.optional().describe(
|
|
34826
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."
|
|
@@ -34838,7 +35083,7 @@ var webhookTools = [
|
|
|
34838
35083
|
},
|
|
34839
35084
|
{
|
|
34840
35085
|
name: "tailscale_delete_webhook",
|
|
34841
|
-
description: "Delete a webhook. This is irreversible
|
|
35086
|
+
description: "Delete a webhook. This is irreversible -- the webhook secret cannot be recovered.",
|
|
34842
35087
|
annotations: {
|
|
34843
35088
|
title: "Delete webhook",
|
|
34844
35089
|
readOnlyHint: false,
|
|
@@ -34847,7 +35092,10 @@ var webhookTools = [
|
|
|
34847
35092
|
openWorldHint: true
|
|
34848
35093
|
},
|
|
34849
35094
|
inputSchema: external_exports.object({
|
|
34850
|
-
|
|
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")
|
|
34851
35099
|
}),
|
|
34852
35100
|
handler: async (input) => {
|
|
34853
35101
|
return apiDelete(`/webhooks/${encPath(input.webhookId)}`);
|
|
@@ -34855,7 +35103,7 @@ var webhookTools = [
|
|
|
34855
35103
|
},
|
|
34856
35104
|
{
|
|
34857
35105
|
name: "tailscale_rotate_webhook_secret",
|
|
34858
|
-
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.",
|
|
34859
35107
|
annotations: {
|
|
34860
35108
|
title: "Rotate webhook secret",
|
|
34861
35109
|
readOnlyHint: false,
|
|
@@ -34864,7 +35112,7 @@ var webhookTools = [
|
|
|
34864
35112
|
openWorldHint: true
|
|
34865
35113
|
},
|
|
34866
35114
|
inputSchema: external_exports.object({
|
|
34867
|
-
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")
|
|
34868
35116
|
}),
|
|
34869
35117
|
handler: async (input) => {
|
|
34870
35118
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/rotate`);
|
|
@@ -34883,7 +35131,7 @@ var webhookTools = [
|
|
|
34883
35131
|
openWorldHint: true
|
|
34884
35132
|
},
|
|
34885
35133
|
inputSchema: external_exports.object({
|
|
34886
|
-
webhookId: external_exports.string().describe("The webhook ID to test")
|
|
35134
|
+
webhookId: external_exports.string().trim().min(1).describe("The webhook ID to test")
|
|
34887
35135
|
}),
|
|
34888
35136
|
handler: async (input) => {
|
|
34889
35137
|
return apiPost(`/webhooks/${encPath(input.webhookId)}/test`);
|
|
@@ -34972,8 +35220,9 @@ function formatTailnetMismatchWarning(env) {
|
|
|
34972
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.`;
|
|
34973
35221
|
}
|
|
34974
35222
|
function formatBannerFilterSuffix(inputs) {
|
|
34975
|
-
const
|
|
34976
|
-
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;
|
|
34977
35226
|
const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
|
|
34978
35227
|
return [
|
|
34979
35228
|
profileLabel,
|
|
@@ -34991,6 +35240,11 @@ function wrapToolHandler(tool) {
|
|
|
34991
35240
|
try {
|
|
34992
35241
|
const result = await tool.handler(input);
|
|
34993
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
|
+
}
|
|
34994
35248
|
if (!response.ok) {
|
|
34995
35249
|
return {
|
|
34996
35250
|
content: [
|
|
@@ -35016,10 +35270,7 @@ function wrapToolHandler(tool) {
|
|
|
35016
35270
|
};
|
|
35017
35271
|
}
|
|
35018
35272
|
async function tailnetStatusResource(uri) {
|
|
35019
|
-
const [devicesRes, settingsRes] = await
|
|
35020
|
-
apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
|
|
35021
|
-
apiGet(`/tailnet/${getTailnet()}/settings`)
|
|
35022
|
-
]);
|
|
35273
|
+
const [devicesRes, settingsRes] = await fetchTailnetStatusInputs();
|
|
35023
35274
|
const data = composeTailnetStatusData(devicesRes, settingsRes, { tailnet: getTailnet() });
|
|
35024
35275
|
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
|
|
35025
35276
|
}
|
|
@@ -35068,7 +35319,7 @@ function explainNotLoaded(group, state) {
|
|
|
35068
35319
|
toEnable: "set TAILSCALE_LOCAL_CLI=1"
|
|
35069
35320
|
};
|
|
35070
35321
|
}
|
|
35071
|
-
if (state.toolsEnv) {
|
|
35322
|
+
if (parseGroupList(state.toolsEnv) && !state.toolsIgnored) {
|
|
35072
35323
|
return {
|
|
35073
35324
|
reason: `TAILSCALE_TOOLS is set to "${state.toolsEnv}", which does not include this group`,
|
|
35074
35325
|
toEnable: `add "${group}" to TAILSCALE_TOOLS`
|
|
@@ -35092,10 +35343,16 @@ function explainWritesWithheld(group, state) {
|
|
|
35092
35343
|
toEnable: "unset TAILSCALE_READONLY"
|
|
35093
35344
|
};
|
|
35094
35345
|
}
|
|
35095
|
-
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` : "";
|
|
35096
35353
|
return {
|
|
35097
|
-
reason:
|
|
35098
|
-
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(",")}")`
|
|
35099
35356
|
};
|
|
35100
35357
|
}
|
|
35101
35358
|
function buildGroupReports(state) {
|
|
@@ -35184,8 +35441,8 @@ function buildMetaTools(state) {
|
|
|
35184
35441
|
const totalAvailable = groups.reduce((n, g) => n + g.available, 0);
|
|
35185
35442
|
const activeFilters = [
|
|
35186
35443
|
state.profileEnv ? `TAILSCALE_PROFILE=${state.profileEnv}` : null,
|
|
35187
|
-
state.toolsEnv ? `TAILSCALE_TOOLS=${state.toolsEnv}` : null,
|
|
35188
|
-
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,
|
|
35189
35446
|
state.writeGroupsEnv?.trim() ? `TAILSCALE_WRITE_GROUPS=${state.writeGroupsEnv.trim()}` : null,
|
|
35190
35447
|
state.localCliEnabled ? "TAILSCALE_LOCAL_CLI=1" : null
|
|
35191
35448
|
].filter(Boolean);
|
|
@@ -35225,7 +35482,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
|
|
|
35225
35482
|
);
|
|
35226
35483
|
process.exit(1);
|
|
35227
35484
|
}
|
|
35228
|
-
var version2 = true ? "0.
|
|
35485
|
+
var version2 = true ? "0.22.0" : resolveVersionFallback();
|
|
35229
35486
|
var subcommand = process.argv[2];
|
|
35230
35487
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
35231
35488
|
|
|
@@ -35254,6 +35511,9 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35254
35511
|
}
|
|
35255
35512
|
const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
|
|
35256
35513
|
run(filePath).then(() => process.exit(0)).catch((err) => {
|
|
35514
|
+
if (err instanceof Error && err.name === "CliFailure") {
|
|
35515
|
+
process.exit(1);
|
|
35516
|
+
}
|
|
35257
35517
|
console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
35258
35518
|
process.exit(1);
|
|
35259
35519
|
});
|
|
@@ -35271,6 +35531,8 @@ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
|
35271
35531
|
if (!cliSubcommandHandled) {
|
|
35272
35532
|
const localCliEnabled = isLocalCliEnabled(process.env);
|
|
35273
35533
|
const toolGroups = buildToolGroups(process.env);
|
|
35534
|
+
const fullRegistry = buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" });
|
|
35535
|
+
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35274
35536
|
const {
|
|
35275
35537
|
tools: allTools,
|
|
35276
35538
|
unknownGroups,
|
|
@@ -35289,6 +35551,10 @@ if (!cliSubcommandHandled) {
|
|
|
35289
35551
|
profile: process.env.TAILSCALE_PROFILE,
|
|
35290
35552
|
writeGroups: process.env.TAILSCALE_WRITE_GROUPS
|
|
35291
35553
|
});
|
|
35554
|
+
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35555
|
+
if (tailnetMismatch) {
|
|
35556
|
+
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35557
|
+
}
|
|
35292
35558
|
if (unknownGroups.length > 0) {
|
|
35293
35559
|
const validNames = Object.keys(toolGroups);
|
|
35294
35560
|
const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
|
|
@@ -35298,7 +35564,7 @@ if (!cliSubcommandHandled) {
|
|
|
35298
35564
|
}
|
|
35299
35565
|
if (unknownWriteGroups && unknownWriteGroups.length > 0) {
|
|
35300
35566
|
const validNames = Object.keys(toolGroups);
|
|
35301
|
-
const everyPossibleGroup = Object.keys(
|
|
35567
|
+
const everyPossibleGroup = Object.keys(fullRegistry);
|
|
35302
35568
|
const notEnabled = unknownWriteGroups.filter((g) => everyPossibleGroup.includes(g));
|
|
35303
35569
|
const realTypos = unknownWriteGroups.filter((g) => !everyPossibleGroup.includes(g));
|
|
35304
35570
|
if (notEnabled.length > 0) {
|
|
@@ -35325,10 +35591,6 @@ if (!cliSubcommandHandled) {
|
|
|
35325
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.`
|
|
35326
35592
|
);
|
|
35327
35593
|
}
|
|
35328
|
-
const tailnetMismatch = formatTailnetMismatchWarning(process.env);
|
|
35329
|
-
if (tailnetMismatch) {
|
|
35330
|
-
console.error(`@yawlabs/tailscale-mcp: ${tailnetMismatch}`);
|
|
35331
|
-
}
|
|
35332
35594
|
if (unknownProfile) {
|
|
35333
35595
|
console.error(
|
|
35334
35596
|
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
|
|
@@ -35344,7 +35606,7 @@ if (!cliSubcommandHandled) {
|
|
|
35344
35606
|
// that is currently disabled -- which is exactly the group an agent needs
|
|
35345
35607
|
// explained. Reporting only what loaded would make local-cli invisible rather
|
|
35346
35608
|
// than explained.
|
|
35347
|
-
fullRegistry
|
|
35609
|
+
fullRegistry,
|
|
35348
35610
|
// Ground truth for availability: what this server actually serves. Deliberately
|
|
35349
35611
|
// not a re-derivation of the filter logic, so the catalog cannot disagree with
|
|
35350
35612
|
// the server about what exists.
|
|
@@ -35352,8 +35614,10 @@ if (!cliSubcommandHandled) {
|
|
|
35352
35614
|
toolsEnv: process.env.TAILSCALE_TOOLS,
|
|
35353
35615
|
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
35354
35616
|
writeGroupsEnv: process.env.TAILSCALE_WRITE_GROUPS,
|
|
35355
|
-
readonlyMode
|
|
35356
|
-
|
|
35617
|
+
readonlyMode,
|
|
35618
|
+
readonlyEnv: process.env.TAILSCALE_READONLY,
|
|
35619
|
+
localCliEnabled,
|
|
35620
|
+
toolsIgnored: toolsAllUnknown
|
|
35357
35621
|
});
|
|
35358
35622
|
for (const tool of metaTools) {
|
|
35359
35623
|
server.registerTool(
|
|
@@ -35418,7 +35682,6 @@ if (!cliSubcommandHandled) {
|
|
|
35418
35682
|
`);
|
|
35419
35683
|
process.exit(1);
|
|
35420
35684
|
});
|
|
35421
|
-
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
35422
35685
|
const filterSuffix = formatBannerFilterSuffix({
|
|
35423
35686
|
unknownProfile,
|
|
35424
35687
|
explicitTools,
|
|
@@ -35432,15 +35695,22 @@ if (!cliSubcommandHandled) {
|
|
|
35432
35695
|
console.error(
|
|
35433
35696
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
35434
35697
|
);
|
|
35435
|
-
const hasCreds =
|
|
35436
|
-
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
|
+
};
|
|
35437
35704
|
const registeredNames = new Set(allTools.map((t) => t.name));
|
|
35438
|
-
const adminWritable = ADMIN_EQUIVALENT.filter(
|
|
35705
|
+
const adminWritable = Object.keys(ADMIN_EQUIVALENT).filter(
|
|
35439
35706
|
(g) => (toolGroups[g] ?? []).some((t) => t.annotations.readOnlyHint !== true && registeredNames.has(t.name))
|
|
35440
35707
|
);
|
|
35441
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}`;
|
|
35442
35712
|
console.error(
|
|
35443
|
-
`@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.`
|
|
35444
35714
|
);
|
|
35445
35715
|
}
|
|
35446
35716
|
if (!filterSuffix && hasCreds) {
|