@yawlabs/tailscale-mcp 0.12.9 → 0.13.2

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.
Files changed (3) hide show
  1. package/README.md +38 -1
  2. package/dist/index.js +243 -154
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -454,10 +454,47 @@ MCP Resources expose read-only data clients can browse without a tool call.
454
454
  For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent — use the built-in CLI:
455
455
 
456
456
  ```bash
457
+ # PR check: validate the proposed policy without touching the tailnet
458
+ npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
459
+
460
+ # On merge: ETag fetch + validate + deploy with If-Match, fail-closed at every step
457
461
  npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
458
462
  ```
459
463
 
460
- Handles ETag fetching, validation, and deployment in one command. Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars.
464
+ Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
465
+
466
+ A complete GitHub Actions workflow — validate on PR, deploy on merge:
467
+
468
+ ```yaml
469
+ name: tailscale-acl
470
+ on:
471
+ pull_request:
472
+ paths: ["tailscale/acl.json"]
473
+ push:
474
+ branches: [main]
475
+ paths: ["tailscale/acl.json"]
476
+
477
+ jobs:
478
+ acl:
479
+ runs-on: ubuntu-latest
480
+ env:
481
+ TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
482
+ TAILSCALE_TAILNET: your-tailnet.ts.net # or omit: defaults to the key's tailnet
483
+ steps:
484
+ - uses: actions/checkout@v4
485
+ - uses: actions/setup-node@v4
486
+ with: { node-version: 22 }
487
+ - name: Validate ACL
488
+ if: github.event_name == 'pull_request'
489
+ run: npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
490
+ - name: Deploy ACL
491
+ if: github.event_name == 'push'
492
+ run: npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
493
+ ```
494
+
495
+ For reproducible deploys, replace `@latest` with a pinned version.
496
+
497
+ > **If you hand-roll this with `curl` instead:** Tailscale's ACL endpoint only returns the `ETag` header on **GET**, not HEAD. A `curl -I` (HEAD) ETag fetch silently yields an empty value — and an empty `If-Match` either deploys unguarded (clobbering concurrent edits) or trips your guard and fails the deploy. Fetch the ETag with a GET (`curl -fsS -D - -o /dev/null ...`), and fail the job if it comes back empty. The CLI above does all of this for you.
461
498
 
462
499
  **Optional:** Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
463
500
 
package/dist/index.js CHANGED
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __commonJS = (cb, mod) => function __require() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ try {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ } catch (e) {
12
+ throw mod = 0, e;
13
+ }
10
14
  };
