@yawlabs/tailscale-mcp 0.5.1 → 0.6.4

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 +240 -52
  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
@@ -21021,8 +21021,9 @@ function getConfig() {
21021
21021
  const oauthClientSecret = process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
21022
21022
  const tailnet = process.env.TAILSCALE_TAILNET || "-";
21023
21023
  if (!apiKey && !(oauthClientId && oauthClientSecret)) {
21024
+ const hint = process.platform === "win32" ? ' On Windows, env vars set in bash/WSL profiles are not visible to MCP servers launched via cmd. Either add "env": {"TAILSCALE_API_KEY": "tskey-api-..."} to your .mcp.json, or set it as a Windows user environment variable.' : "";
21024
21025
  throw new Error(
21025
- "No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET."
21026
+ `No Tailscale credentials configured. Set TAILSCALE_API_KEY, or set both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET.${hint}`
21026
21027
  );
21027
21028
  }
21028
21029
  if (apiKey && apiKey.trim() === "") {
@@ -21079,6 +21080,32 @@ function getTailnet() {
21079
21080
  function encPath(segment) {
21080
21081
  return encodeURIComponent(segment);
21081
21082
  }
21083
+ function sanitizeDescription(value) {
21084
+ return value.replace(/[/_]/g, "-").replace(/[^a-zA-Z0-9 -]/g, "").replace(/ {2,}/g, " ").trim().slice(0, 50);
21085
+ }
21086
+ function formatAuthError(apiBody) {
21087
+ const usingOAuth = !process.env.TAILSCALE_API_KEY && process.env.TAILSCALE_OAUTH_CLIENT_ID;
21088
+ const lines = [
21089
+ "Authentication failed (HTTP 401).",
21090
+ "",
21091
+ "Possible causes:",
21092
+ usingOAuth ? " - OAuth client credentials are invalid or lack required scopes" : " - API key has expired or been revoked"
21093
+ ];
21094
+ if (process.platform === "win32" && !usingOAuth) {
21095
+ lines.push(
21096
+ " - On Windows, env vars set in bash/WSL profiles are not visible to MCP servers launched via cmd",
21097
+ "",
21098
+ "Fix options:",
21099
+ ' 1. Add "env": {"TAILSCALE_API_KEY": "tskey-api-..."} to your .mcp.json',
21100
+ " 2. Set TAILSCALE_API_KEY as a Windows user environment variable (System Properties > Environment Variables)"
21101
+ );
21102
+ }
21103
+ lines.push("", "Generate a new key at: https://login.tailscale.com/admin/settings/keys");
21104
+ if (apiBody) {
21105
+ lines.push("", `API response: ${apiBody}`);
21106
+ }
21107
+ return lines.join("\n");
21108
+ }
21082
21109
  async function apiRequest(method, path, body, options) {
21083
21110
  const auth = await getAuthHeader();
21084
21111
  const headers = {
@@ -21109,13 +21136,15 @@ async function apiRequest(method, path, body, options) {
21109
21136
  if (options?.acceptRaw) {
21110
21137
  const rawBody = await res.text();
21111
21138
  if (!res.ok) {
21112
- return { ok: false, status: res.status, error: rawBody, rawBody, etag };
21139
+ const error2 = res.status === 401 ? formatAuthError(rawBody) : rawBody;
21140
+ return { ok: false, status: res.status, error: error2, rawBody, etag };
21113
21141
  }
21114
21142
  return { ok: true, status: res.status, rawBody, etag };
21115
21143
  }
21116
21144
  if (!res.ok) {
21117
21145
  const errorBody = await res.text();
21118
- return { ok: false, status: res.status, error: errorBody, etag };
21146
+ const error2 = res.status === 401 ? formatAuthError(errorBody) : errorBody;
21147
+ return { ok: false, status: res.status, error: error2, etag };
21119
21148
  }
21120
21149
  if (res.status === 204 || res.headers.get("content-length") === "0") {
21121
21150
  return { ok: true, status: res.status, etag };
@@ -21255,7 +21284,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21255
21284
  acceptRaw: true,
21256
21285
  accept: "application/hujson"
21257
21286
  });
21258
- if (res.ok && !res.rawBody) {
21287
+ if (res.ok && (!res.rawBody || !res.rawBody.trim())) {
21259
21288
  return { ...res, rawBody: "ACL policy is valid." };
21260
21289
  }
21261
21290
  return res;
@@ -21290,8 +21319,7 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21290
21319
 
21291
21320
  // src/tools/audit.ts
21292
21321
  function assertRFC3339(value, label) {
21293
- const d = new Date(value);
21294
- if (Number.isNaN(d.getTime())) {
21322
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
21295
21323
  throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
21296
21324
  }
21297
21325
  }
@@ -21357,11 +21385,21 @@ var deviceTools = [
21357
21385
  inputSchema: external_exports.object({
21358
21386
  fields: external_exports.string().optional().describe(
21359
21387
  "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."
21388
+ ),
21389
+ filters: external_exports.record(external_exports.string(), external_exports.string()).optional().describe(
21390
+ "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
21391
  )
21361
21392
  }),
21362
21393
  handler: async (input) => {
21363
- const params = input.fields ? `?${new URLSearchParams({ fields: input.fields })}` : "";
21364
- return apiGet(`/tailnet/${getTailnet()}/devices${params}`);
21394
+ const params = new URLSearchParams();
21395
+ if (input.fields) params.set("fields", input.fields);
21396
+ if (input.filters) {
21397
+ for (const [key, value] of Object.entries(input.filters)) {
21398
+ params.set(key, value);
21399
+ }
21400
+ }
21401
+ const qs = params.toString();
21402
+ return apiGet(`/tailnet/${getTailnet()}/devices${qs ? `?${qs}` : ""}`);
21365
21403
  }
21366
21404
  },
21367
21405
  {
@@ -21375,7 +21413,7 @@ var deviceTools = [
21375
21413
  openWorldHint: true
21376
21414
  },
21377
21415
  inputSchema: external_exports.object({
21378
- deviceId: external_exports.string().describe("The device ID (numeric or nodekey format)")
21416
+ deviceId: external_exports.string().describe("The device ID (numeric id or nodeId, NOT the nodeKey)")
21379
21417
  }),
21380
21418
  handler: async (input) => {
21381
21419
  return apiGet(`/device/${encPath(input.deviceId)}`);
@@ -21563,6 +21601,9 @@ var deviceTools = [
21563
21601
  attributeKey: external_exports.string().describe("The attribute key to delete (e.g. 'custom:lastAuditDate')")
21564
21602
  }),
21565
21603
  handler: async (input) => {
21604
+ if (!input.attributeKey.startsWith("custom:")) {
21605
+ throw new Error(`attributeKey must start with 'custom:' prefix, got: '${input.attributeKey}'`);
21606
+ }
21566
21607
  return apiDelete(`/device/${encPath(input.deviceId)}/attributes/${encPath(input.attributeKey)}`);
21567
21608
  }
21568
21609
  },
@@ -21642,6 +21683,17 @@ var deviceTools = [
21642
21683
  )
21643
21684
  }),
21644
21685
  handler: async (input) => {
21686
+ const invalidKeys = [];
21687
+ for (const attrs of Object.values(input.attributes)) {
21688
+ for (const key of Object.keys(attrs)) {
21689
+ if (!key.startsWith("custom:")) invalidKeys.push(key);
21690
+ }
21691
+ }
21692
+ if (invalidKeys.length > 0) {
21693
+ throw new Error(
21694
+ `All attribute keys must start with 'custom:' prefix. Invalid keys: ${[...new Set(invalidKeys)].join(", ")}`
21695
+ );
21696
+ }
21645
21697
  return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, input.attributes);
21646
21698
  }
21647
21699
  }
@@ -21865,7 +21917,7 @@ var inviteTools = [
21865
21917
  },
21866
21918
  {
21867
21919
  name: "tailscale_create_device_invite",
21868
- description: "Create a new device invite that allows someone to add a specific device to your tailnet.",
21920
+ description: "Create a device share invitation that allows an external user to access a specific device in your tailnet.",
21869
21921
  annotations: {
21870
21922
  title: "Create device invite",
21871
21923
  readOnlyHint: false,
@@ -21921,6 +21973,23 @@ var inviteTools = [
21921
21973
  return apiDelete(`/device-invites/${encPath(input.inviteId)}`);
21922
21974
  }
21923
21975
  },
21976
+ {
21977
+ name: "tailscale_accept_device_invite",
21978
+ description: "Accept a device share invitation using the invite URL or code.",
21979
+ annotations: {
21980
+ title: "Accept device invite",
21981
+ readOnlyHint: false,
21982
+ destructiveHint: false,
21983
+ idempotentHint: true,
21984
+ openWorldHint: true
21985
+ },
21986
+ inputSchema: external_exports.object({
21987
+ invite: external_exports.string().describe("The device invite URL or invite code")
21988
+ }),
21989
+ handler: async (input) => {
21990
+ return apiPost("/device-invites/-/accept", { invite: input.invite });
21991
+ }
21992
+ },
21924
21993
  // --- User Invites ---
21925
21994
  {
21926
21995
  name: "tailscale_list_user_invites",
@@ -22032,17 +22101,20 @@ var inviteTools = [
22032
22101
  var keyTools = [
22033
22102
  {
22034
22103
  name: "tailscale_list_keys",
22035
- description: "List all auth keys in your tailnet.",
22104
+ description: "List keys in your tailnet. By default lists auth keys only. Set 'all' to true to include OAuth clients and federated identities.",
22036
22105
  annotations: {
22037
- title: "List auth keys",
22106
+ title: "List keys",
22038
22107
  readOnlyHint: true,
22039
22108
  destructiveHint: false,
22040
22109
  idempotentHint: true,
22041
22110
  openWorldHint: true
22042
22111
  },
22043
- inputSchema: external_exports.object({}),
22044
- handler: async () => {
22045
- return apiGet(`/tailnet/${getTailnet()}/keys`);
22112
+ inputSchema: external_exports.object({
22113
+ all: external_exports.boolean().optional().describe("When true, returns all key types (auth keys, OAuth clients, federated identities). Default: false")
22114
+ }),
22115
+ handler: async (input) => {
22116
+ const qs = input.all ? "?all=true" : "";
22117
+ return apiGet(`/tailnet/${getTailnet()}/keys${qs}`);
22046
22118
  }
22047
22119
  },
22048
22120
  {
@@ -22064,21 +22136,35 @@ var keyTools = [
22064
22136
  },
22065
22137
  {
22066
22138
  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.",
22139
+ 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
22140
  annotations: {
22069
- title: "Create auth key",
22141
+ title: "Create key",
22070
22142
  readOnlyHint: false,
22071
22143
  destructiveHint: false,
22072
22144
  idempotentHint: false,
22073
22145
  openWorldHint: true
22074
22146
  },
22075
22147
  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")
22148
+ keyType: external_exports.enum(["auth", "client", "federated"]).optional().describe(
22149
+ "Key type: 'auth' (default) for device auth keys, 'client' for OAuth clients, 'federated' for OIDC federation"
22150
+ ),
22151
+ description: external_exports.string().optional().describe("Description for this key (max 50 chars, alphanumeric/hyphens/spaces)"),
22152
+ // Auth key fields
22153
+ reusable: external_exports.boolean().optional().describe("(auth only) Whether the key can be used more than once (default: false)"),
22154
+ ephemeral: external_exports.boolean().optional().describe("(auth only) Whether devices using this key are ephemeral (default: false)"),
22155
+ preauthorized: external_exports.boolean().optional().describe("(auth only) Whether devices are pre-authorized (default: false)"),
22156
+ expirySeconds: external_exports.number().optional().describe("(auth only) Key expiry in seconds (default: 90 days)"),
22157
+ // Shared fields
22158
+ tags: external_exports.array(external_exports.string()).optional().describe(
22159
+ "ACL tags (must start with 'tag:'). Required for client/federated if scopes include 'devices:core' or 'auth_keys'"
22160
+ ),
22161
+ // Client + Federated fields
22162
+ scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl'])"),
22163
+ // Federated-only fields
22164
+ issuer: external_exports.string().optional().describe("(federated only) OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com')"),
22165
+ subject: external_exports.string().optional().describe("(federated only) Expected subject claim, supports * wildcards"),
22166
+ audience: external_exports.string().optional().describe("(federated only) Expected audience claim"),
22167
+ customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Custom claim mapping rules")
22082
22168
  }),
22083
22169
  handler: async (input) => {
22084
22170
  if (input.tags && input.tags.length > 0) {
@@ -22087,8 +22173,19 @@ var keyTools = [
22087
22173
  throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22088
22174
  }
22089
22175
  }
22090
- const body = {
22091
- capabilities: {
22176
+ const keyType = input.keyType ?? "auth";
22177
+ if (keyType !== "auth") {
22178
+ const authOnlyFields = ["reusable", "ephemeral", "preauthorized", "expirySeconds"];
22179
+ const wrongFields = authOnlyFields.filter((f) => input[f] !== void 0);
22180
+ if (wrongFields.length > 0) {
22181
+ throw new Error(`${wrongFields.join(", ")} can only be used with keyType 'auth', not '${keyType}'`);
22182
+ }
22183
+ }
22184
+ const body = {};
22185
+ if (keyType !== "auth") body.keyType = keyType;
22186
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22187
+ if (keyType === "auth") {
22188
+ body.capabilities = {
22092
22189
  devices: {
22093
22190
  create: {
22094
22191
  reusable: input.reusable ?? false,
@@ -22097,10 +22194,23 @@ var keyTools = [
22097
22194
  tags: input.tags ?? []
22098
22195
  }
22099
22196
  }
22197
+ };
22198
+ if (input.expirySeconds !== void 0) body.expirySeconds = input.expirySeconds;
22199
+ } else {
22200
+ if (!input.scopes || input.scopes.length === 0) {
22201
+ throw new Error(`scopes are required for keyType '${keyType}'`);
22100
22202
  }
22101
- };
22102
- if (input.expirySeconds !== void 0) body.expirySeconds = input.expirySeconds;
22103
- if (input.description !== void 0) body.description = input.description;
22203
+ body.scopes = input.scopes;
22204
+ if (input.tags) body.tags = input.tags;
22205
+ if (keyType === "federated") {
22206
+ if (!input.issuer) throw new Error("issuer is required for federated keys");
22207
+ if (!input.subject) throw new Error("subject is required for federated keys");
22208
+ body.issuer = input.issuer;
22209
+ body.subject = input.subject;
22210
+ if (input.audience !== void 0) body.audience = input.audience;
22211
+ if (input.customClaimRules !== void 0) body.customClaimRules = input.customClaimRules;
22212
+ }
22213
+ }
22104
22214
  return apiPost(`/tailnet/${getTailnet()}/keys`, body);
22105
22215
  }
22106
22216
  },
@@ -22123,21 +22233,42 @@ var keyTools = [
22123
22233
  },
22124
22234
  {
22125
22235
  name: "tailscale_update_key",
22126
- description: "Update an existing auth key's description or capabilities.",
22236
+ 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
22237
  annotations: {
22128
- title: "Update auth key",
22238
+ title: "Update key",
22129
22239
  readOnlyHint: false,
22130
22240
  destructiveHint: false,
22131
22241
  idempotentHint: true,
22132
22242
  openWorldHint: true
22133
22243
  },
22134
22244
  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")
22245
+ keyId: external_exports.string().describe("The key ID to update"),
22246
+ description: external_exports.string().optional().describe("Updated description (max 50 chars, alphanumeric/hyphens/spaces)"),
22247
+ scopes: external_exports.array(external_exports.string()).optional().describe("(client/federated) Updated OAuth scopes"),
22248
+ tags: external_exports.array(external_exports.string()).optional().describe("Updated ACL tags (must start with 'tag:')"),
22249
+ issuer: external_exports.string().optional().describe("(federated only) Updated OIDC issuer URL"),
22250
+ subject: external_exports.string().optional().describe("(federated only) Updated subject claim pattern"),
22251
+ audience: external_exports.string().optional().describe("(federated only) Updated audience claim"),
22252
+ customClaimRules: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("(federated only) Updated custom claim rules")
22137
22253
  }),
22138
22254
  handler: async (input) => {
22255
+ if (input.tags && input.tags.length > 0) {
22256
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22257
+ if (invalid.length > 0) {
22258
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22259
+ }
22260
+ }
22139
22261
  const body = {};
22140
- if (input.description !== void 0) body.description = input.description;
22262
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22263
+ if (input.scopes !== void 0) body.scopes = input.scopes;
22264
+ if (input.tags !== void 0) body.tags = input.tags;
22265
+ if (input.issuer !== void 0) body.issuer = input.issuer;
22266
+ if (input.subject !== void 0) body.subject = input.subject;
22267
+ if (input.audience !== void 0) body.audience = input.audience;
22268
+ if (input.customClaimRules !== void 0) body.customClaimRules = input.customClaimRules;
22269
+ if (Object.keys(body).length === 0) {
22270
+ throw new Error("No fields to update. Provide at least one field (description, scopes, tags, etc.).");
22271
+ }
22141
22272
  return apiPut(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`, body);
22142
22273
  }
22143
22274
  }
@@ -22200,9 +22331,7 @@ var logStreamingTools = [
22200
22331
  },
22201
22332
  inputSchema: external_exports.object({
22202
22333
  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
- ),
22334
+ destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
22206
22335
  url: external_exports.string().optional().describe("Destination URL (required for most destination types)"),
22207
22336
  token: external_exports.string().optional().describe("Authentication token or API key for the destination"),
22208
22337
  user: external_exports.string().optional().describe("Username for the destination (if required)")
@@ -22352,12 +22481,12 @@ var oauthClientTools = [
22352
22481
  openWorldHint: true
22353
22482
  },
22354
22483
  inputSchema: external_exports.object({
22355
- name: external_exports.string().describe("A human-readable name for this OAuth client"),
22484
+ name: external_exports.string().describe("A human-readable name for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)"),
22356
22485
  scopes: external_exports.array(external_exports.string()).describe(
22357
22486
  "OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl']). See Tailscale docs for available scopes."
22358
22487
  ),
22359
22488
  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")
22489
+ description: external_exports.string().optional().describe("Description for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)")
22361
22490
  }),
22362
22491
  handler: async (input) => {
22363
22492
  if (input.tags && input.tags.length > 0) {
@@ -22366,7 +22495,10 @@ var oauthClientTools = [
22366
22495
  throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22367
22496
  }
22368
22497
  }
22369
- return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, input);
22498
+ const body = { ...input };
22499
+ body.name = sanitizeDescription(input.name);
22500
+ if (input.description !== void 0) body.description = sanitizeDescription(input.description);
22501
+ return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, body);
22370
22502
  }
22371
22503
  },
22372
22504
  {
@@ -22391,6 +22523,12 @@ var oauthClientTools = [
22391
22523
  for (const [key, value] of Object.entries(body)) {
22392
22524
  if (value !== void 0) cleanBody[key] = value;
22393
22525
  }
22526
+ if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
22527
+ if (cleanBody.description !== void 0)
22528
+ cleanBody.description = sanitizeDescription(cleanBody.description);
22529
+ if (Object.keys(cleanBody).length === 0) {
22530
+ throw new Error("No fields to update. Provide at least one of: name, scopes, description.");
22531
+ }
22394
22532
  return apiPatch(`/tailnet/${getTailnet()}/oauth-clients/${encPath(clientId)}`, cleanBody);
22395
22533
  }
22396
22534
  },
@@ -22465,7 +22603,14 @@ var postureTools = [
22465
22603
  cloudEnvironment: external_exports.string().optional().describe("Cloud environment (e.g. 'us-1', 'eu-1')")
22466
22604
  }),
22467
22605
  handler: async (input) => {
22468
- return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, input);
22606
+ const body = {
22607
+ provider: input.provider,
22608
+ clientId: input.clientId,
22609
+ clientSecret: input.clientSecret
22610
+ };
22611
+ if (input.tenantId !== void 0) body.tenantId = input.tenantId;
22612
+ if (input.cloudEnvironment !== void 0) body.cloudEnvironment = input.cloudEnvironment;
22613
+ return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, body);
22469
22614
  }
22470
22615
  },
22471
22616
  {
@@ -22491,6 +22636,11 @@ var postureTools = [
22491
22636
  for (const [key, value] of Object.entries(body)) {
22492
22637
  if (value !== void 0) cleanBody[key] = value;
22493
22638
  }
22639
+ if (Object.keys(cleanBody).length === 0) {
22640
+ throw new Error(
22641
+ "No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudEnvironment."
22642
+ );
22643
+ }
22494
22644
  return apiPatch(`/tailnet/${getTailnet()}/posture/integrations/${encPath(integrationId)}`, cleanBody);
22495
22645
  }
22496
22646
  },
@@ -22580,6 +22730,9 @@ var serviceTools = [
22580
22730
  for (const [key, value] of Object.entries(body)) {
22581
22731
  if (value !== void 0) cleanBody[key] = value;
22582
22732
  }
22733
+ if (Object.keys(cleanBody).length === 0) {
22734
+ throw new Error("No fields to update. Provide at least one of: ports, tags, autoApproveHosts.");
22735
+ }
22583
22736
  return apiPut(`/tailnet/${getTailnet()}/services/${encPath(serviceName)}`, cleanBody);
22584
22737
  }
22585
22738
  },
@@ -22743,6 +22896,9 @@ var tailnetTools = [
22743
22896
  for (const [key, value] of Object.entries(input)) {
22744
22897
  if (value !== void 0) body[key] = value;
22745
22898
  }
22899
+ if (Object.keys(body).length === 0) {
22900
+ throw new Error("No fields to update. Provide at least one setting to change.");
22901
+ }
22746
22902
  return apiPatch(`/tailnet/${getTailnet()}/settings`, body);
22747
22903
  }
22748
22904
  },
@@ -22820,9 +22976,16 @@ var userTools = [
22820
22976
  idempotentHint: true,
22821
22977
  openWorldHint: true
22822
22978
  },
22823
- inputSchema: external_exports.object({}),
22824
- handler: async () => {
22825
- return apiGet(`/tailnet/${getTailnet()}/users`);
22979
+ inputSchema: external_exports.object({
22980
+ type: external_exports.enum(["member", "shared", "all"]).optional().describe("Filter by user type: 'member' (direct members), 'shared' (shared-in users), or 'all' (default)"),
22981
+ role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).optional().describe("Filter by user role")
22982
+ }),
22983
+ handler: async (input) => {
22984
+ const params = new URLSearchParams();
22985
+ if (input.type) params.set("type", input.type);
22986
+ if (input.role) params.set("role", input.role);
22987
+ const qs = params.toString();
22988
+ return apiGet(`/tailnet/${getTailnet()}/users${qs ? `?${qs}` : ""}`);
22826
22989
  }
22827
22990
  },
22828
22991
  {
@@ -22931,6 +23094,26 @@ var userTools = [
22931
23094
  ];
22932
23095
 
22933
23096
  // src/tools/webhooks.ts
23097
+ var webhookEventTypes = [
23098
+ "nodeCreated",
23099
+ "nodeNeedsApproval",
23100
+ "nodeApproved",
23101
+ "nodeKeyExpiringInOneDay",
23102
+ "nodeKeyExpired",
23103
+ "nodeDeleted",
23104
+ "nodeSigned",
23105
+ "nodeNeedsSignature",
23106
+ "policyUpdate",
23107
+ "userCreated",
23108
+ "userNeedsApproval",
23109
+ "userSuspended",
23110
+ "userRestored",
23111
+ "userDeleted",
23112
+ "userApproved",
23113
+ "userRoleUpdated",
23114
+ "subnetIPForwardingNotEnabled",
23115
+ "exitNodeIPForwardingNotEnabled"
23116
+ ];
22934
23117
  var webhookTools = [
22935
23118
  {
22936
23119
  name: "tailscale_list_webhooks",
@@ -22976,9 +23159,7 @@ var webhookTools = [
22976
23159
  },
22977
23160
  inputSchema: external_exports.object({
22978
23161
  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
- )
23162
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).describe("Event types to subscribe to")
22982
23163
  }),
22983
23164
  handler: async (input) => {
22984
23165
  return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
@@ -23000,14 +23181,15 @@ var webhookTools = [
23000
23181
  inputSchema: external_exports.object({
23001
23182
  webhookId: external_exports.string().describe("The webhook ID to update"),
23002
23183
  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
- )
23184
+ subscriptions: external_exports.array(external_exports.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
23006
23185
  }),
23007
23186
  handler: async (input) => {
23008
23187
  const body = {};
23009
23188
  if (input.endpointUrl !== void 0) body.endpointUrl = input.endpointUrl;
23010
23189
  if (input.subscriptions !== void 0) body.subscriptions = input.subscriptions;
23190
+ if (Object.keys(body).length === 0) {
23191
+ throw new Error("No fields to update. Provide at least one of: endpointUrl, subscriptions.");
23192
+ }
23011
23193
  return apiPatch(`/webhooks/${encPath(input.webhookId)}`, body);
23012
23194
  }
23013
23195
  },
@@ -23109,13 +23291,15 @@ var workloadIdentityTools = [
23109
23291
  openWorldHint: true
23110
23292
  },
23111
23293
  inputSchema: external_exports.object({
23112
- name: external_exports.string().describe("A human-readable name for this provider"),
23294
+ name: external_exports.string().describe("A human-readable name for this provider (max 50 chars, alphanumeric/hyphens/spaces)"),
23113
23295
  issuerUrl: external_exports.string().describe("The OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com' for GitHub Actions)"),
23114
23296
  audience: external_exports.string().optional().describe("Expected audience claim in the OIDC token"),
23115
23297
  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
23298
  }),
23117
23299
  handler: async (input) => {
23118
- return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, input);
23300
+ const body = { ...input };
23301
+ body.name = sanitizeDescription(input.name);
23302
+ return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, body);
23119
23303
  }
23120
23304
  },
23121
23305
  {
@@ -23140,6 +23324,10 @@ var workloadIdentityTools = [
23140
23324
  for (const [key, value] of Object.entries(body)) {
23141
23325
  if (value !== void 0) cleanBody[key] = value;
23142
23326
  }
23327
+ if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
23328
+ if (Object.keys(cleanBody).length === 0) {
23329
+ throw new Error("No fields to update. Provide at least one of: name, audience, claimMappings.");
23330
+ }
23143
23331
  return apiPatch(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(providerId)}`, cleanBody);
23144
23332
  }
23145
23333
  },
@@ -23163,7 +23351,7 @@ var workloadIdentityTools = [
23163
23351
  ];
23164
23352
 
23165
23353
  // src/index.ts
23166
- var version2 = true ? "0.5.1" : (await null).createRequire(import.meta.url)("../package.json").version;
23354
+ var version2 = true ? "0.6.4" : (await null).createRequire(import.meta.url)("../package.json").version;
23167
23355
  var subcommand = process.argv[2];
23168
23356
  if (subcommand === "deploy-acl") {
23169
23357
  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.4",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",