@yawlabs/tailscale-mcp 0.5.1 → 0.6.3

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 +4 -3
  2. package/dist/index.js +211 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![CI](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml)
7
7
 
8
- **Manage your Tailscale tailnet from Claude Code, Cursor, and any MCP client.** 98 tools + 4 resources. One env var. Works on first try.
8
+ **Manage your Tailscale tailnet from Claude Code, Cursor, and any MCP client.** 99 tools + 4 resources. One env var. Works on first try.
9
9
 
10
10
  Built and maintained by [YawLabs](https://yaw.sh).
11
11
 
@@ -96,7 +96,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
96
96
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
97
97
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
98
98
 
99
- ## Tools (98)
99
+ ## Tools (99)
100
100
 
101
101
  <details>
102
102
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -297,7 +297,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
297
297
  </details>
298
298
 
299
299
  <details>
300
- <summary><strong>Device Invites</strong> (5 tools)</summary>
300
+ <summary><strong>Device Invites</strong> (6 tools)</summary>
301
301
 
302
302
  | Tool | Description |
303
303
  |------|-------------|
@@ -305,6 +305,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
305
305
  | `tailscale_create_device_invite` | Create a device invite |
306
306
  | `tailscale_get_device_invite` | Get a device invite |
307
307
  | `tailscale_delete_device_invite` | Delete a device invite |
308
+ | `tailscale_accept_device_invite` | Accept a device share invitation |
308
309
  | `tailscale_resend_device_invite` | Resend a device invite email |
309
310
 
310
311
  </details>
package/dist/index.js CHANGED
@@ -21079,6 +21079,9 @@ function getTailnet() {
21079
21079
  function encPath(segment) {
21080
21080
  return encodeURIComponent(segment);
21081
21081
  }
21082
+ function sanitizeDescription(value) {
21083
+ return value.replace(/[/_]/g, "-").replace(/[^a-zA-Z0-9 -]/g, "").replace(/ {2,}/g, " ").trim().slice(0, 50);
21084
+ }
21082
21085
  async function apiRequest(method, path, body, options) {
21083
21086
  const auth = await getAuthHeader();
21084
21087
  const headers = {
@@ -21255,7 +21258,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21255
21258
  acceptRaw: true,
21256
21259
  accept: "application/hujson"
21257
21260
  });
21258
- if (res.ok && !res.rawBody) {
21261
+ if (res.ok && (!res.rawBody || !res.rawBody.trim())) {
21259
21262
  return { ...res, rawBody: "ACL policy is valid." };
21260
21263
  }
21261
21264
  return res;
@@ -21290,8 +21293,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21290
21293
 
21291
21294
  // src/tools/audit.ts
21292
21295
  function assertRFC3339(value, label) {
21293
- const d = new Date(value);
21294
- if (Number.isNaN(d.getTime())) {
21296
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
21295
21297
  throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
21296
21298
  }
21297
21299
  }
@@ -21357,11 +21359,21 @@ var deviceTools = [
21357
21359
  inputSchema: external_exports.object({
21358
21360
  fields: external_exports.string().optional().describe(
21359
21361
  "Comma-separated list of fields to include. Omit for all fields. Valid fields: addresses, advertisedRoutes, authorized, blocksIncomingConnections, clientConnectivity, clientVersion, connectedToControl, created, distro, enabledRoutes, expires, hostname, id, isExternal, keyExpiryDisabled, lastSeen, machineKey, name, nodeId, nodeKey, os, sshEnabled, tags, tailnetLockError, tailnetLockKey, updateAvailable, user. Use 'all' for every field."
21362
+ ),
21363
+ filters: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
21364
+ "Server-side filters as key-value pairs. Filter by any top-level device property (e.g. { isEphemeral: 'true', os: 'linux', tags: 'tag:prod' }). Multiple filters are ANDed together."
21360
21365
  )
21361
21366
  }),
21362
21367
  handler: async (input) => {
21363
- const params = input.fields ? `?${new URLSearchParams({ fields: input.fields })}` : "";
21364
- return apiGet(`/tailnet/${getTailnet()}/devices${params}`);
21368
+ const params = new URLSearchParams();
21369
+ if (input.fields) params.set("fields", input.fields);
21370
+ if (input.filters) {
21371
+ for (const [key, value] of Object.entries(input.filters)) {
21372
+ params.set(key, value);
21373
+ }
21374
+ }
21375
+ const qs = params.toString();
21376
+ return apiGet(`/tailnet/${getTailnet()}/devices${qs ? `?${qs}` : ""}`);
21365
21377
  }
21366
21378
  },
21367
21379
  {
@@ -21375,7 +21387,7 @@ var deviceTools = [
21375
21387
  openWorldHint: true
21376
21388
  },
21377
21389
  inputSchema: external_exports.object({
21378
- deviceId: external_exports.string().describe("The device ID (numeric or nodekey format)")
21390
+ deviceId: external_exports.string().describe("The device ID (numeric id or nodeId, NOT the nodeKey)")
21379
21391
  }),
21380
21392
  handler: async (input) => {
21381
21393
  return apiGet(`/device/${encPath(input.deviceId)}`);
@@ -21563,6 +21575,9 @@ var deviceTools = [
21563
21575
  attributeKey: external_exports.string().describe("The attribute key to delete (e.g. 'custom:lastAuditDate')")
21564
21576
  }),
21565
21577
  handler: async (input) => {
21578
+ if (!input.attributeKey.startsWith("custom:")) {
21579
+ throw new Error(`attributeKey must start with 'custom:' prefix, got: '${input.attributeKey}'`);
21580
+ }
21566
21581
  return apiDelete(`/device/${encPath(input.deviceId)}/attributes/${encPath(input.attributeKey)}`);
21567
21582
  }
21568
21583
  },
@@ -21642,6 +21657,17 @@ var deviceTools = [
21642
21657
  )
21643
21658
  }),