11
15
  var __export = (target, all) => {
12
16
  for (var name in all)
@@ -28506,11 +28510,11 @@ var Protocol = class {
28506
28510
  *
28507
28511
  * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
28508
28512
  */
28509
- async connect(transport2) {
28513
+ async connect(transport) {
28510
28514
  if (this._transport) {
28511
28515
  throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.");
28512
28516
  }
28513
- this._transport = transport2;
28517
+ this._transport = transport;
28514
28518
  const _onclose = this.transport?.onclose;
28515
28519
  this._transport.onclose = () => {
28516
28520
  _onclose?.();
@@ -30100,8 +30104,8 @@ var McpServer = class {
30100
30104
  *
30101
30105
  * The `server` object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
30102
30106
  */
30103
- async connect(transport2) {
30104
- return await this.server.connect(transport2);
30107
+ async connect(transport) {
30108
+ return await this.server.connect(transport);
30105
30109
  }
30106
30110
  /**
30107
30111
  * Closes the connection.
@@ -31158,6 +31162,19 @@ async function executeFetch(method, url2, headers, body, timeoutMs) {
31158
31162
  signal: AbortSignal.timeout(timeoutMs)
31159
31163
  });
31160
31164
  }
31165
+ function describeTransportError(err, method, attemptTimeoutMs) {
31166
+ if (err instanceof Error) {
31167
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
31168
+ return `${method} request timed out after ${attemptTimeoutMs}ms`;
31169
+ }
31170
+ const cause = err.cause;
31171
+ if (cause instanceof Error && cause.message) {
31172
+ return `${method} request failed: ${err.message} (${cause.message})`;
31173
+ }
31174
+ return `${method} request failed: ${err.message}`;
31175
+ }
31176
+ return `${method} request failed: ${String(err)}`;
31177
+ }
31161
31178
  async function apiRequest(method, path, body, options) {
31162
31179
  const headers = {};
31163
31180
  if (options?.accept) {
@@ -31175,6 +31192,13 @@ async function apiRequest(method, path, body, options) {
31175
31192
  fetchBody = JSON.stringify(body);
31176
31193
  }
31177
31194
  const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
31195
+ if (isAbsolute && !path.startsWith("https://api.tailscale.com/")) {
31196
+ return {
31197
+ ok: false,
31198
+ status: 0,
31199
+ error: `Absolute URL ${JSON.stringify(path)} is not on api.tailscale.com -- refusing to send an authenticated request elsewhere.`
31200
+ };
31201
+ }
31178
31202
  const url2 = isAbsolute ? path : `${BASE_URL}${path}`;
31179
31203
  const startedAt = Date.now();
31180
31204
  debugLog(`${method} ${url2}`);
@@ -31183,17 +31207,38 @@ async function apiRequest(method, path, body, options) {
31183
31207
  return withConcurrencyLimit(async () => {
31184
31208
  headers.Authorization = await getAuthHeader();
31185
31209
  let res;
31210
+ let lastTransportError;
31186
31211
  for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
31187
31212
  const remaining = requestBudgetMs - (Date.now() - startedAt);
31188
31213
  if (remaining <= 0) {
31189
31214
  return {
31190
31215
  ok: false,
31191
31216
  status: 0,
31192
- error: `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
31217
+ error: lastTransportError ? `${lastTransportError}; request budget of ${requestBudgetMs}ms exhausted before next attempt could begin.` : `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
31193
31218
  };
31194
31219
  }
31195
31220
  const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
31196
- res = await executeFetch(method, url2, headers, fetchBody, attemptTimeoutMs);
31221
+ let attemptRes;
31222
+ try {
31223
+ attemptRes = await executeFetch(method, url2, headers, fetchBody, attemptTimeoutMs);
31224
+ } catch (err) {
31225
+ const desc = describeTransportError(err, method, attemptTimeoutMs);
31226
+ lastTransportError = desc;
31227
+ if (!isRetryable || attempt === MAX_429_RETRIES) {
31228
+ return { ok: false, status: 0, error: desc };
31229
+ }
31230
+ const delay2 = compute429DelayMs(null, attempt);
31231
+ const elapsed3 = Date.now() - startedAt;
31232
+ if (requestBudgetMs - elapsed3 - delay2 <= 0) {
31233
+ return { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
31234
+ }
31235
+ debugLog(
31236
+ ` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
31237
+ );
31238
+ await new Promise((r) => setTimeout(r, delay2));
31239
+ continue;
31240
+ }
31241
+ res = attemptRes;
31197
31242
  if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
31198
31243
  const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
31199
31244
  const elapsed2 = Date.now() - startedAt;
@@ -31210,24 +31255,33 @@ async function apiRequest(method, path, body, options) {
31210
31255
  const etag = response.headers.get("etag") || void 0;
31211
31256
  const elapsed = Date.now() - startedAt;
31212
31257
  debugLog(` <- ${response.status} (${elapsed}ms)`);
31213
- if (options?.acceptRaw) {
31214
- const rawBody = await response.text();
31258
+ try {
31259
+ if (options?.acceptRaw) {
31260
+ const rawBody = await response.text();
31261
+ if (!response.ok) {
31262
+ const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, rawBody) : extractErrorMessage(rawBody);
31263
+ return { ok: false, status: response.status, error: error51, rawBody, etag };
31264
+ }
31265
+ return { ok: true, status: response.status, rawBody, etag };
31266
+ }
31215
31267
  if (!response.ok) {
31216
- const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, rawBody) : extractErrorMessage(rawBody);
31217
- return { ok: false, status: response.status, error: error51, rawBody, etag };
31268
+ const errorBody = await response.text();
31269
+ const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, errorBody) : extractErrorMessage(errorBody);
31270
+ return { ok: false, status: response.status, error: error51, etag };
31218
31271
  }
31219
- return { ok: true, status: response.status, rawBody, etag };
31220
- }
31221
- if (!response.ok) {
31222
- const errorBody = await response.text();
31223
- const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, errorBody) : extractErrorMessage(errorBody);
31224
- return { ok: false, status: response.status, error: error51, etag };
31225
- }
31226
- if (response.status === 204 || response.headers.get("content-length") === "0") {
31227
- return { ok: true, status: response.status, etag };
31272
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
31273
+ return { ok: true, status: response.status, etag };
31274
+ }
31275
+ const data = await response.json();
31276
+ return { ok: true, status: response.status, data, etag };
31277
+ } catch (err) {
31278
+ return {
31279
+ ok: false,
31280
+ status: response.status,
31281
+ error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${err instanceof Error ? err.message : String(err)}`,
31282
+ etag
31283
+ };
31228
31284
  }
31229
- const data = await response.json();
31230
- return { ok: true, status: response.status, data, etag };
31231
31285
  });
31232
31286
  }
31233
31287
  async function apiGet(path, options) {
@@ -31264,19 +31318,15 @@ function parseValidationError(rawBody) {
31264
31318
  if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
31265
31319
  return void 0;
31266
31320
  }
31267
- async function deployAcl(filePath) {
31268
- let policy;
31321
+ function readPolicyFile(filePath) {
31269
31322
  try {
31270
- policy = readFileSync(filePath, "utf-8");
31323
+ return readFileSync(filePath, "utf-8");
31271
31324
  } catch (err) {
31272
31325
  console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
31273
31326
  process.exit(1);
31274
31327
  }
31275
- const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31276
- if (!getRes.ok || !getRes.etag) {
31277
- console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
31278
- process.exit(1);
31279
- }
31328
+ }
31329
+ async function validatePolicy(policy) {
31280
31330
  const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
31281
31331
  rawBody: policy,
31282
31332
  contentType: "application/hujson",
@@ -31292,6 +31342,20 @@ async function deployAcl(filePath) {
31292
31342
  console.error(`ACL validation failed: ${validationError}`);
31293
31343
  process.exit(1);
31294
31344
  }
31345
+ }
31346
+ async function validateAcl(filePath) {
31347
+ const policy = readPolicyFile(filePath);
31348
+ await validatePolicy(policy);
31349
+ console.log("ACL policy is valid");
31350
+ }
31351
+ async function deployAcl(filePath) {
31352
+ const policy = readPolicyFile(filePath);
31353
+ const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
31354
+ if (!getRes.ok || !getRes.etag) {
31355
+ console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
31356
+ process.exit(1);
31357
+ }
31358
+ await validatePolicy(policy);
31295
31359
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
31296
31360
  rawBody: policy,
31297
31361
  contentType: "application/hujson",
@@ -31300,7 +31364,13 @@ async function deployAcl(filePath) {
31300
31364
  accept: "application/hujson"
31301
31365
  });
31302
31366
  if (!deployRes.ok) {
31303
- console.error(`ACL deploy failed: ${deployRes.error}`);
31367
+ if (deployRes.status === 412) {
31368
+ console.error(
31369
+ `ACL deploy failed: the tailnet ACL changed between the ETag fetch and the deploy (concurrent edit). Re-run to retry against the current version. (${deployRes.error})`
31370
+ );
31371
+ } else {
31372
+ console.error(`ACL deploy failed: ${deployRes.error}`);
31373
+ }
31304
31374
  process.exit(1);
31305
31375
  }
31306
31376
  console.log("ACL deployed successfully");
@@ -31319,25 +31389,25 @@ function parseReadonlyFlag(value) {
31319
31389
  function filterTools(groups, options) {
31320
31390
  const validNames = new Set(Object.keys(groups));
31321
31391
  let profileGroups;
31322
- let unknownProfile2;
31323
- let profileWouldFilter2 = false;
31392
+ let unknownProfile;
31393
+ let profileWouldFilter = false;
31324
31394
  if (options.profile) {
31325
31395
  const profileKey = options.profile.trim().toLowerCase();
31326
31396
  if (Object.hasOwn(PROFILES, profileKey)) {
31327
31397
  const preset = PROFILES[profileKey];
31328
- profileWouldFilter2 = preset.length > 0;
31329
- profileGroups = profileWouldFilter2 ? [...preset] : void 0;
31398
+ profileWouldFilter = preset.length > 0;
31399
+ profileGroups = profileWouldFilter ? [...preset] : void 0;
31330
31400
  } else {
31331
- unknownProfile2 = profileKey;
31401
+ unknownProfile = profileKey;
31332
31402
  }
31333
31403
  }
31334
31404
  const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
31335
- const explicitTools2 = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31336
- const explicitToolsAllUnknown = explicitTools2?.every((g) => !validNames.has(g)) ?? false;
31337
- const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools2;
31405
+ const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31406
+ const explicitToolsAllUnknown = explicitTools?.every((g) => !validNames.has(g)) ?? false;
31407
+ const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
31338
31408
  const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
31339
31409
  const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
31340
- const unknownGroups2 = explicitTools2 ? explicitTools2.filter((g) => !validNames.has(g)) : enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31410
+ const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31341
31411
  const readonly2 = parseReadonlyFlag(options.readonly);
31342
31412
  const out = [];
31343
31413
  for (const [name, tools] of Object.entries(groups)) {
@@ -31347,11 +31417,11 @@ function filterTools(groups, options) {
31347
31417
  out.push(t);
31348
31418
  }
31349
31419
  }
31350
- const result = { tools: out, unknownGroups: unknownGroups2 };
31351
- if (unknownProfile2) result.unknownProfile = unknownProfile2;
31420
+ const result = { tools: out, unknownGroups };
31421
+ if (unknownProfile) result.unknownProfile = unknownProfile;
31352
31422
  if (profileGroups && !effectiveExplicitTools) result.profileGroups = profileGroups;
31353
31423
  if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
31354
- if (profileWouldFilter2) result.profileWouldFilter = true;
31424
+ if (profileWouldFilter) result.profileWouldFilter = true;
31355
31425
  if (explicitToolsAllUnknown) result.toolsAllUnknown = true;
31356
31426
  return result;
31357
31427
  }
@@ -31565,8 +31635,11 @@ var aclTools = [
31565
31635
  acceptRaw: true,
31566
31636
  accept: "application/hujson"
31567
31637
  });
31568
- if (res.ok && !res.rawBody?.trim()) {
31569
- return { ...res, rawBody: "ACL policy is valid." };
31638
+ if (res.ok) {
31639
+ const trimmed = res.rawBody?.trim();
31640
+ if (!trimmed || trimmed === "{}") {
31641
+ return { ...res, rawBody: "ACL policy is valid." };
31642
+ }
31570
31643
  }
31571
31644
  return res;
31572
31645
  }
@@ -32516,7 +32589,7 @@ var keyTools = [
32516
32589
  },
32517
32590
  {
32518
32591
  name: "tailscale_create_key",
32519
- description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value \u2014 save it immediately, as it cannot be retrieved again.\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
32592
+ description: "Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value \u2014 save it immediately, as it cannot be retrieved again.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
32520
32593
  annotations: {
32521
32594
  title: "Create key",
32522
32595
  readOnlyHint: false,
@@ -32756,7 +32829,7 @@ var localCliTools = [
32756
32829
  "Target hostname, IP, or MagicDNS name (e.g. 'my-laptop', '100.64.0.1', 'my-laptop.tail-scale.ts.net')"
32757
32830
  ),
32758
32831
  count: external_exports.number().int().positive().max(20).optional().describe(
32759
- "Number of ping attempts (default 1, max 20). Higher counts give a better latency picture but block the tool call for longer."
32832
+ "Number of ping attempts (max 20). If omitted, no -c flag is passed and the tailscale CLI uses its built-in default (retries until a direct path is found, typically <10 attempts). Higher explicit counts give a more complete latency picture but block the tool call for longer."
32760
32833
  )
32761
32834
  }),
32762
32835
  handler: async (input) => {
@@ -32866,7 +32939,9 @@ var logStreamingTools = [
32866
32939
  logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
32867
32940
  destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
32868
32941
  url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations)"),
32869
- token: external_exports.string().optional().describe("Authentication token or API key for the destination"),
32942
+ token: external_exports.string().optional().describe(
32943
+ "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."
32944
+ ),
32870
32945
  user: external_exports.string().optional().describe("Username for the destination (if required)"),
32871
32946
  uploadPeriodMinutes: external_exports.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
32872
32947
  compressionFormat: external_exports.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
@@ -32877,7 +32952,9 @@ var logStreamingTools = [
32877
32952
  "(s3 only) Authentication mode. Required when destinationType is 's3'. Tailscale recommends 'rolearn'."
32878
32953
  ),
32879
32954
  s3AccessKeyId: external_exports.string().optional().describe("(s3 only) AWS access key id. Required when s3AuthenticationType is 'accesskey'."),
32880
- s3SecretAccessKey: external_exports.string().optional().describe("(s3 only) AWS secret access key. Required when s3AuthenticationType is 'accesskey'."),
32955
+ s3SecretAccessKey: external_exports.string().optional().describe(
32956
+ "(s3 only) AWS secret access key. Required when s3AuthenticationType is 'accesskey'. SENSITIVE: see the token field's note about MCP client logging."
32957
+ ),
32881
32958
  s3RoleArn: external_exports.string().optional().describe(
32882
32959
  "(s3 only) IAM role ARN that Tailscale will assume. Required when s3AuthenticationType is 'rolearn'."
32883
32960
  )
@@ -33051,7 +33128,9 @@ var postureTools = [
33051
33128
  clientId: external_exports.string().optional().describe(
33052
33129
  "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank)"
33053
33130
  ),
33054
- clientSecret: external_exports.string().describe("The secret (auth key, token, etc.) used to authenticate with the provider"),
33131
+ clientSecret: external_exports.string().describe(
33132
+ "The secret (auth key, token, etc.) used to authenticate with the provider. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
33133
+ ),
33055
33134
  tenantId: external_exports.string().optional().describe("Microsoft Intune directory (tenant) ID. Other providers leave blank."),
33056
33135
  cloudId: external_exports.string().optional().describe(
33057
33136
  "Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank."
@@ -33081,7 +33160,9 @@ var postureTools = [
33081
33160
  inputSchema: external_exports.object({
33082
33161
  integrationId: external_exports.string().describe("The posture integration ID to update"),
33083
33162
  clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
33084
- clientSecret: external_exports.string().optional().describe("Updated client secret for the provider (omit to retain the existing secret)"),
33163
+ clientSecret: external_exports.string().optional().describe(
33164
+ "Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
33165
+ ),
33085
33166
  tenantId: external_exports.string().optional().describe("Updated tenant ID"),
33086
33167
  cloudId: external_exports.string().optional().describe("Updated cloud identifier (e.g. 'us-1', 'global', or provider FQDN)")
33087
33168
  }),
@@ -33606,7 +33687,7 @@ var webhookTools = [
33606
33687
  },
33607
33688
  {
33608
33689
  name: "tailscale_create_webhook",
33609
- description: "Create a new webhook.",
33690
+ description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
33610
33691
  annotations: {
33611
33692
  title: "Create webhook",
33612
33693
  readOnlyHint: false,
@@ -33669,7 +33750,7 @@ var webhookTools = [
33669
33750
  },
33670
33751
  {
33671
33752
  name: "tailscale_rotate_webhook_secret",
33672
- 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.",
33753
+ 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.",
33673
33754
  annotations: {
33674
33755
  title: "Rotate webhook secret",
33675
33756
  readOnlyHint: false,
@@ -33706,125 +33787,133 @@ var webhookTools = [
33706
33787
  ];
33707
33788
 
33708
33789
  // src/index.ts
33709
- var version2 = true ? "0.12.9" : resolveVersionFallback();
33790
+ var version2 = true ? "0.13.2" : resolveVersionFallback();
33710
33791
  var subcommand = process.argv[2];
33711
- if (subcommand === "deploy-acl") {
33792
+ var cliSubcommandHandled = false;
33793
+ if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
33794
+ cliSubcommandHandled = true;
33712
33795
  const filePath = process.argv[3];
33713
33796
  if (!filePath) {
33714
- console.error("Usage: tailscale-mcp deploy-acl <path-to-acl.json>");
33797
+ console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
33715
33798
  process.exit(1);
33716
33799
  }
33717
- await deployAcl(filePath).catch((err) => {
33800
+ const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
33801
+ run(filePath).then(() => process.exit(0)).catch((err) => {
33718
33802
  console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
33719
33803
  process.exit(1);
33720
33804
  });
33721
- process.exit(0);
33722
33805
  } else if (subcommand === "version" || subcommand === "--version") {
33723
33806
  console.log(version2);
33724
33807
  process.exit(0);
33725
33808
  } else if (subcommand !== void 0) {
33726
33809
  console.error(
33727
- `@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, version. Starting the MCP server.`
33810
+ `@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version. Starting the MCP server.`
33728
33811
  );
33729
33812
  }
33730
- var toolGroups = {
33731
- status: statusTools,
33732
- devices: deviceTools,
33733
- acl: aclTools,
33734
- dns: dnsTools,
33735
- keys: keyTools,
33736
- users: userTools,
33737
- tailnet: tailnetTools,
33738
- webhooks: webhookTools,
33739
- posture: postureTools,
33740
- audit: auditTools,
33741
- invites: inviteTools,
33742
- services: serviceTools,
33743
- "log-streaming": logStreamingTools
33744
- };
33745
- var localCliEnabled = isLocalCliEnabled(process.env);
33746
- if (localCliEnabled) {
33747
- toolGroups["local-cli"] = localCliTools;
33748
- }
33749
- var {
33750
- tools: allTools,
33751
- unknownGroups,
33752
- unknownProfile,
33753
- explicitTools,
33754
- profileWouldFilter,
33755
- toolsAllUnknown
33756
- } = filterTools(toolGroups, {
33757
- tools: process.env.TAILSCALE_TOOLS,
33758
- readonly: process.env.TAILSCALE_READONLY,
33759
- profile: process.env.TAILSCALE_PROFILE
33760
- });
33761
- if (unknownGroups.length > 0) {
33762
- const validNames = Object.keys(toolGroups);
33763
- const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
33764
- console.error(
33765
- `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
33813
+ if (!cliSubcommandHandled) {
33814
+ const toolGroups = {
33815
+ status: statusTools,
33816
+ devices: deviceTools,
33817
+ acl: aclTools,
33818
+ dns: dnsTools,
33819
+ keys: keyTools,
33820
+ users: userTools,
33821
+ tailnet: tailnetTools,
33822
+ webhooks: webhookTools,
33823
+ posture: postureTools,
33824
+ audit: auditTools,
33825
+ invites: inviteTools,
33826
+ services: serviceTools,
33827
+ "log-streaming": logStreamingTools
33828
+ };
33829
+ const localCliEnabled = isLocalCliEnabled(process.env);
33830
+ if (localCliEnabled) {
33831
+ toolGroups["local-cli"] = localCliTools;
33832
+ }
33833
+ const {
33834
+ tools: allTools,
33835
+ unknownGroups,
33836
+ unknownProfile,
33837
+ explicitTools,
33838
+ profileWouldFilter,
33839
+ toolsAllUnknown
33840
+ } = filterTools(toolGroups, {
33841
+ tools: process.env.TAILSCALE_TOOLS,
33842
+ readonly: process.env.TAILSCALE_READONLY,
33843
+ profile: process.env.TAILSCALE_PROFILE
33844
+ });
33845
+ if (unknownGroups.length > 0) {
33846
+ const validNames = Object.keys(toolGroups);
33847
+ const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
33848
+ console.error(
33849
+ `@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
33850
+ );
33851
+ }
33852
+ if (unknownProfile) {
33853
+ console.error(
33854
+ `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
33855
+ );
33856
+ }
33857
+ const server = new McpServer({
33858
+ name: "@yawlabs/tailscale-mcp",
33859
+ version: version2
33860
+ });
33861
+ for (const tool of allTools) {
33862
+ server.tool(tool.name, tool.description, tool.inputSchema.shape, tool.annotations, wrapToolHandler(tool));
33863
+ }
33864
+ server.resource(
33865
+ "tailnet-status",
33866
+ "tailscale://tailnet/status",
33867
+ { description: "Current tailnet status including device count and settings", mimeType: "application/json" },
33868
+ tailnetStatusResource
33766
33869
  );
33767
- }
33768
- if (unknownProfile) {
33769
- console.error(
33770
- `@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
33870
+ server.resource(
33871
+ "tailnet-devices",
33872
+ "tailscale://tailnet/devices",
33873
+ { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
33874
+ tailnetDevicesResource
33771
33875
  );
33772
- }
33773
- var server = new McpServer({
33774
- name: "@yawlabs/tailscale-mcp",
33775
- version: version2
33776
- });
33777
- for (const tool of allTools) {
33778
- server.tool(tool.name, tool.description, tool.inputSchema.shape, tool.annotations, wrapToolHandler(tool));
33779
- }
33780
- server.resource(
33781
- "tailnet-status",
33782
- "tailscale://tailnet/status",
33783
- { description: "Current tailnet status including device count and settings", mimeType: "application/json" },
33784
- tailnetStatusResource
33785
- );
33786
- server.resource(
33787
- "tailnet-devices",
33788
- "tailscale://tailnet/devices",
33789
- { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
33790
- tailnetDevicesResource
33791
- );
33792
- server.resource(
33793
- "tailnet-acl",
33794
- "tailscale://tailnet/acl",
33795
- { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
33796
- tailnetAclResource
33797
- );
33798
- server.resource(
33799
- "tailnet-dns",
33800
- "tailscale://tailnet/dns",
33801
- {
33802
- description: "DNS configuration including nameservers, search paths, split DNS, and MagicDNS status",
33803
- mimeType: "application/json"
33804
- },
33805
- tailnetDnsResource
33806
- );
33807
- var transport = new StdioServerTransport();
33808
- await server.connect(transport);
33809
- var readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
33810
- var filterSuffix = formatBannerFilterSuffix({
33811
- unknownProfile,
33812
- explicitTools,
33813
- profileWouldFilter,
33814
- profileEnv: process.env.TAILSCALE_PROFILE,
33815
- readonlyMode,
33816
- localCliEnabled
33817
- });
33818
- console.error(
33819
- `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
33820
- );
33821
- var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
33822
- if (!filterSuffix && hasCreds) {
33823
- const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
33824
- const coreCount = profileCount(PROFILES.core);
33825
- const minimalCount = profileCount(PROFILES.minimal);
33876
+ server.resource(
33877
+ "tailnet-acl",
33878
+ "tailscale://tailnet/acl",
33879
+ { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
33880
+ tailnetAclResource
33881
+ );
33882
+ server.resource(
33883
+ "tailnet-dns",
33884
+ "tailscale://tailnet/dns",
33885
+ {
33886
+ description: "DNS configuration including nameservers, search paths, split DNS, and MagicDNS status",
33887
+ mimeType: "application/json"
33888
+ },
33889
+ tailnetDnsResource
33890
+ );
33891
+ const transport = new StdioServerTransport();
33892
+ server.connect(transport).catch((err) => {
33893
+ process.stderr.write(`tailscale-mcp: ${err instanceof Error ? err.message : String(err)}
33894
+ `);
33895
+ process.exit(1);
33896
+ });
33897
+ const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
33898
+ const filterSuffix = formatBannerFilterSuffix({
33899
+ unknownProfile,
33900
+ explicitTools,
33901
+ profileWouldFilter,
33902
+ profileEnv: process.env.TAILSCALE_PROFILE,
33903
+ readonlyMode,
33904
+ localCliEnabled
33905
+ });
33826
33906
  console.error(
33827
- `@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
33907
+ `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
33828
33908
  );
33909
+ const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
33910
+ if (!filterSuffix && hasCreds) {
33911
+ const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
33912
+ const coreCount = profileCount(PROFILES.core);
33913
+ const minimalCount = profileCount(PROFILES.minimal);
33914
+ console.error(
33915
+ `@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
33916
+ );
33917
+ }
33829
33918
  }
33830
33919
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.12.9",
3
+ "version": "0.13.2",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
6
6
  "license": "MIT",
@@ -38,7 +38,6 @@
38
38
  "lint:fix": "biome check --write src/",
39
39
  "prepublishOnly": "npm run clean && npm run build"
40
40
  },
41
- "dependencies": {},
42
41
  "overrides": {
43
42
  "hono": "^4.12.21",
44
43
  "@hono/node-server": "^1.19.13",
@@ -50,7 +49,8 @@
50
49
  "@biomejs/biome": "^2.4.12",
51
50
  "@modelcontextprotocol/sdk": "^1.29.0",
52
51
  "@types/node": "^25.6.0",
53
- "esbuild": "^0.28.0",
52
+ "esbuild": "^0.28.1",
53
+ "postject": "^1.0.0-alpha.6",
54
54
  "typescript": "^6.0.3",
55
55
  "zod": "^4.3.6"
56
56
  },