@yawlabs/tailscale-mcp 0.11.1 → 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 +90 -19
  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,7 +31315,9 @@ 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
 
@@ -31321,6 +31325,17 @@ function filterTools(groups, options) {
31321
31325
  function isLocalCliEnabled(env) {
31322
31326
  return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
31323
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
+ }
31324
31339
  function wrapToolHandler(tool) {
31325
31340
  return async (input) => {
31326
31341
  try {
@@ -31357,7 +31372,11 @@ async function tailnetStatusResource(uri) {
31357
31372
  ]);
31358
31373
  const data = {
31359
31374
  tailnet: getTailnet(),
31360
- 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,
31361
31380
  settings: settingsRes.ok ? settingsRes.data : null
31362
31381
  };
31363
31382
  const errors = {};
@@ -31626,6 +31645,11 @@ var deviceTools = [
31626
31645
  if (input.fields) params.set("fields", input.fields);
31627
31646
  if (input.filters) {
31628
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
+ }
31629
31653
  params.set(key, value);
31630
31654
  }
31631
31655
  }
@@ -32810,6 +32834,21 @@ var logStreamingTools = [
32810
32834
  );
32811
32835
  }
32812
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
+ }
32813
32852
  const missing = [];
32814
32853
  if (!input.url) missing.push("url");
32815
32854
  if (!input.token) missing.push("token");
@@ -33060,7 +33099,7 @@ var serviceTools = [
33060
33099
  ports: external_exports.array(
33061
33100
  external_exports.object({
33062
33101
  protocol: external_exports.enum(["tcp", "udp"]).describe("Protocol (tcp or udp)"),
33063
- port: external_exports.number().describe("Port number")
33102
+ port: external_exports.number().int().min(1).max(65535).describe("Port number (1-65535)")
33064
33103
  })
33065
33104
  ).optional().describe("Ports the service listens on"),
33066
33105
  tags: external_exports.array(external_exports.string()).optional().describe("ACL tags for the service"),
@@ -33181,7 +33220,11 @@ var statusTools = [
33181
33220
  const data = {
33182
33221
  connected: true,
33183
33222
  tailnet: getTailnet(),
33184
- 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,
33185
33228
  settings: settingsRes.ok ? settingsRes.data : null
33186
33229
  };
33187
33230
  const errors = {};
@@ -33456,7 +33499,7 @@ var userTools = [
33456
33499
  ];
33457
33500
 
33458
33501
  // src/tools/webhooks.ts
33459
- var webhookEventTypes = [
33502
+ var STATIC_WEBHOOK_EVENT_TYPES = [
33460
33503
  "nodeCreated",
33461
33504
  "nodeNeedsApproval",
33462
33505
  "nodeApproved",
@@ -33476,6 +33519,31 @@ var webhookEventTypes = [
33476
33519
  "subnetIPForwardingNotEnabled",
33477
33520
  "exitNodeIPForwardingNotEnabled"
33478
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
+ });
33479
33547
  var webhookTools = [
33480
33548
  {
33481
33549
  name: "tailscale_list_webhooks",
@@ -33521,7 +33589,7 @@ var webhookTools = [
33521
33589
  },
33522
33590
  inputSchema: external_exports.object({
33523
33591
  endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
33524
- 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)")
33525
33593
  }),
33526
33594
  handler: async (input) => {
33527
33595
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -33543,7 +33611,7 @@ var webhookTools = [
33543
33611
  inputSchema: external_exports.object({
33544
33612
  webhookId: external_exports.string().describe("The webhook ID to update"),
33545
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"),
33546
- 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)")
33547
33615
  }),
33548
33616
  handler: async (input) => {
33549
33617
  const body = {};
@@ -33611,7 +33679,7 @@ var webhookTools = [
33611
33679
  ];
33612
33680
 
33613
33681
  // src/index.ts
33614
- var version2 = true ? "0.11.1" : (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;
33615
33683
  var subcommand = process.argv[2];
33616
33684
  if (subcommand === "deploy-acl") {
33617
33685
  const filePath = process.argv[3];
@@ -33650,7 +33718,9 @@ if (localCliEnabled) {
33650
33718
  var {
33651
33719
  tools: allTools,
33652
33720
  unknownGroups,
33653
- unknownProfile
33721
+ unknownProfile,
33722
+ explicitTools,
33723
+ profileWouldFilter
33654
33724
  } = filterTools(toolGroups, {
33655
33725
  tools: process.env.TAILSCALE_TOOLS,
33656
33726
  readonly: process.env.TAILSCALE_READONLY,
@@ -33704,13 +33774,14 @@ server.resource(
33704
33774
  var transport = new StdioServerTransport();
33705
33775
  await server.connect(transport);
33706
33776
  var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
33707
- var profileApplied = process.env.TAILSCALE_PROFILE && !unknownProfile ? process.env.TAILSCALE_PROFILE : null;
33708
- var filterSuffix = [
33709
- profileApplied ? `profile=${profileApplied}` : null,
33710
- process.env.TAILSCALE_TOOLS ? `groups=${process.env.TAILSCALE_TOOLS}` : null,
33711
- readonlyMode ? "readonly" : null,
33712
- localCliEnabled ? "local-cli=on" : null
33713
- ].filter(Boolean).join(", ");
33777
+ var filterSuffix = formatBannerFilterSuffix({
33778
+ unknownProfile,
33779
+ explicitTools,
33780
+ profileWouldFilter,
33781
+ profileEnv: process.env.TAILSCALE_PROFILE,
33782
+ readonlyMode,
33783
+ localCliEnabled
33784
+ });
33714
33785
  console.error(
33715
33786
  `@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
33716
33787
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.11.1",
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>",