21644
21659
  handler: async (input) => {
21660
+ const invalidKeys = [];
21661
+ for (const attrs of Object.values(input.attributes)) {
21662
+ for (const key of Object.keys(attrs)) {
21663
+ if (!key.startsWith("custom:")) invalidKeys.push(key);
21664
+ }
21665
+ }
21666
+ if (invalidKeys.length > 0) {
21667
+ throw new Error(
21668
+ `All attribute keys must start with 'custom:' prefix. Invalid keys: ${[...new Set(invalidKeys)].join(", ")}`
21669
+ );
21670
+ }
21645
21671
  return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, input.attributes);
21646
21672
  }
21647
21673
  }
@@ -21865,7 +21891,7 @@ var inviteTools = [
21865
21891
  },
21866
21892
  {
21867
21893
  name: "tailscale_create_device_invite",
21868
- description: "Create a new device invite that allows someone to add a specific device to your tailnet.",
21894
+ description: "Create a device share invitation that allows an external user to access a specific device in your tailnet.",
21869
21895
  annotations: {
21870
21896
  title: "Create device invite",
21871
21897
  readOnlyHint: false,
@@ -21921,6 +21947,23 @@ var inviteTools = [
21921
21947
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
21922
21948
  }
21923
21949
  },
21950
+ {
21951
+ name: "tailscale_accept_device_invite",
21952
+ description: "Accept a device share invitation using the invite URL or code.",
21953
+ annotations: {
21954
+ title: "Accept device invite",
21955
+ readOnlyHint: false,
21956
+ destructiveHint: false,
21957
+ idempotentHint: true,
21958
+ openWorldHint: true
21959
+ },
21960
+ inputSchema: external_exports.object({
21961
+ invite: external_exports.string().describe("The device invite URL or invite code")
21962
+ }),
21963
+ handler: async (input) => {
21964
+ return apiPost("/device-invites/-/accept", { invite: input.invite });
21965
+ }
21966
+ },
21924
21967
  // --- User Invites ---
21925
21968
  {
21926
21969
  name: "tailscale_list_user_invites",
@@ -22032,17 +22075,20 @@ var inviteTools = [
22032
22075
  var keyTools = [
22033
22076
  {
22034
22077
  name: "tailscale_list_keys",
22035
- description: "List all auth keys in your tailnet.",
22078
+ description: "List keys in your tailnet. By default lists auth keys only. Set 'all' to true to include OAuth clients and federated identities.",
22036
22079
  annotations: {
22037
- title: "List auth keys",
22080
+ title: "List keys",
22038
22081
  readOnlyHint: true,
22039
22082
  destructiveHint: false,
22040
22083
  idempotentHint: true,
22041
22084
  openWorldHint: true
22042
22085
  },
22043
- inputSchema: external_exports.object({}),
22044
- handler: async () => {
22045
- return apiGet(`/tailnet/${getTailnet()}/keys`);
22086
+ inputSchema: external_exports.object({
22087
+ all: external_exports.boolean().optional().describe("When true, returns all key types (auth keys, OAuth clients, federated identities). Default: false")
22088
+ }),
22089
+ handler: async (input) => {
22090
+ const qs = input.all ? "?all=true" : "";
22091
+ return apiGet(`/tailnet/${getTailnet()}/keys${qs}`);
22046
22092
  }
22047
22093
  },
22048
22094
  {
@@ -22064,21 +22110,35 @@ var keyTools = [
22064
22110
  },
22065
22111
  {
22066
22112
  name: "tailscale_create_key",
22067
- description: "Create a new auth key for adding devices to your tailnet. Returns the key value \u2014 save it immediately, as it cannot be retrieved again.",
22113
+ 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.",
22068
22114
  annotations: {
22069
- title: "Create auth key",
22115
+ title: "Create key",
22070
22116
  readOnlyHint: false,
22071
22117
  destructiveHint: false,
22072
22118
  idempotentHint: false,
22073
22119
  openWorldHint: true
22074
22120
  },
22075
22121
  inputSchema: external_exports.object({
22076
- reusable: external_exports.boolean().optional().describe("Whether the key can be used more than once (default: false)"),
22077
- ephemeral: external_exports.boolean().optional().describe("Whether devices using this key are ephemeral (default: false)"),
22078
- preauthorized: external_exports.boolean().optional().describe("Whether devices are pre-authorized (default: false)"),
22079
- tags: external_exports.array(external_exports.string()).optional().describe("ACL tags to apply to devices using this key"),
22080
- expirySeconds: external_exports.number().optional().describe("Key expiry in seconds (default: 90 days)"),
22081
- description: external_exports.string().optional().describe("Description for this key")
22122
+ keyType: external_exports.enum(["auth", "client", "federated"]).optional().describe(
22123
+ "Key type: 'auth' (default) for device auth keys, 'client' for OAuth clients, 'federated' for OIDC federation"
22124
+ ),
22125
+ description: external_exports.string().optional().describe("Description for this key (max 50 chars, alphanumeric/hyphens/spaces)"),
22126
+ // Auth key fields
22127
+ reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
22128
+ ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
22129
+ preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
22130
+ expirySeconds: external_exports.number().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
22131
+ // Shared fields
22132
+ tags: external_exports.array(external_exports.string()).optional().describe(
22133
+ "ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
22134
+ ),
22135
+ // Client + Federated fields
22136
+ scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl'])"),
22137
+ // Federated-only fields
22138
+ issuer: external_exports.string().optional().describe("(federated only) OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com')"),
22139
+ subject: external_exports.string().optional().describe("(federated only) Expected subject claim, supports * wildcards"),
22140
+ audience: external_exports.string().optional().describe("(federated only) Expected audience claim"),
22141
+ customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Custom claim mapping rules")
22082
22142
  }),
22083
22143
  handler: async (input) => {
22084
22144
  if (input.tags && input.tags.length > 0) {
@@ -22087,8 +22147,19 @@ var keyTools = [
22087
22147
  throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22088
22148
  }
22089
22149
  }
22090
- const body = {
22091
- capabilities: {
22150
+ const keyType = input.keyType ?? "auth";
22151
+ if (keyType !== "auth") {
22152
+ const authOnlyFields = ["reusable", "ephemeral", "preauthorized", "expirySeconds"];
22153
+ const wrongFields = authOnlyFields.filter((f) => input[f] !== void 0);
22154
+ if (wrongFields.length > 0) {
22155
+ throw new Error(`${wrongFields.join(", ")} can only be used with keyType 'auth', not '${keyType}'`);
22156
+ }
22157
+ }
22158
+ const body = {};
22159
+ if (keyType !== "auth") body.keyType = keyType;
22160
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22161
+ if (keyType === "auth") {
22162
+ body.capabilities = {
22092
22163
  devices: {
22093
22164
  create: {
22094
22165
  reusable: input.reusable ?? false,
@@ -22097,10 +22168,23 @@ var keyTools = [
22097
22168
  tags: input.tags ?? []
22098
22169
  }
22099
22170
  }
22171
+ };
22172
+ if (input.expirySeconds !== void 0) body.expirySeconds = input.expirySeconds;
22173
+ } else {
22174
+ if (!input.scopes || input.scopes.length === 0) {
22175
+ throw new Error(`scopes are required for keyType '${keyType}'`);
22100
22176
  }
22101
- };
22102
- if (input.expirySeconds !== void 0) body.expirySeconds = input.expirySeconds;
22103
- if (input.description !== void 0) body.description = input.description;
22177
+ body.scopes = input.scopes;
22178
+ if (input.tags) body.tags = input.tags;
22179
+ if (keyType === "federated") {
22180
+ if (!input.issuer) throw new Error("issuer is required for federated keys");
22181
+ if (!input.subject) throw new Error("subject is required for federated keys");
22182
+ body.issuer = input.issuer;
22183
+ body.subject = input.subject;
22184
+ if (input.audience !== void 0) body.audience = input.audience;
22185
+ if (input.customClaimRules !== void 0) body.customClaimRules = input.customClaimRules;
22186
+ }
22187
+ }
22104
22188
  return apiPost(`/tailnet/${getTailnet()}/keys`, body);
22105
22189
  }
22106
22190
  },
@@ -22123,21 +22207,42 @@ var keyTools = [
22123
22207
  },
22124
22208
  {
22125
22209
  name: "tailscale_update_key",
22126
- description: "Update an existing auth key's description or capabilities.",
22210
+ description: "Update an existing key's description or configuration. For OAuth clients and federated identities, scopes, tags, and identity fields can also be updated. Auth keys only support description updates.",
22127
22211
  annotations: {
22128
- title: "Update auth key",
22212
+ title: "Update key",
22129
22213
  readOnlyHint: false,
22130
22214
  destructiveHint: false,
22131
22215
  idempotentHint: true,
22132
22216
  openWorldHint: true
22133
22217
  },
22134
22218
  inputSchema: external_exports.object({
22135
- keyId: external_exports.string().describe("The auth key ID to update"),
22136
- description: external_exports.string().optional().describe("Updated description for the key")
22219
+ keyId: external_exports.string().describe("The key ID to update"),
22220
+ description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
22221
+ scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
22222
+ tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
22223
+ issuer: external_exports.string().optional().describe("(federated only) Updated OIDC issuer URL"),
22224
+ subject: external_exports.string().optional().describe("(federated only) Updated subject claim pattern"),
22225
+ audience: external_exports.string().optional().describe("(federated only) Updated audience claim"),
22226
+ customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Updated custom claim rules")
22137
22227
  }),
22138
22228
  handler: async (input) => {
22229
+ if (input.tags && input.tags.length > 0) {
22230
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22231
+ if (invalid.length > 0) {
22232
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22233
+ }
22234
+ }
22139
22235
  const body = {};
22140
- if (input.description !== void 0) body.description = input.description;
22236
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22237
+ if (input.scopes !== void 0) body.scopes = input.scopes;
22238
+ if (input.tags !== void 0) body.tags = input.tags;
22239
+ if (input.issuer !== void 0) body.issuer = input.issuer;
22240
+ if (input.subject !== void 0) body.subject = input.subject;
22241
+ if (input.audience !== void 0) body.audience = input.audience;
22242
+ if (input.customClaimRules !== void 0) body.customClaimRules = input.customClaimRules;
22243
+ if (Object.keys(body).length === 0) {
22244
+ throw new Error("No fields to update. Provide at least one field (description, scopes, tags, etc.).");
22245
+ }
22141
22246
  return apiPut(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`, body);
22142
22247
  }
22143
22248
  }
@@ -22200,9 +22305,7 @@ var logStreamingTools = [
22200
22305
  },
22201
22306
  inputSchema: external_exports.object({
22202
22307
  logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
22203
- destinationType: external_exports.string().describe(
22204
- "The destination type (e.g. 'axiom', 'datadog', 'splunk', 'elasticsearch', 'panther', 's3', 'gcs', 'cribl')"
22205
- ),
22308
+ destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
22206
22309
  url: external_exports.string().optional().describe("Destination URL (required for most destination types)"),
22207
22310
  token: external_exports.string().optional().describe("Authentication token or API key for the destination"),
22208
22311
  user: external_exports.string().optional().describe("Username for the destination (if required)")
@@ -22352,12 +22455,12 @@ var oauthClientTools = [
22352
22455
  openWorldHint: true
22353
22456
  },
22354
22457
  inputSchema: external_exports.object({
22355
- name: external_exports.string().describe("A human-readable name for this OAuth client"),
22458
+ name: external_exports.string().describe("A human-readable name for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)"),
22356
22459
  scopes: external_exports.array(external_exports.string()).describe(
22357
22460
  "OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl']). See Tailscale docs for available scopes."
22358
22461
  ),
22359
22462
  tags: external_exports.array(external_exports.string()).optional().describe("ACL tags to assign to the OAuth client"),
22360
- description: external_exports.string().optional().describe("Description for this OAuth client")
22463
+ description: external_exports.string().optional().describe("Description for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)")
22361
22464
  }),
22362
22465
  handler: async (input) => {
22363
22466
  if (input.tags && input.tags.length > 0) {
@@ -22366,7 +22469,10 @@ var oauthClientTools = [
22366
22469
  throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22367
22470
  }
22368
22471
  }
22369
- return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, input);
22472
+ const body = { ...input };
22473
+ body.name = sanitizeDescription(input.name);
22474
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22475
+ return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, body);
22370
22476
  }
22371
22477
  },
22372
22478
  {
@@ -22391,6 +22497,12 @@ var oauthClientTools = [
22391
22497
  for (const [key, value] of Object.entries(body)) {
22392
22498
  if (value !== void 0) cleanBody[key] = value;
22393
22499
  }
22500
+ if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
22501
+ if (cleanBody.description !== void 0)
22502
+ cleanBody.description = sanitizeDescription(cleanBody.description);
22503
+ if (Object.keys(cleanBody).length === 0) {
22504
+ throw new Error("No fields to update. Provide at least one of: name, scopes, description.");
22505
+ }
22394
22506
  return apiPatch(`/tailnet/${getTailnet()}/oauth-clients/${encPath(clientId)}`, cleanBody);
22395
22507
  }
22396
22508
  },
@@ -22465,7 +22577,14 @@ var postureTools = [
22465
22577
  cloudEnvironment: external_exports.string().optional().describe("Cloud environment (e.g. 'us-1', 'eu-1')")
22466
22578
  }),
22467
22579
  handler: async (input) => {
22468
- return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, input);
22580
+ const body = {
22581
+ provider: input.provider,
22582
+ clientId: input.clientId,
22583
+ clientSecret: input.clientSecret
22584
+ };
22585
+ if (input.tenantId !== void 0) body.tenantId = input.tenantId;
22586
+ if (input.cloudEnvironment !== void 0) body.cloudEnvironment = input.cloudEnvironment;
22587
+ return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, body);
22469
22588
  }
22470
22589
  },
22471
22590
  {
@@ -22491,6 +22610,11 @@ var postureTools = [
22491
22610
  for (const [key, value] of Object.entries(body)) {
22492
22611
  if (value !== void 0) cleanBody[key] = value;
22493
22612
  }
22613
+ if (Object.keys(cleanBody).length === 0) {
22614
+ throw new Error(
22615
+ "No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudEnvironment."
22616
+ );
22617
+ }
22494
22618
  return apiPatch(`/tailnet/${getTailnet()}/posture/integrations/${encPath(integrationId)}`, cleanBody);
22495
22619
  }
22496
22620
  },
@@ -22580,6 +22704,9 @@ var serviceTools = [
22580
22704
  for (const [key, value] of Object.entries(body)) {
22581
22705
  if (value !== void 0) cleanBody[key] = value;
22582
22706
  }
22707
+ if (Object.keys(cleanBody).length === 0) {
22708
+ throw new Error("No fields to update. Provide at least one of: ports, tags, autoApproveHosts.");
22709
+ }
22583
22710
  return apiPut(`/tailnet/${getTailnet()}/services/${encPath(serviceName)}`, cleanBody);
22584
22711
  }
22585
22712
  },
@@ -22743,6 +22870,9 @@ var tailnetTools = [
22743
22870
  for (const [key, value] of Object.entries(input)) {
22744
22871
  if (value !== void 0) body[key] = value;
22745
22872
  }
22873
+ if (Object.keys(body).length === 0) {
22874
+ throw new Error("No fields to update. Provide at least one setting to change.");
22875
+ }
22746
22876
  return apiPatch(`/tailnet/${getTailnet()}/settings`, body);
22747
22877
  }
22748
22878
  },
@@ -22820,9 +22950,16 @@ var userTools = [
22820
22950
  idempotentHint: true,
22821
22951
  openWorldHint: true
22822
22952
  },
22823
- inputSchema: external_exports.object({}),
22824
- handler: async () => {
22825
- return apiGet(`/tailnet/${getTailnet()}/users`);
22953
+ inputSchema: external_exports.object({
22954
+ type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
22955
+ role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).optional().describe("Filter by user role")
22956
+ }),
22957
+ handler: async (input) => {
22958
+ const params = new URLSearchParams();
22959
+ if (input.type) params.set("type", input.type);
22960
+ if (input.role) params.set("role", input.role);
22961
+ const qs = params.toString();
22962
+ return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
22826
22963
  }
22827
22964
  },
22828
22965
  {
@@ -22931,6 +23068,26 @@ var userTools = [
22931
23068
  ];
22932
23069
 
22933
23070
  // src/tools/webhooks.ts
23071
+ var webhookEventTypes = [
23072
+ "nodeCreated",
23073
+ "nodeNeedsApproval",
23074
+ "nodeApproved",
23075
+ "nodeKeyExpiringInOneDay",
23076
+ "nodeKeyExpired",
23077
+ "nodeDeleted",
23078
+ "nodeSigned",
23079
+ "nodeNeedsSignature",
23080
+ "policyUpdate",
23081
+ "userCreated",
23082
+ "userNeedsApproval",
23083
+ "userSuspended",
23084
+ "userRestored",
23085
+ "userDeleted",
23086
+ "userApproved",
23087
+ "userRoleUpdated",
23088
+ "subnetIPForwardingNotEnabled",
23089
+ "exitNodeIPForwardingNotEnabled"
23090
+ ];
22934
23091
  var webhookTools = [
22935
23092
  {
22936
23093
  name: "tailscale_list_webhooks",
@@ -22976,9 +23133,7 @@ var webhookTools = [
22976
23133
  },
22977
23134
  inputSchema: external_exports.object({
22978
23135
  endpointUrl: external_exports.string().describe("The URL to send webhook events to"),
22979
- subscriptions: external_exports.array(external_exports.string()).describe(
22980
- "Event types to subscribe to (e.g. ['nodeCreated', 'nodeDeleted', 'nodeApproved', 'policyUpdate', 'userCreated', 'userDeleted'])"
22981
- )
23136
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).describe("Event types to subscribe to")
22982
23137
  }),
22983
23138
  handler: async (input) => {
22984
23139
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -23000,14 +23155,15 @@ var webhookTools = [
23000
23155
  inputSchema: external_exports.object({
23001
23156
  webhookId: external_exports.string().describe("The webhook ID to update"),
23002
23157
  endpointUrl: external_exports.string().optional().describe("New URL to send webhook events to"),
23003
- subscriptions: external_exports.array(external_exports.string()).optional().describe(
23004
- "Updated list of event types to subscribe to (e.g. ['nodeCreated', 'nodeDeleted', 'nodeApproved', 'policyUpdate', 'userCreated', 'userDeleted'])"
23005
- )
23158
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
23006
23159
  }),
23007
23160
  handler: async (input) => {
23008
23161
  const body = {};
23009
23162
  if (input.endpointUrl !== void 0) body.endpointUrl = input.endpointUrl;
23010
23163
  if (input.subscriptions !== void 0) body.subscriptions = input.subscriptions;
23164
+ if (Object.keys(body).length === 0) {
23165
+ throw new Error("No fields to update. Provide at least one of: endpointUrl, subscriptions.");
23166
+ }
23011
23167
  return apiPatch(`/webhooks/${encPath(input.webhookId)}`, body);
23012
23168
  }
23013
23169
  },
@@ -23109,13 +23265,15 @@ var workloadIdentityTools = [
23109
23265
  openWorldHint: true
23110
23266
  },
23111
23267
  inputSchema: external_exports.object({
23112
- name: external_exports.string().describe("A human-readable name for this provider"),
23268
+ name: external_exports.string().describe("A human-readable name for this provider (max 50 chars, alphanumeric/hyphens/spaces)"),
23113
23269
  issuerUrl: external_exports.string().describe("The OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com' for GitHub Actions)"),
23114
23270
  audience: external_exports.string().optional().describe("Expected audience claim in the OIDC token"),
23115
23271
  claimMappings: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("Map of Tailscale attributes to OIDC token claims (e.g. { 'tag': 'repository' })")
23116
23272
  }),
23117
23273
  handler: async (input) => {
23118
- return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, input);
23274
+ const body = { ...input };
23275
+ body.name = sanitizeDescription(input.name);
23276
+ return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, body);
23119
23277
  }
23120
23278
  },
23121
23279
  {
@@ -23140,6 +23298,10 @@ var workloadIdentityTools = [
23140
23298
  for (const [key, value] of Object.entries(body)) {
23141
23299
  if (value !== void 0) cleanBody[key] = value;
23142
23300
  }
23301
+ if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
23302
+ if (Object.keys(cleanBody).length === 0) {
23303
+ throw new Error("No fields to update. Provide at least one of: name, audience, claimMappings.");
23304
+ }
23143
23305
  return apiPatch(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(providerId)}`, cleanBody);
23144
23306
  }
23145
23307
  },
@@ -23163,7 +23325,7 @@ var workloadIdentityTools = [
23163
23325
  ];
23164
23326
 
23165
23327
  // src/index.ts
23166
- var version2 = true ? "0.5.1" : (await null).createRequire(import.meta.url)("../package.json").version;
23328
+ var version2 = true ? "0.6.3" : (await null).createRequire(import.meta.url)("../package.json").version;
23167
23329
  var subcommand = process.argv[2];
23168
23330
  if (subcommand === "deploy-acl") {
23169
23331
  const filePath = process.argv[3];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.6.3",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",