@yawlabs/tailscale-mcp 0.5.0 → 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 +245 -55
  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 = {
@@ -21156,7 +21159,9 @@ async function deployAcl(filePath) {
21156
21159
  }
21157
21160
  const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
21158
21161
  rawBody: policy,
21159
- contentType: "application/hujson"
21162
+ contentType: "application/hujson",
21163
+ acceptRaw: true,
21164
+ accept: "application/hujson"
21160
21165
  });
21161
21166
  if (!validateRes.ok) {
21162
21167
  console.error(`ACL validation failed: ${validateRes.error}`);
@@ -21165,7 +21170,9 @@ async function deployAcl(filePath) {
21165
21170
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
21166
21171
  rawBody: policy,
21167
21172
  contentType: "application/hujson",
21168
- ifMatch: getRes.etag
21173
+ ifMatch: getRes.etag,
21174
+ acceptRaw: true,
21175
+ accept: "application/hujson"
21169
21176
  });
21170
21177
  if (!deployRes.ok) {
21171
21178
  console.error(`ACL deploy failed: ${deployRes.error}`);
@@ -21225,7 +21232,9 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21225
21232
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
21226
21233
  rawBody: input.policy,
21227
21234
  contentType: "application/hujson",
21228
- ifMatch: input.etag
21235
+ ifMatch: input.etag,
21236
+ acceptRaw: true,
21237
+ accept: "application/hujson"
21229
21238
  });
21230
21239
  }
21231
21240
  },
@@ -21245,10 +21254,12 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21245
21254
  handler: async (input) => {
21246
21255
  const res = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
21247
21256
  rawBody: input.policy,
21248
- contentType: "application/hujson"
21257
+ contentType: "application/hujson",
21258
+ acceptRaw: true,
21259
+ accept: "application/hujson"
21249
21260
  });
21250
- if (res.ok && !res.data) {
21251
- return { ...res, data: { message: "ACL policy is valid." } };
21261
+ if (res.ok && (!res.rawBody || !res.rawBody.trim())) {
21262
+ return { ...res, rawBody: "ACL policy is valid." };
21252
21263
  }
21253
21264
  return res;
21254
21265
  }
@@ -21272,7 +21283,9 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21272
21283
  const params = new URLSearchParams({ type: input.type, previewFor: input.previewFor });
21273
21284
  return apiPost(`/tailnet/${getTailnet()}/acl/preview?${params}`, void 0, {
21274
21285
  rawBody: input.policy,
21275
- contentType: "application/hujson"
21286
+ contentType: "application/hujson",
21287
+ acceptRaw: true,
21288
+ accept: "application/hujson"
21276
21289
  });
21277
21290
  }
21278
21291
  }
@@ -21280,8 +21293,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21280
21293
 
21281
21294
  // src/tools/audit.ts
21282
21295
  function assertRFC3339(value, label) {
21283
- const d = new Date(value);
21284
- if (Number.isNaN(d.getTime())) {
21296
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
21285
21297
  throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
21286
21298
  }
21287
21299
  }
@@ -21347,11 +21359,21 @@ var deviceTools = [
21347
21359
  inputSchema: external_exports.object({
21348
21360
  fields: external_exports.string().optional().describe(
21349
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."
21350
21365
  )
21351
21366
  }),
21352
21367
  handler: async (input) => {
21353
- const params = input.fields ? `?fields=${encodeURIComponent(input.fields)}` : "";
21354
- 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}` : ""}`);
21355
21377
  }
21356
21378
  },
21357
21379
  {
@@ -21365,7 +21387,7 @@ var deviceTools = [
21365
21387
  openWorldHint: true
21366
21388
  },
21367
21389
  inputSchema: external_exports.object({
21368
- 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)")
21369
21391
  }),
21370
21392
  handler: async (input) => {
21371
21393
  return apiGet(`/device/${encPath(input.deviceId)}`);
@@ -21553,6 +21575,9 @@ var deviceTools = [
21553
21575
  attributeKey: external_exports.string().describe("The attribute key to delete (e.g. 'custom:lastAuditDate')")
21554
21576
  }),
