@yawlabs/tailscale-mcp 0.11.0 → 0.12.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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +98 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
12
- [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Tailscale&command=npx&args=-y%2C%40yawlabs%2Ftailscale-mcp&env=TAILSCALE_API_KEY&description=Manage%20your%20Tailscale%20tailnet%20-%20devices%2C%20ACLs%2C%20DNS%2C%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Ftailscale-mcp)
12
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Tailscale&command=npx&args=-y%2C%40yawlabs%2Ftailscale-mcp&description=Manage%20your%20Tailscale%20tailnet%20-%20devices%2C%20ACLs%2C%20DNS%2C%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Ftailscale-mcp)
13
13
 
14
14
  One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
15
15
 
@@ -197,6 +197,8 @@ The server checks for an API key first, then falls back to OAuth. If neither is
197
197
 
198
198
  **`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including 429 retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the 429 immediately instead of holding the line. Tune lower if your MCP client has a tighter outer timeout. 429s on non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
199
199
 
200
+ **`TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB`** — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate `subscriptions` against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. Please also [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) so the static list catches up.
201
+
200
202
  **Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
201
203
 
202
204
  ## Local CLI integration (opt-in)
package/dist/index.js CHANGED
@@ -31288,18 +31288,20 @@ function filterTools(groups, options) {
31288
31288
  const validNames = new Set(Object.keys(groups));
31289
31289
  let profileGroups;
31290
31290
  let unknownProfile2;
31291
+ let profileWouldFilter2 = false;
31291
31292
  if (options.profile) {
31292
31293
  const profileKey = options.profile.trim().toLowerCase();
31293
31294
  if (Object.hasOwn(PROFILES, profileKey)) {
31294
31295
  const preset = PROFILES[profileKey];
31295
- profileGroups = preset.length > 0 ? [...preset] : void 0;
31296
+ profileWouldFilter2 = preset.length > 0;
31297
+ profileGroups = profileWouldFilter2 ? [...preset] : void 0;
31296
31298
  } else {
31297
31299
  unknownProfile2 = profileKey;
31298
31300
  }
31299
31301
  }
31300
31302
  const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
31301
- const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31302
- const effectiveGroups = explicitTools ?? profileGroups ?? null;
31303
+ const explicitTools2 = parsedTools && parsedTools.length > 0 ? parsedTools : null;
31304
+ const effectiveGroups = explicitTools2 ?? profileGroups ?? null;
31303
31305
  const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
31304
31306
  const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
31305
31307
  const readonly2 = options.readonly === "1" || options.readonly === "true";
@@ -31313,11 +31315,27 @@ function filterTools(groups, options) {
31313
31315
  }
31314
31316
  const result = { tools: out, unknownGroups: unknownGroups2 };
31315
31317
  if (unknownProfile2) result.unknownProfile = unknownProfile2;
31316
- if (profileGroups && !explicitTools) result.profileGroups = profileGroups;
31318
+ if (profileGroups && !explicitTools2) result.profileGroups = profileGroups;
31319
+ if (explicitTools2) result.explicitTools = explicitTools2;
31320
+ if (profileWouldFilter2) result.profileWouldFilter = true;
31317
31321
  return result;
31318
31322
  }
31319
31323
 
31320
31324
  // src/server-wiring.ts
31325
+ function isLocalCliEnabled(env) {
31326
+ return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
31327
+ }
31328
+ function formatBannerFilterSuffix(inputs) {
31329
+ const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
31330
+ const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
31331
+ const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
31332
+ return [
31333
+ profileLabel,
31334
+ groupsLabel,
31335
+ inputs.readonlyMode ? "readonly" : null,
31336
+ inputs.localCliEnabled ? "local-cli=on" : null
31337
+ ].filter(Boolean).join(", ");
31338
+ }
31321
31339
  function wrapToolHandler(tool) {
31322
31340
  return async (input) => {
31323
31341
  try {
@@ -31354,7 +31372,11 @@ async function tailnetStatusResource(uri) {
31354
31372
  ]);
31355
31373
  const data = {
31356
31374
  tailnet: getTailnet(),
31357
- deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
31375
+ // `?? null` (not `?? 0`): the request succeeded but the body was missing
31376
+ // a `devices` array (204 / empty content-length / unexpected shape).
31377
+ // Reporting `0` in that case would be confidently wrong; null signals
31378
+ // "unknown" so the caller doesn't conflate it with an actually-empty tailnet.
31379
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
31358
31380
  settings: settingsRes.ok ? settingsRes.data : null
31359
31381
  };
31360
31382
  const errors = {};
@@ -31623,6 +31645,11 @@ var deviceTools = [
31623
31645
  if (input.fields) params.set("fields", input.fields);
31624
31646
  if (input.filters) {
31625
31647
  for (const [key, value] of Object.entries(input.filters)) {
31648
+ if (key === "fields") {
31649
+ throw new Error(
31650
+ "filters.fields is not allowed -- use the top-level 'fields' parameter to select which device fields to return."
31651
+ );
31652
+ }
31626
31653
  params.set(key, value);
31627
31654
  }
31628
31655
  }
@@ -32627,10 +32654,12 @@ async function runTailscaleCli(args, options = {}) {
32627
32654
  }
32628
32655
 
32629
32656
  // src/tools/local-cli.ts
32657
+ var HOSTNAME_LABEL = /^[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?$/;
32630
32658
  function isValidPingTarget(s) {
32631
32659
  if (s.length === 0 || s.length > 253) return false;
32632
32660
  if (net2.isIP(s)) return true;
32633
- return /^[a-zA-Z0-9._-]+$/.test(s);
32661
+ const labels = s.split(".");
32662
+ return labels.every((label) => label.length >= 1 && label.length <= 63 && HOSTNAME_LABEL.test(label));
32634
32663
  }
32635
32664
  var localCliTools = [
32636
32665
  {
@@ -32805,6 +32834,21 @@ var logStreamingTools = [
32805
32834
  );
32806
32835
  }
32807
32836
  } else {
32837
+ const s3Only = [
32838
+ "s3Bucket",
32839
+ "s3Region",
32840
+ "s3KeyPrefix",
32841
+ "s3AuthenticationType",
32842
+ "s3AccessKeyId",
32843
+ "s3SecretAccessKey",
32844
+ "s3RoleArn"
32845
+ ];
32846
+ const wrongFields = s3Only.filter((f) => input[f] !== void 0);
32847
+ if (wrongFields.length > 0) {
32848
+ throw new Error(
32849
+ `${wrongFields.join(", ")} can only be used with destinationType 's3', not '${input.destinationType}'.`
32850
+ );
32851
+ }
32808
32852
  const missing = [];
32809
32853
  if (!input.url) missing.push("url");
32810
32854
  if (!input.token) missing.push("token");
@@ -33055,7 +33099,7 @@ var serviceTools = [
33055
33099
  ports: external_exports.array(
33056
33100
  external_exports.object({
33057
33101
  protocol: external_exports.enum(["tcp", "udp"]).describe("Protocol (tcp or udp)"),
33058
- port: external_exports.number().describe("Port number")
33102
+ port: external_exports.number().int().min(1).max(65535).describe("Port number (1-65535)")
33059
33103
  })
33060
33104
  ).optional().describe("Ports the service listens on"),
33061
33105
  tags: external_exports.array(external_exports.string()).optional().describe("ACL tags for the service"),
@@ -33176,7 +33220,11 @@ var statusTools = [
33176
33220
  const data = {
33177
33221
  connected: true,
33178
33222
  tailnet: getTailnet(),
33179
- deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
33223
+ // `?? null` (not `?? 0`): the request succeeded but the body was missing
33224
+ // a `devices` array (204 / empty content-length / unexpected shape).
33225
+ // Reporting `0` in that case would be confidently wrong; null signals
33226
+ // "unknown" so the caller doesn't conflate it with an actually-empty tailnet.
33227
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
33180
33228
  settings: settingsRes.ok ? settingsRes.data : null
33181
33229
  };
33182
33230
  const errors = {};
@@ -33451,7 +33499,7 @@ var userTools = [
33451
33499
  ];
33452
33500
 
33453
33501
  // src/tools/webhooks.ts
33454
- var webhookEventTypes = [
33502
+ var STATIC_WEBHOOK_EVENT_TYPES = [
33455
33503
  "nodeCreated",
33456
33504
  "nodeNeedsApproval",
33457
33505
  "nodeApproved",
@@ -33471,6 +33519,31 @@ var webhookEventTypes = [
33471
33519
  "subnetIPForwardingNotEnabled",
33472
33520
  "exitNodeIPForwardingNotEnabled"
33473
33521
  ];
33522
+ function getAllowedWebhookEvents() {
33523
+ const raw = process.env.TAILSCALE_EXTRA_WEBHOOK_EVENTS;
33524
+ if (!raw) return new Set(STATIC_WEBHOOK_EVENT_TYPES);
33525
+ const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
33526
+ return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
33527
+ }
33528
+ var webhookSubscriptionsSchema = external_exports.array(external_exports.string()).min(1).superRefine((arr, ctx) => {
33529
+ const allowed = getAllowedWebhookEvents();
33530
+ let knownEventsList = null;
33531
+ for (let i = 0; i < arr.length; i++) {
33532
+ const value = arr[i];
33533
+ if (!allowed.has(value)) {
33534
+ knownEventsList ??= [...allowed].sort().join(", ");
33535
+ ctx.addIssue({
33536
+ code: "custom",
33537
+ // `path: [i]` so the issue locates the bad element. Zod prepends the
33538
+ // parent path (e.g. "subscriptions") when reporting through the
33539
+ // surrounding object schema, producing a final path of
33540
+ // ["subscriptions", i] in error.issues.
33541
+ path: [i],
33542
+ message: `Unknown webhook event ${JSON.stringify(value)}. Known events: ${knownEventsList}. To allow a new event Tailscale has shipped before this package updates, set TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventName1,eventName2 in your MCP config.`
33543
+ });
33544
+ }
33545
+ }
33546
+ });
33474
33547
  var webhookTools = [
33475
33548
  {
33476
33549
  name: "tailscale_list_webhooks",
@@ -33516,7 +33589,7 @@ var webhookTools = [
33516
33589
  },
33517
33590
  inputSchema: external_exports.object({
33518
33591
  endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
33519
- subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).min(1).describe("Event types to subscribe to (at least one)")
33592
+ subscriptions: webhookSubscriptionsSchema.describe("Event types to subscribe to (at least one)")
33520
33593
  }),
33521
33594
  handler: async (input) => {
33522
33595
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -33538,7 +33611,7 @@ var webhookTools = [
33538
33611
  inputSchema: external_exports.object({
33539
33612
  webhookId: external_exports.string().describe("The webhook ID to update"),
33540
33613
  endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
33541
- subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).min(1).optional().describe("Updated list of event types to subscribe to (at least one)")
33614
+ subscriptions: webhookSubscriptionsSchema.optional().describe("Updated list of event types to subscribe to (at least one)")
33542
33615
  }),
33543
33616
  handler: async (input) => {
33544
33617
  const body = {};
@@ -33606,7 +33679,7 @@ var webhookTools = [
33606
33679
  ];
33607
33680
 
33608
33681
  // src/index.ts
33609
- var version2 = true ? "0.11.0" : (await null).createRequire(import.meta.url)("../package.json").version;
33682
+ var version2 = true ? "0.12.0" : (await null).createRequire(import.meta.url)("../package.json").version;
33610
33683
  var subcommand = process.argv[2];
33611
33684
  if (subcommand === "deploy-acl") {
33612
33685
  const filePath = process.argv[3];
@@ -33638,13 +33711,16 @@ var toolGroups = {
33638
33711
  services: serviceTools,
33639
33712
  "log-streaming": logStreamingTools
33640
33713
  };
33641
- if (process.env.TAILSCALE_LOCAL_CLI === "1" || process.env.TAILSCALE_LOCAL_CLI === "true") {
33714
+ var localCliEnabled = isLocalCliEnabled(process.env);
33715
+ if (localCliEnabled) {
33642
33716
  toolGroups["local-cli"] = localCliTools;
33643
33717
  }
33644
33718
  var {
33645
33719
  tools: allTools,
33646
33720
  unknownGroups,
33647
- unknownProfile
33721
+ unknownProfile,
33722
+ explicitTools,
33723
+ profileWouldFilter
33648
33724
  } = filterTools(toolGroups, {
33649
33725
  tools: process.env.TAILSCALE_TOOLS,
33650
33726
  readonly: process.env.TAILSCALE_READONLY,
@@ -33698,14 +33774,14 @@ server.resource(
33698
33774
  var transport = new StdioServerTransport();
33699
33775
  await server.connect(transport);
33700
33776
  var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
33701
- var profileApplied = process.env.TAILSCALE_PROFILE && !unknownProfile ? process.env.TAILSCALE_PROFILE : null;
33702
- var localCliEnabled = process.env.TAILSCALE_LOCAL_CLI === "1" || process.env.TAILSCALE_LOCAL_CLI === "true";
33703
- var filterSuffix = [
33704
- profileApplied ? `profile=${profileApplied}` : null,
33705
- process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
33706
- readonlyMode ? "readonly" : null,
33707
- localCliEnabled ? "local-cli=on" : null
33708
- ].filter(Boolean).join(", ");
33777
+ var filterSuffix = formatBannerFilterSuffix({
33778
+ unknownProfile,
33779
+ explicitTools,
33780
+ profileWouldFilter,
33781
+ profileEnv: process.env.TAILSCALE_PROFILE,
33782
+ readonlyMode,
33783
+ localCliEnabled
33784
+ });
33709
33785
  console.error(
33710
33786
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
33711
33787
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",