@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/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 (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4152
- if (uri.indexOf("%") !== -1) {
4153
- if (parsed.host !== void 0 && !malformedIPLiteral) {
4154
- const host = isIP2 ? parsed.host : normalizePercentEncoding(parsed.host, true);
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 && this._taskMessageQueue) {
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
- async function getOAuthAccessToken(clientId, clientSecret) {
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(REQUEST_TIMEOUT_MS)
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() + data.expires_in * 1e3
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 || method.toUpperCase() !== "DELETE") return error51;
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
- headers.Authorization = await getAuthHeader();
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: describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError)
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 { ok: false, status: 0, error: desc };
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 { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
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 !== 429) {
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 data = await response.json();
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
- console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
31825
- process.exit(1);
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.exit(1);
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.exit(1);
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.exit(1);
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.exit(1);
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 \u2014 you must pass it to tailscale_update_acl to safely update the policy.",
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 \u2014 safe to leave in or strip before re-submitting.)",
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 regardless; either way it sets `truncated`, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in `failed` and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.",
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(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
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 \u2014 useful for security monitoring and debugging connectivity.",
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 \u2014 the device must re-authenticate to rejoin.",
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
- deviceId: external_exports.string().describe(`The device ID to delete. ${DEVICE_ID_HINT}`)
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 \u2014 pass the full list of routes you want enabled.",
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 \u2014 pass the full list of tags you want applied.",
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 \u2014 pass null as the attribute config to delete.",
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
- splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).optional().describe("Map of domain to nameserver list for split DNS"),
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 \u2014 the invite link will stop working.",
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(["member", "admin", "it-admin", "network-admin", "billing-admin", "auditor"]).optional().describe("Role to assign to the invited user (default: member)")
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 \u2014 the invite link will stop working.",
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
- expirySeconds: external_exports.number().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
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
- keyId: external_exports.string().describe("The key ID to delete (auth key, API access token, OAuth client, or federated identity)")
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' \u2014 the Tailscale API will reject other fields.",
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 -- not a directory, and not a shell alias or function.`;
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.error || `HTTP ${configuration.status}`;
33805
- if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
33806
- if (!configuration.ok && !network.ok) {
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 handlers are called
33976
- // with the client's raw input, and `??` rather than `||` because false is a
33977
- // meaningful value.
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: services are created implicitly when a node first advertises one (`tailscale up --advertise-services=svc:name`); there is no API endpoint to create a service from this MCP. Use the update/delete/approval tools here once the service exists.",
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 \u2014 the service's MagicDNS name and virtual IP will be released.",
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
- serviceName: external_exports.string().describe("The service name to delete")
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 Promise.all([
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) => u.startsWith("https://"), "endpointUrl must use https://");
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 \u2014 the webhook secret cannot be recovered.",
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
- webhookId: external_exports.string().describe("The webhook ID to delete")
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 \u2014 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.",
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 profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
34976
- const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
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 Promise.all([
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 current = state.writeGroupsEnv?.trim();
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: current ? `TAILSCALE_WRITE_GROUPS is set to "${current}", which does not grant writes here` : "writes are withheld in this group",
35098
- toEnable: current ? `add "${group}" to TAILSCALE_WRITE_GROUPS (e.g. "${current},${group}")` : `add "${group}" to TAILSCALE_WRITE_GROUPS`
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 ? "TAILSCALE_READONLY=1" : null,
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.21.1" : resolveVersionFallback();
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(buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }));
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: buildToolGroups({ ...process.env, TAILSCALE_LOCAL_CLI: "1" }),
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: parseReadonlyFlag(process.env.TAILSCALE_READONLY),
35356
- localCliEnabled
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 = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
35436
- const ADMIN_EQUIVALENT = ["keys", "users", "acl"];
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. tailscale_create_key mints an OAuth client with any scopes the caller asks for, tailscale_update_user_role accepts "owner", and tailscale_update_acl rewrites policy for every principal. Scope the Tailscale OAuth client itself to the areas you need -- that bound survives outside this process; this one does not. TAILSCALE_WRITE_GROUPS narrows what this server exposes.`
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) {