21555
21577
  handler: async (input) => {
21578
+ if (!input.attributeKey.startsWith("custom:")) {
21579
+ throw new Error(`attributeKey must start with 'custom:' prefix, got: '${input.attributeKey}'`);
21580
+ }
21556
21581
  return apiDelete(`/device/${encPath(input.deviceId)}/attributes/${encPath(input.attributeKey)}`);
21557
21582
  }
21558
21583
  },
@@ -21632,6 +21657,17 @@ var deviceTools = [
21632
21657
  )
21633
21658
  }),
21634
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
+ }
21635
21671
  return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, input.attributes);
21636
21672
  }
21637
21673
  }
@@ -21855,7 +21891,7 @@ var inviteTools = [
21855
21891
  },
21856
21892
  {
21857
21893
  name: "tailscale_create_device_invite",
21858
- 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.",
21859
21895
  annotations: {
21860
21896
  title: "Create device invite",
21861
21897
  readOnlyHint: false,
@@ -21911,6 +21947,23 @@ var inviteTools = [
21911
21947
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
21912
21948
  }
21913
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
+ },
21914
21967
  // --- User Invites ---
21915
21968
  {
21916
21969
  name: "tailscale_list_user_invites",
@@ -22022,17 +22075,20 @@ var inviteTools = [
22022
22075
  var keyTools = [
22023
22076
  {
22024
22077
  name: "tailscale_list_keys",
22025
- 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.",
22026
22079
  annotations: {
22027
- title: "List auth keys",
22080
+ title: "List keys",
22028
22081
  readOnlyHint: true,
22029
22082
  destructiveHint: false,
22030
22083
  idempotentHint: true,
22031
22084
  openWorldHint: true
22032
22085
  },
22033
- inputSchema: external_exports.object({}),
22034
- handler: async () => {
22035
- 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}`);
22036
22092
  }
22037
22093
  },
22038
22094
  {
@@ -22054,25 +22110,56 @@ var keyTools = [
22054
22110
  },
22055
22111
  {
22056
22112
  name: "tailscale_create_key",
22057
- 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.",
22058
22114
  annotations: {
22059
- title: "Create auth key",
22115
+ title: "Create key",
22060
22116
  readOnlyHint: false,
22061
22117
  destructiveHint: false,
22062
22118
  idempotentHint: false,
22063
22119
  openWorldHint: true
22064
22120
  },
22065
22121
  inputSchema: external_exports.object({
22066
- reusable: external_exports.boolean().optional().describe("Whether the key can be used more than once (default: false)"),
22067
- ephemeral: external_exports.boolean().optional().describe("Whether devices using this key are ephemeral (default: false)"),
22068
- preauthorized: external_exports.boolean().optional().describe("Whether devices are pre-authorized (default: false)"),
22069
- tags: external_exports.array(external_exports.string()).optional().describe("ACL tags to apply to devices using this key"),
22070
- expirySeconds: external_exports.number().optional().describe("Key expiry in seconds (default: 90 days)"),
22071
- 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")
22072
22142
  }),
22073
22143
  handler: async (input) => {
22074
- const body = {
22075
- capabilities: {
22144
+ if (input.tags && input.tags.length > 0) {
22145
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22146
+ if (invalid.length > 0) {
22147
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22148
+ }
22149
+ }
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 = {
22076
22163
  devices: {
22077
22164
  create: {
22078
22165
  reusable: input.reusable ?? false,
@@ -22081,10 +22168,23 @@ var keyTools = [
22081
22168
  tags: input.tags ?? []
22082
22169
  }
22083
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}'`);
22084
22176
  }
22085
- };
22086
- if (input.expirySeconds !== void 0) body.expirySeconds = input.expirySeconds;
22087
- 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
+ }
22088
22188
  return apiPost(`/tailnet/${getTailnet()}/keys`, body);
22089
22189
  }
22090
22190
  },
@@ -22107,21 +22207,42 @@ var keyTools = [
22107
22207
  },
22108
22208
  {
22109
22209
  name: "tailscale_update_key",
22110
- 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.",
22111
22211
  annotations: {
22112
- title: "Update auth key",
22212
+ title: "Update key",
22113
22213
  readOnlyHint: false,
22114
22214
  destructiveHint: false,
22115
22215
  idempotentHint: true,
22116
22216
  openWorldHint: true
22117
22217
  },
22118
22218
  inputSchema: external_exports.object({
22119
- keyId: external_exports.string().describe("The auth key ID to update"),
22120
- 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")
22121
22227
  }),
22122
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
+ }
22123
22235
  const body = {};
22124
- 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
+ }
22125
22246
  return apiPut(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`, body);
22126
22247
  }
22127
22248
  }
@@ -22184,9 +22305,7 @@ var logStreamingTools = [
22184
22305
  },
22185
22306
  inputSchema: external_exports.object({
22186
22307
  logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
22187
- destinationType: external_exports.string().describe(
22188
- "The destination type (e.g. 'axiom', 'datadog', 'splunk', 'elasticsearch', 'panther', 's3', 'gcs', 'cribl')"
22189
- ),
22308
+ destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
22190
22309
  url: external_exports.string().optional().describe("Destination URL (required for most destination types)"),
22191
22310
  token: external_exports.string().optional().describe("Authentication token or API key for the destination"),
22192
22311
  user: external_exports.string().optional().describe("Username for the destination (if required)")
@@ -22336,15 +22455,24 @@ var oauthClientTools = [
22336
22455
  openWorldHint: true
22337
22456
  },
22338
22457
  inputSchema: external_exports.object({
22339
- 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)"),
22340
22459
  scopes: external_exports.array(external_exports.string()).describe(
22341
22460
  "OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl']). See Tailscale docs for available scopes."
22342
22461
  ),
22343
22462
  tags: external_exports.array(external_exports.string()).optional().describe("ACL tags to assign to the OAuth client"),
22344
- 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)")
22345
22464
  }),
22346
22465
  handler: async (input) => {
22347
- return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, input);
22466
+ if (input.tags && input.tags.length > 0) {
22467
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22468
+ if (invalid.length > 0) {
22469
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22470
+ }
22471
+ }
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);
22348
22476
  }
22349
22477
  },
22350
22478
  {
@@ -22369,6 +22497,12 @@ var oauthClientTools = [
22369
22497
  for (const [key, value] of Object.entries(body)) {
22370
22498
  if (value !== void 0) cleanBody[key] = value;
22371
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
+ }
22372
22506
  return apiPatch(`/tailnet/${getTailnet()}/oauth-clients/${encPath(clientId)}`, cleanBody);
22373
22507
  }
22374
22508
  },
@@ -22443,7 +22577,14 @@ var postureTools = [
22443
22577
  cloudEnvironment: external_exports.string().optional().describe("Cloud environment (e.g. 'us-1', 'eu-1')")
22444
22578
  }),
22445
22579
  handler: async (input) => {
22446
- 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);
22447
22588
  }
22448
22589
  },
22449
22590
  {
@@ -22469,6 +22610,11 @@ var postureTools = [
22469
22610
  for (const [key, value] of Object.entries(body)) {
22470
22611
  if (value !== void 0) cleanBody[key] = value;
22471
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
+ }
22472
22618
  return apiPatch(`/tailnet/${getTailnet()}/posture/integrations/${encPath(integrationId)}`, cleanBody);
22473
22619
  }
22474
22620
  },
@@ -22547,11 +22693,20 @@ var serviceTools = [
22547
22693
  autoApproveHosts: external_exports.boolean().optional().describe("Whether to auto-approve devices that want to host this service")
22548
22694
  }),
22549
22695
  handler: async (input) => {
22696
+ if (input.tags && input.tags.length > 0) {
22697
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22698
+ if (invalid.length > 0) {
22699
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22700
+ }
22701
+ }
22550
22702
  const { serviceName, ...body } = input;
22551
22703
  const cleanBody = {};
22552
22704
  for (const [key, value] of Object.entries(body)) {
22553
22705
  if (value !== void 0) cleanBody[key] = value;
22554
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
+ }
22555
22710
  return apiPut(`/tailnet/${getTailnet()}/services/${encPath(serviceName)}`, cleanBody);
22556
22711
  }
22557
22712
  },
@@ -22715,6 +22870,9 @@ var tailnetTools = [
22715
22870
  for (const [key, value] of Object.entries(input)) {
22716
22871
  if (value !== void 0) body[key] = value;
22717
22872
  }
22873
+ if (Object.keys(body).length === 0) {
22874
+ throw new Error("No fields to update. Provide at least one setting to change.");
22875
+ }
22718
22876
  return apiPatch(`/tailnet/${getTailnet()}/settings`, body);
22719
22877
  }
22720
22878
  },
@@ -22792,9 +22950,16 @@ var userTools = [
22792
22950
  idempotentHint: true,
22793
22951
  openWorldHint: true
22794
22952
  },
22795
- inputSchema: external_exports.object({}),
22796
- handler: async () => {
22797
- 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}` : ""}`);
22798
22963
  }
22799
22964
  },
22800
22965
  {
@@ -22903,6 +23068,26 @@ var userTools = [
22903
23068
  ];
22904
23069
 
22905
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
+ ];
22906
23091
  var webhookTools = [
22907
23092
  {
22908
23093
  name: "tailscale_list_webhooks",
@@ -22948,9 +23133,7 @@ var webhookTools = [
22948
23133
  },
22949
23134
  inputSchema: external_exports.object({
22950
23135
  endpointUrl: external_exports.string().describe("The URL to send webhook events to"),
22951
- subscriptions: external_exports.array(external_exports.string()).describe(
22952
- "Event types to subscribe to (e.g. ['nodeCreated', 'nodeDeleted', 'nodeApproved', 'policyUpdate', 'userCreated', 'userDeleted'])"
22953
- )
23136
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).describe("Event types to subscribe to")
22954
23137
  }),
22955
23138
  handler: async (input) => {
22956
23139
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -22972,14 +23155,15 @@ var webhookTools = [
22972
23155
  inputSchema: external_exports.object({
22973
23156
  webhookId: external_exports.string().describe("The webhook ID to update"),
22974
23157
  endpointUrl: external_exports.string().optional().describe("New URL to send webhook events to"),
22975
- subscriptions: external_exports.array(external_exports.string()).optional().describe(
22976
- "Updated list of event types to subscribe to (e.g. ['nodeCreated', 'nodeDeleted', 'nodeApproved', 'policyUpdate', 'userCreated', 'userDeleted'])"
22977
- )
23158
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
22978
23159
  }),
22979
23160
  handler: async (input) => {
22980
23161
  const body = {};
22981
23162
  if (input.endpointUrl !== void 0) body.endpointUrl = input.endpointUrl;
22982
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
+ }
22983
23167
  return apiPatch(`/webhooks/${encPath(input.webhookId)}`, body);
22984
23168
  }
22985
23169
  },
@@ -23081,13 +23265,15 @@ var workloadIdentityTools = [
23081
23265
  openWorldHint: true
23082
23266
  },
23083
23267
  inputSchema: external_exports.object({
23084
- 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)"),
23085
23269
  issuerUrl: external_exports.string().describe("The OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com' for GitHub Actions)"),
23086
23270
  audience: external_exports.string().optional().describe("Expected audience claim in the OIDC token"),
23087
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' })")
23088
23272
  }),
23089
23273
  handler: async (input) => {
23090
- 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);
23091
23277
  }
23092
23278
  },
23093
23279
  {
@@ -23112,6 +23298,10 @@ var workloadIdentityTools = [
23112
23298
  for (const [key, value] of Object.entries(body)) {
23113
23299
  if (value !== void 0) cleanBody[key] = value;
23114
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
+ }
23115
23305
  return apiPatch(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(providerId)}`, cleanBody);
23116
23306
  }
23117
23307
  },
@@ -23135,7 +23325,7 @@ var workloadIdentityTools = [
23135
23325
  ];
23136
23326
 
23137
23327
  // src/index.ts
23138
- var version2 = true ? "0.5.0" : (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;
23139
23329
  var subcommand = process.argv[2];
23140
23330
  if (subcommand === "deploy-acl") {
23141
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.0",
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>",