@yawlabs/tailscale-mcp 0.4.0 → 0.5.1

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 +33 -16
  2. package/dist/index.js +396 -40
  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.** 81 tools + 4 resources. One env var. Works on first try.
8
+ **Manage your Tailscale tailnet from Claude Code, Cursor, and any MCP client.** 98 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 (81)
99
+ ## Tools (98)
100
100
 
101
101
  <details>
102
102
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -108,7 +108,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
108
108
  </details>
109
109
 
110
110
  <details>
111
- <summary><strong>Devices</strong> (13 tools)</summary>
111
+ <summary><strong>Devices</strong> (16 tools)</summary>
112
112
 
113
113
  | Tool | Description |
114
114
  |------|-------------|
@@ -125,6 +125,9 @@ MCP Resources expose read-only data that clients can browse without tool calls.
125
125
  | `tailscale_set_device_posture_attribute` | Set a custom posture attribute (with optional expiry) |
126
126
  | `tailscale_delete_device_posture_attribute` | Delete a custom posture attribute |
127
127
  | `tailscale_set_device_tags` | Set ACL tags on a device |
128
+ | `tailscale_set_device_ip` | Set a device's Tailscale IPv4 address |
129
+ | `tailscale_update_device_key` | Update device key settings (e.g. disable key expiry) |
130
+ | `tailscale_batch_update_posture_attributes` | Batch update custom posture attributes across devices |
128
131
 
129
132
  </details>
130
133
 
@@ -141,7 +144,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
141
144
  </details>
142
145
 
143
146
  <details>
144
- <summary><strong>DNS</strong> (8 tools)</summary>
147
+ <summary><strong>DNS</strong> (11 tools)</summary>
145
148
 
146
149
  | Tool | Description |
147
150
  |------|-------------|
@@ -150,14 +153,17 @@ MCP Resources expose read-only data that clients can browse without tool calls.
150
153
  | `tailscale_get_search_paths` | Get DNS search paths |
151
154
  | `tailscale_set_search_paths` | Set DNS search paths |
152
155
  | `tailscale_get_split_dns` | Get split DNS configuration |
153
- | `tailscale_set_split_dns` | Set split DNS configuration |
156
+ | `tailscale_set_split_dns` | Set split DNS configuration (full replace) |
157
+ | `tailscale_update_split_dns` | Update split DNS configuration (partial merge) |
154
158
  | `tailscale_get_dns_preferences` | Get DNS preferences (MagicDNS) |
155
159
  | `tailscale_set_dns_preferences` | Set DNS preferences (MagicDNS) |
160
+ | `tailscale_get_dns_configuration` | Get unified DNS configuration (all settings in one call) |
161
+ | `tailscale_set_dns_configuration` | Set unified DNS configuration (all settings in one call) |
156
162
 
157
163
  </details>
158
164
 
159
165
  <details>
160
- <summary><strong>Auth Keys</strong> (4 tools)</summary>
166
+ <summary><strong>Auth Keys</strong> (5 tools)</summary>
161
167
 
162
168
  | Tool | Description |
163
169
  |------|-------------|
@@ -165,11 +171,12 @@ MCP Resources expose read-only data that clients can browse without tool calls.
165
171
  | `tailscale_get_key` | Get details for an auth key |
166
172
  | `tailscale_create_key` | Create a new auth key |
167
173
  | `tailscale_delete_key` | Delete an auth key |
174
+ | `tailscale_update_key` | Update an existing auth key |
168
175
 
169
176
  </details>
170
177
 
171
178
  <details>
172
- <summary><strong>Users</strong> (6 tools)</summary>
179
+ <summary><strong>Users</strong> (7 tools)</summary>
173
180
 
174
181
  | Tool | Description |
175
182
  |------|-------------|
@@ -179,18 +186,20 @@ MCP Resources expose read-only data that clients can browse without tool calls.
179
186
  | `tailscale_suspend_user` | Suspend a user, revoking access |
180
187
  | `tailscale_restore_user` | Restore a suspended user |
181
188
  | `tailscale_update_user_role` | Update a user's role (owner, admin, member, etc.) |
189
+ | `tailscale_delete_user` | Delete a user and all their devices |
182
190
 
183
191
  </details>
184
192
 
185
193
  <details>
186
- <summary><strong>Tailnet Settings</strong> (4 tools)</summary>
194
+ <summary><strong>Tailnet Settings</strong> (5 tools)</summary>
187
195
 
188
196
  | Tool | Description |
189
197
  |------|-------------|
190
198
  | `tailscale_get_tailnet_settings` | Get tailnet settings (HTTPS, device approval, key expiry, etc.) |
191
- | `tailscale_update_tailnet_settings` | Update tailnet settings (HTTPS certificates, approval, auto-updates, key expiry, posture, regional routing, network flow logging) |
199
+ | `tailscale_update_tailnet_settings` | Update tailnet settings (HTTPS certificates, approval, auto-updates, key expiry, posture, regional routing, network flow logging, external ACL management) |
192
200
  | `tailscale_get_contacts` | Get tailnet contacts |
193
201
  | `tailscale_set_contacts` | Set tailnet contacts |
202
+ | `tailscale_resend_contact_verification` | Resend verification email for a contact |
194
203
 
195
204
  </details>
196
205
 
@@ -204,7 +213,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
204
213
  </details>
205
214
 
206
215
  <details>
207
- <summary><strong>Webhooks</strong> (6 tools)</summary>
216
+ <summary><strong>Webhooks</strong> (7 tools)</summary>
208
217
 
209
218
  | Tool | Description |
210
219
  |------|-------------|
@@ -214,6 +223,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
214
223
  | `tailscale_update_webhook` | Update a webhook's endpoint URL and/or subscriptions |
215
224
  | `tailscale_delete_webhook` | Delete a webhook |
216
225
  | `tailscale_rotate_webhook_secret` | Rotate a webhook's secret |
226
+ | `tailscale_test_webhook` | Send a test event to verify webhook delivery |
217
227
 
218
228
  </details>
219
229
 
@@ -231,7 +241,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
231
241
  </details>
232
242
 
233
243
  <details>
234
- <summary><strong>Tailscale Services</strong> (5 tools)</summary>
244
+ <summary><strong>Tailscale Services</strong> (7 tools)</summary>
235
245
 
236
246
  | Tool | Description |
237
247
  |------|-------------|
@@ -240,18 +250,23 @@ MCP Resources expose read-only data that clients can browse without tool calls.
240
250
  | `tailscale_update_service` | Update a service's configuration |
241
251
  | `tailscale_delete_service` | Delete a service |
242
252
  | `tailscale_list_service_hosts` | List devices hosting a service |
253
+ | `tailscale_get_service_device_approval` | Get approval status of a device for a service |
254
+ | `tailscale_set_service_device_approval` | Approve or reject a device to host a service |
243
255
 
244
256
  </details>
245
257
 
246
258
  <details>
247
- <summary><strong>Log Streaming</strong> (4 tools)</summary>
259
+ <summary><strong>Log Streaming</strong> (7 tools)</summary>
248
260
 
249
261
  | Tool | Description |
250
262
  |------|-------------|
251
- | `tailscale_list_log_stream_configs` | List log streaming configurations |
263
+ | `tailscale_list_log_stream_configs` | List log streaming configurations (both audit and network) |
252
264
  | `tailscale_get_log_stream_config` | Get log streaming config for a log type |
253
265
  | `tailscale_set_log_stream_config` | Set where logs are sent (Axiom, Datadog, Splunk, etc.) |
254
266
  | `tailscale_delete_log_stream_config` | Delete a log streaming configuration |
267
+ | `tailscale_get_log_stream_status` | Check if log streaming is delivering successfully |
268
+ | `tailscale_create_aws_external_id` | Create/get AWS external ID for S3 log streaming |
269
+ | `tailscale_validate_aws_trust_policy` | Validate AWS IAM role trust policy for S3 log streaming |
255
270
 
256
271
  </details>
257
272
 
@@ -282,19 +297,20 @@ MCP Resources expose read-only data that clients can browse without tool calls.
282
297
  </details>
283
298
 
284
299
  <details>
285
- <summary><strong>Device Invites</strong> (4 tools)</summary>
300
+ <summary><strong>Device Invites</strong> (5 tools)</summary>
286
301
 
287
302
  | Tool | Description |
288
303
  |------|-------------|
289
- | `tailscale_list_device_invites` | List device invites |
304
+ | `tailscale_list_device_invites` | List device invites for a specific device |
290
305
  | `tailscale_create_device_invite` | Create a device invite |
291
306
  | `tailscale_get_device_invite` | Get a device invite |
292
307
  | `tailscale_delete_device_invite` | Delete a device invite |
308
+ | `tailscale_resend_device_invite` | Resend a device invite email |
293
309
 
294
310
  </details>
295
311
 
296
312
  <details>
297
- <summary><strong>User Invites</strong> (4 tools)</summary>
313
+ <summary><strong>User Invites</strong> (5 tools)</summary>
298
314
 
299
315
  | Tool | Description |
300
316
  |------|-------------|
@@ -302,6 +318,7 @@ MCP Resources expose read-only data that clients can browse without tool calls.
302
318
  | `tailscale_create_user_invite` | Create a user invite |
303
319
  | `tailscale_get_user_invite` | Get a user invite |
304
320
  | `tailscale_delete_user_invite` | Delete a user invite |
321
+ | `tailscale_resend_user_invite` | Resend a user invite email |
305
322
 
306
323
  </details>
307
324
 
package/dist/index.js CHANGED
@@ -21156,7 +21156,9 @@ async function deployAcl(filePath) {
21156
21156
  }
21157
21157
  const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
21158
21158
  rawBody: policy,
21159
- contentType: "application/hujson"
21159
+ contentType: "application/hujson",
21160
+ acceptRaw: true,
21161
+ accept: "application/hujson"
21160
21162
  });
21161
21163
  if (!validateRes.ok) {
21162
21164
  console.error(`ACL validation failed: ${validateRes.error}`);
@@ -21165,7 +21167,9 @@ async function deployAcl(filePath) {
21165
21167
  const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
21166
21168
  rawBody: policy,
21167
21169
  contentType: "application/hujson",
21168
- ifMatch: getRes.etag
21170
+ ifMatch: getRes.etag,
21171
+ acceptRaw: true,
21172
+ accept: "application/hujson"
21169
21173
  });
21170
21174
  if (!deployRes.ok) {
21171
21175
  console.error(`ACL deploy failed: ${deployRes.error}`);
@@ -21225,7 +21229,9 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21225
21229
  return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
21226
21230
  rawBody: input.policy,
21227
21231
  contentType: "application/hujson",
21228
- ifMatch: input.etag
21232
+ ifMatch: input.etag,
21233
+ acceptRaw: true,
21234
+ accept: "application/hujson"
21229
21235
  });
21230
21236
  }
21231
21237
  },
@@ -21245,10 +21251,12 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21245
21251
  handler: async (input) => {
21246
21252
  const res = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
21247
21253
  rawBody: input.policy,
21248
- contentType: "application/hujson"
21254
+ contentType: "application/hujson",
21255
+ acceptRaw: true,
21256
+ accept: "application/hujson"
21249
21257
  });
21250
- if (res.ok && !res.data) {
21251
- return { ...res, data: { message: "ACL policy is valid." } };
21258
+ if (res.ok && !res.rawBody) {
21259
+ return { ...res, rawBody: "ACL policy is valid." };
21252
21260
  }
21253
21261
  return res;
21254
21262
  }
@@ -21272,7 +21280,9 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
21272
21280
  const params = new URLSearchParams({ type: input.type, previewFor: input.previewFor });
21273
21281
  return apiPost(`/tailnet/${getTailnet()}/acl/preview?${params}`, void 0, {
21274
21282
  rawBody: input.policy,
21275
- contentType: "application/hujson"
21283
+ contentType: "application/hujson",
21284
+ acceptRaw: true,
21285
+ accept: "application/hujson"
21276
21286
  });
21277
21287
  }
21278
21288
  }
@@ -21350,7 +21360,7 @@ var deviceTools = [
21350
21360
  )
21351
21361
  }),
21352
21362
  handler: async (input) => {
21353
- const params = input.fields ? `?fields=${encodeURIComponent(input.fields)}` : "";
21363
+ const params = input.fields ? `?${new URLSearchParams({ fields: input.fields })}` : "";
21354
21364
  return apiGet(`/tailnet/${getTailnet()}/devices${params}`);
21355
21365
  }
21356
21366
  },
@@ -21577,6 +21587,63 @@ var deviceTools = [
21577
21587
  }
21578
21588
  return apiPost(`/device/${encPath(input.deviceId)}/tags`, { tags: input.tags });
21579
21589
  }
21590
+ },
21591
+ {
21592
+ name: "tailscale_set_device_ip",
21593
+ description: "Set the Tailscale IPv4 address for a device.",
21594
+ annotations: {
21595
+ title: "Set device IP",
21596
+ readOnlyHint: false,
21597
+ destructiveHint: false,
21598
+ idempotentHint: true,
21599
+ openWorldHint: true
21600
+ },
21601
+ inputSchema: external_exports.object({
21602
+ deviceId: external_exports.string().describe("The device ID"),
21603
+ ipv4: external_exports.string().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
21604
+ }),
21605
+ handler: async (input) => {
21606
+ return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
21607
+ }
21608
+ },
21609
+ {
21610
+ name: "tailscale_update_device_key",
21611
+ description: "Update a device's key settings, such as disabling key expiry. Useful for servers that should never need to re-authenticate.",
21612
+ annotations: {
21613
+ title: "Update device key",
21614
+ readOnlyHint: false,
21615
+ destructiveHint: false,
21616
+ idempotentHint: true,
21617
+ openWorldHint: true
21618
+ },
21619
+ inputSchema: external_exports.object({
21620
+ deviceId: external_exports.string().describe("The device ID"),
21621
+ keyExpiryDisabled: external_exports.boolean().describe("Whether to disable key expiry for this device")
21622
+ }),
21623
+ handler: async (input) => {
21624
+ return apiPost(`/device/${encPath(input.deviceId)}/key`, {
21625
+ keyExpiryDisabled: input.keyExpiryDisabled
21626
+ });
21627
+ }
21628
+ },
21629
+ {
21630
+ name: "tailscale_batch_update_posture_attributes",
21631
+ description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'.",
21632
+ annotations: {
21633
+ title: "Batch update posture attributes",
21634
+ readOnlyHint: false,
21635
+ destructiveHint: false,
21636
+ idempotentHint: true,
21637
+ openWorldHint: true
21638
+ },
21639
+ inputSchema: external_exports.object({
21640
+ attributes: external_exports.record(external_exports.string(), external_exports.record(external_exports.string(), external_exports.unknown())).describe(
21641
+ 'Map of device ID to attribute map (e.g. { "12345": { "custom:compliant": "true" }, "67890": { "custom:compliant": "false" } })'
21642
+ )
21643
+ }),
21644
+ handler: async (input) => {
21645
+ return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, input.attributes);
21646
+ }
21580
21647
  }
21581
21648
  ];
21582
21649
 
@@ -21715,6 +21782,64 @@ var dnsTools = [
21715
21782
  magicDNS: input.magicDNS
21716
21783
  });
21717
21784
  }
21785
+ },
21786
+ {
21787
+ name: "tailscale_update_split_dns",
21788
+ description: "Partially update split DNS configuration. Merges the provided domains with the existing config \u2014 only the specified domains are changed, others are untouched. Set a domain's nameservers to an empty array to remove it.",
21789
+ annotations: {
21790
+ title: "Update split DNS (partial)",
21791
+ readOnlyHint: false,
21792
+ destructiveHint: false,
21793
+ idempotentHint: true,
21794
+ openWorldHint: true
21795
+ },
21796
+ inputSchema: external_exports.object({
21797
+ splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).describe(
21798
+ 'Map of domain to nameserver list to merge (e.g. { "new.example.com": ["10.0.0.3"] }). Only specified domains are changed.'
21799
+ )
21800
+ }),
21801
+ handler: async (input) => {
21802
+ return apiPatch(`/tailnet/${getTailnet()}/dns/split-dns`, input.splitDns);
21803
+ }
21804
+ },
21805
+ {
21806
+ name: "tailscale_get_dns_configuration",
21807
+ description: "Get the unified DNS configuration for your tailnet, including nameservers, search paths, split DNS, and MagicDNS preference in a single call.",
21808
+ annotations: {
21809
+ title: "Get DNS configuration (unified)",
21810
+ readOnlyHint: true,
21811
+ destructiveHint: false,
21812
+ idempotentHint: true,
21813
+ openWorldHint: true
21814
+ },
21815
+ inputSchema: external_exports.object({}),
21816
+ handler: async () => {
21817
+ return apiGet(`/tailnet/${getTailnet()}/dns/configuration`);
21818
+ }
21819
+ },
21820
+ {
21821
+ name: "tailscale_set_dns_configuration",
21822
+ description: "Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference).",
21823
+ annotations: {
21824
+ title: "Set DNS configuration (unified)",
21825
+ readOnlyHint: false,
21826
+ destructiveHint: false,
21827
+ idempotentHint: true,
21828
+ openWorldHint: true
21829
+ },
21830
+ inputSchema: external_exports.object({
21831
+ dns: external_exports.array(external_exports.string()).optional().describe("List of DNS server IP addresses"),
21832
+ searchPaths: external_exports.array(external_exports.string()).optional().describe("List of DNS search domains"),
21833
+ splitDns: external_exports.record(external_exports.string(), external_exports.array(external_exports.string())).optional().describe("Map of domain to nameserver list for split DNS"),
21834
+ magicDNS: external_exports.boolean().optional().describe("Whether to enable MagicDNS")
21835
+ }),
21836
+ handler: async (input) => {
21837
+ const body = {};
21838
+ for (const [key, value] of Object.entries(input)) {
21839
+ if (value !== void 0) body[key] = value;
21840
+ }
21841
+ return apiPost(`/tailnet/${getTailnet()}/dns/configuration`, body);
21842
+ }
21718
21843
  }
21719
21844
  ];
21720
21845
 
@@ -21723,7 +21848,7 @@ var inviteTools = [
21723
21848
  // --- Device Invites ---
21724
21849
  {
21725
21850
  name: "tailscale_list_device_invites",
21726
- description: "List all device invites for your tailnet.",
21851
+ description: "List all device invites for a specific device.",
21727
21852
  annotations: {
21728
21853
  title: "List device invites",
21729
21854
  readOnlyHint: true,
@@ -21731,14 +21856,16 @@ var inviteTools = [
21731
21856
  idempotentHint: true,
21732
21857
  openWorldHint: true
21733
21858
  },
21734
- inputSchema: external_exports.object({}),
21735
- handler: async () => {
21736
- return apiGet(`/tailnet/${getTailnet()}/device-invites`);
21859
+ inputSchema: external_exports.object({
21860
+ deviceId: external_exports.string().describe("The device ID to list invites for")
21861
+ }),
21862
+ handler: async (input) => {
21863
+ return apiGet(`/device/${encPath(input.deviceId)}/device-invites`);
21737
21864
  }
21738
21865
  },
21739
21866
  {
21740
21867
  name: "tailscale_create_device_invite",
21741
- description: "Create a new device invite that allows someone to add a device to your tailnet.",
21868
+ description: "Create a new device invite that allows someone to add a specific device to your tailnet.",
21742
21869
  annotations: {
21743
21870
  title: "Create device invite",
21744
21871
  readOnlyHint: false,
@@ -21747,6 +21874,7 @@ var inviteTools = [
21747
21874
  openWorldHint: true
21748
21875
  },
21749
21876
  inputSchema: external_exports.object({
21877
+ deviceId: external_exports.string().describe("The device ID to create an invite for"),
21750
21878
  multiUse: external_exports.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
21751
21879
  allowExitNode: external_exports.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
21752
21880
  email: external_exports.string().optional().describe("Email address to send the invite to")
@@ -21756,7 +21884,7 @@ var inviteTools = [
21756
21884
  if (input.multiUse !== void 0) body.multiUse = input.multiUse;
21757
21885
  if (input.allowExitNode !== void 0) body.allowExitNode = input.allowExitNode;
21758
21886
  if (input.email !== void 0) body.email = input.email;
21759
- return apiPost(`/tailnet/${getTailnet()}/device-invites`, body);
21887
+ return apiPost(`/device/${encPath(input.deviceId)}/device-invites`, body);
21760
21888
  }
21761
21889
  },
21762
21890
  {
@@ -21863,6 +21991,40 @@ var inviteTools = [
21863
21991
  handler: async (input) => {
21864
21992
  return apiDelete(`/user-invites/${encPath(input.inviteId)}`);
21865
21993
  }
21994
+ },
21995
+ {
21996
+ name: "tailscale_resend_device_invite",
21997
+ description: "Resend a device invite email.",
21998
+ annotations: {
21999
+ title: "Resend device invite",
22000
+ readOnlyHint: false,
22001
+ destructiveHint: false,
22002
+ idempotentHint: true,
22003
+ openWorldHint: true
22004
+ },
22005
+ inputSchema: external_exports.object({
22006
+ inviteId: external_exports.string().describe("The device invite ID to resend")
22007
+ }),
22008
+ handler: async (input) => {
22009
+ return apiPost(`/device-invites/${encPath(input.inviteId)}/resend`);
22010
+ }
22011
+ },
22012
+ {
22013
+ name: "tailscale_resend_user_invite",
22014
+ description: "Resend a user invite email.",
22015
+ annotations: {
22016
+ title: "Resend user invite",
22017
+ readOnlyHint: false,
22018
+ destructiveHint: false,
22019
+ idempotentHint: true,
22020
+ openWorldHint: true
22021
+ },
22022
+ inputSchema: external_exports.object({
22023
+ inviteId: external_exports.string().describe("The user invite ID to resend")
22024
+ }),
22025
+ handler: async (input) => {
22026
+ return apiPost(`/user-invites/${encPath(input.inviteId)}/resend`);
22027
+ }
21866
22028
  }
21867
22029
  ];
21868
22030
 
@@ -21919,6 +22081,12 @@ var keyTools = [
21919
22081
  description: external_exports.string().optional().describe("Description for this key")
21920
22082
  }),
21921
22083
  handler: async (input) => {
22084
+ if (input.tags && input.tags.length > 0) {
22085
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22086
+ if (invalid.length > 0) {
22087
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22088
+ }
22089
+ }
21922
22090
  const body = {
21923
22091
  capabilities: {
21924
22092
  devices: {
@@ -21952,6 +22120,26 @@ var keyTools = [
21952
22120
  handler: async (input) => {
21953
22121
  return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
21954
22122
  }
22123
+ },
22124
+ {
22125
+ name: "tailscale_update_key",
22126
+ description: "Update an existing auth key's description or capabilities.",
22127
+ annotations: {
22128
+ title: "Update auth key",
22129
+ readOnlyHint: false,
22130
+ destructiveHint: false,
22131
+ idempotentHint: true,
22132
+ openWorldHint: true
22133
+ },
22134
+ 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")
22137
+ }),
22138
+ handler: async (input) => {
22139
+ const body = {};
22140
+ if (input.description !== void 0) body.description = input.description;
22141
+ return apiPut(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`, body);
22142
+ }
21955
22143
  }
21956
22144
  ];
21957
22145
 
@@ -21959,7 +22147,7 @@ var keyTools = [
21959
22147
  var logStreamingTools = [
21960
22148
  {
21961
22149
  name: "tailscale_list_log_stream_configs",
21962
- description: "List all log streaming configurations for your tailnet. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, S3, or GCS.",
22150
+ description: "List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, S3, or GCS.",
21963
22151
  annotations: {
21964
22152
  title: "List log stream configs",
21965
22153
  readOnlyHint: true,
@@ -21969,7 +22157,18 @@ var logStreamingTools = [
21969
22157
  },
21970
22158
  inputSchema: external_exports.object({}),
21971
22159
  handler: async () => {
21972
- return apiGet(`/tailnet/${getTailnet()}/logging/stream`);
22160
+ const [configuration, network] = await Promise.all([
22161
+ apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
22162
+ apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
22163
+ ]);
22164
+ return {
22165
+ ok: true,
22166
+ status: 200,
22167
+ data: {
22168
+ configuration: configuration.ok ? configuration.data : { error: configuration.error },
22169
+ network: network.ok ? network.data : { error: network.error }
22170
+ }
22171
+ };
21973
22172
  }
21974
22173
  },
21975
22174
  {
@@ -21986,7 +22185,7 @@ var logStreamingTools = [
21986
22185
  logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs")
21987
22186
  }),
21988
22187
  handler: async (input) => {
21989
- return apiGet(`/tailnet/${getTailnet()}/logging/stream/${encPath(input.logType)}`);
22188
+ return apiGet(`/tailnet/${getTailnet()}/logging/${encPath(input.logType)}/stream`);
21990
22189
  }
21991
22190
  },
21992
22191
  {
@@ -22014,7 +22213,7 @@ var logStreamingTools = [
22014
22213
  for (const [key, value] of Object.entries(body)) {
22015
22214
  if (value !== void 0) cleanBody[key] = value;
22016
22215
  }
22017
- return apiPut(`/tailnet/${getTailnet()}/logging/stream/${encPath(logType)}`, cleanBody);
22216
+ return apiPut(`/tailnet/${getTailnet()}/logging/${encPath(logType)}/stream`, cleanBody);
22018
22217
  }
22019
22218
  },
22020
22219
  {
@@ -22031,7 +22230,60 @@ var logStreamingTools = [
22031
22230
  logType: external_exports.enum(["configuration", "network"]).describe("The log type to stop streaming: 'configuration' or 'network'")
22032
22231
  }),
22033
22232
  handler: async (input) => {
22034
- return apiDelete(`/tailnet/${getTailnet()}/logging/stream/${encPath(input.logType)}`);
22233
+ return apiDelete(`/tailnet/${getTailnet()}/logging/${encPath(input.logType)}/stream`);
22234
+ }
22235
+ },
22236
+ {
22237
+ name: "tailscale_get_log_stream_status",
22238
+ description: "Get the status of log streaming for a specific log type. Shows whether logs are being delivered successfully.",
22239
+ annotations: {
22240
+ title: "Get log stream status",
22241
+ readOnlyHint: true,
22242
+ destructiveHint: false,
22243
+ idempotentHint: true,
22244
+ openWorldHint: true
22245
+ },
22246
+ inputSchema: external_exports.object({
22247
+ logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs")
22248
+ }),
22249
+ handler: async (input) => {
22250
+ return apiGet(`/tailnet/${getTailnet()}/logging/${encPath(input.logType)}/stream/status`);
22251
+ }
22252
+ },
22253
+ {
22254
+ name: "tailscale_create_aws_external_id",
22255
+ description: "Create or get an AWS external ID for your tailnet. Used when configuring log streaming to S3 \u2014 the external ID is included in the IAM role trust policy.",
22256
+ annotations: {
22257
+ title: "Create AWS external ID",
22258
+ readOnlyHint: false,
22259
+ destructiveHint: false,
22260
+ idempotentHint: true,
22261
+ openWorldHint: true
22262
+ },
22263
+ inputSchema: external_exports.object({}),
22264
+ handler: async () => {
22265
+ return apiPost(`/tailnet/${getTailnet()}/aws-external-id`);
22266
+ }
22267
+ },
22268
+ {
22269
+ name: "tailscale_validate_aws_trust_policy",
22270
+ description: "Validate that an AWS IAM role trust policy is correctly configured with the Tailscale external ID. Use this after setting up the IAM role for S3 log streaming.",
22271
+ annotations: {
22272
+ title: "Validate AWS trust policy",
22273
+ readOnlyHint: true,
22274
+ destructiveHint: false,
22275
+ idempotentHint: true,
22276
+ openWorldHint: true
22277
+ },
22278
+ inputSchema: external_exports.object({
22279
+ externalId: external_exports.string().describe("The AWS external ID to validate"),
22280
+ roleArn: external_exports.string().describe("The AWS IAM role ARN to validate against")
22281
+ }),
22282
+ handler: async (input) => {
22283
+ return apiPost(
22284
+ `/tailnet/${getTailnet()}/aws-external-id/${encPath(input.externalId)}/validate-aws-trust-policy`,
22285
+ { roleArn: input.roleArn }
22286
+ );
22035
22287
  }
22036
22288
  }
22037
22289
  ];
@@ -22108,6 +22360,12 @@ var oauthClientTools = [
22108
22360
  description: external_exports.string().optional().describe("Description for this OAuth client")
22109
22361
  }),
22110
22362
  handler: async (input) => {
22363
+ if (input.tags && input.tags.length > 0) {
22364
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22365
+ if (invalid.length > 0) {
22366
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22367
+ }
22368
+ }
22111
22369
  return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, input);
22112
22370
  }
22113
22371
  },
@@ -22311,12 +22569,18 @@ var serviceTools = [
22311
22569
  autoApproveHosts: external_exports.boolean().optional().describe("Whether to auto-approve devices that want to host this service")
22312
22570
  }),
22313
22571
  handler: async (input) => {
22572
+ if (input.tags && input.tags.length > 0) {
22573
+ const invalid = input.tags.filter((t) => !t.startsWith("tag:"));
22574
+ if (invalid.length > 0) {
22575
+ throw new Error(`All tags must start with 'tag:' prefix. Invalid tags: ${invalid.join(", ")}`);
22576
+ }
22577
+ }
22314
22578
  const { serviceName, ...body } = input;
22315
22579
  const cleanBody = {};
22316
22580
  for (const [key, value] of Object.entries(body)) {
22317
22581
  if (value !== void 0) cleanBody[key] = value;
22318
22582
  }
22319
- return apiPatch(`/tailnet/${getTailnet()}/services/${encPath(serviceName)}`, cleanBody);
22583
+ return apiPut(`/tailnet/${getTailnet()}/services/${encPath(serviceName)}`, cleanBody);
22320
22584
  }
22321
22585
  },
22322
22586
  {
@@ -22350,7 +22614,49 @@ var serviceTools = [
22350
22614
  serviceName: external_exports.string().describe("The service name")
22351
22615
  }),
22352
22616
  handler: async (input) => {
22353
- return apiGet(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}/hosts`);
22617
+ return apiGet(`/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}/devices`);
22618
+ }
22619
+ },
22620
+ {
22621
+ name: "tailscale_get_service_device_approval",
22622
+ description: "Get the approval status of a specific device for a Tailscale Service.",
22623
+ annotations: {
22624
+ title: "Get service device approval",
22625
+ readOnlyHint: true,
22626
+ destructiveHint: false,
22627
+ idempotentHint: true,
22628
+ openWorldHint: true
22629
+ },
22630
+ inputSchema: external_exports.object({
22631
+ serviceName: external_exports.string().describe("The service name"),
22632
+ deviceId: external_exports.string().describe("The device ID")
22633
+ }),
22634
+ handler: async (input) => {
22635
+ return apiGet(
22636
+ `/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}/device/${encPath(input.deviceId)}/approved`
22637
+ );
22638
+ }
22639
+ },
22640
+ {
22641
+ name: "tailscale_set_service_device_approval",
22642
+ description: "Approve or reject a device to host a Tailscale Service.",
22643
+ annotations: {
22644
+ title: "Set service device approval",
22645
+ readOnlyHint: false,
22646
+ destructiveHint: false,
22647
+ idempotentHint: true,
22648
+ openWorldHint: true
22649
+ },
22650
+ inputSchema: external_exports.object({
22651
+ serviceName: external_exports.string().describe("The service name"),
22652
+ deviceId: external_exports.string().describe("The device ID"),
22653
+ approved: external_exports.boolean().describe("Whether to approve (true) or reject (false) the device")
22654
+ }),
22655
+ handler: async (input) => {
22656
+ return apiPost(
22657
+ `/tailnet/${getTailnet()}/services/${encPath(input.serviceName)}/device/${encPath(input.deviceId)}/approved`,
22658
+ { approved: input.approved }
22659
+ );
22354
22660
  }
22355
22661
  }
22356
22662
  ];
@@ -22428,21 +22734,15 @@ var tailnetTools = [
22428
22734
  networkFlowLoggingOn: external_exports.boolean().optional().describe("Whether network flow logging is enabled"),
22429
22735
  regionalRoutingOn: external_exports.boolean().optional().describe("Whether regional routing is enabled"),
22430
22736
  postureIdentityCollectionOn: external_exports.boolean().optional().describe("Whether posture identity collection is enabled"),
22431
- httpsEnabled: external_exports.boolean().optional().describe("Whether HTTPS certificates are enabled (for tailscale serve/funnel)")
22737
+ httpsEnabled: external_exports.boolean().optional().describe("Whether HTTPS certificates are enabled (for tailscale serve/funnel)"),
22738
+ aclsExternallyManagedOn: external_exports.boolean().optional().describe("Whether ACLs are externally managed (e.g. via GitOps)"),
22739
+ aclsExternalLink: external_exports.string().optional().describe("URL to the external ACL management system (shown in the admin console)")
22432
22740
  }),
22433
22741
  handler: async (input) => {
22434
22742
  const body = {};
22435
- if (input.devicesApprovalOn !== void 0) body.devicesApprovalOn = input.devicesApprovalOn;
22436
- if (input.devicesAutoUpdatesOn !== void 0) body.devicesAutoUpdatesOn = input.devicesAutoUpdatesOn;
22437
- if (input.devicesKeyDurationDays !== void 0) body.devicesKeyDurationDays = input.devicesKeyDurationDays;
22438
- if (input.usersApprovalOn !== void 0) body.usersApprovalOn = input.usersApprovalOn;
22439
- if (input.usersRoleAllowedToJoinExternalTailnets !== void 0)
22440
- body.usersRoleAllowedToJoinExternalTailnets = input.usersRoleAllowedToJoinExternalTailnets;
22441
- if (input.networkFlowLoggingOn !== void 0) body.networkFlowLoggingOn = input.networkFlowLoggingOn;
22442
- if (input.regionalRoutingOn !== void 0) body.regionalRoutingOn = input.regionalRoutingOn;
22443
- if (input.postureIdentityCollectionOn !== void 0)
22444
- body.postureIdentityCollectionOn = input.postureIdentityCollectionOn;
22445
- if (input.httpsEnabled !== void 0) body.httpsEnabled = input.httpsEnabled;
22743
+ for (const [key, value] of Object.entries(input)) {
22744
+ if (value !== void 0) body[key] = value;
22745
+ }
22446
22746
  return apiPatch(`/tailnet/${getTailnet()}/settings`, body);
22447
22747
  }
22448
22748
  },
@@ -22477,11 +22777,33 @@ var tailnetTools = [
22477
22777
  security: external_exports.object({ email: external_exports.string() }).optional().describe("Security contact email")
22478
22778
  }),
22479
22779
  handler: async (input) => {
22480
- const body = {};
22481
- if (input.account !== void 0) body.account = input.account;
22482
- if (input.support !== void 0) body.support = input.support;
22483
- if (input.security !== void 0) body.security = input.security;
22484
- return apiPatch(`/tailnet/${getTailnet()}/contacts`, body);
22780
+ const results = {};
22781
+ for (const contactType of ["account", "support", "security"]) {
22782
+ const value = input[contactType];
22783
+ if (value !== void 0) {
22784
+ const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
22785
+ if (!res.ok) return res;
22786
+ results[contactType] = res.data;
22787
+ }
22788
+ }
22789
+ return { ok: true, status: 200, data: results };
22790
+ }
22791
+ },
22792
+ {
22793
+ name: "tailscale_resend_contact_verification",
22794
+ description: "Resend the verification email for a tailnet contact.",
22795
+ annotations: {
22796
+ title: "Resend contact verification",
22797
+ readOnlyHint: false,
22798
+ destructiveHint: false,
22799
+ idempotentHint: true,
22800
+ openWorldHint: true
22801
+ },
22802
+ inputSchema: external_exports.object({
22803
+ contactType: external_exports.enum(["account", "support", "security"]).describe("The contact type to resend verification for")
22804
+ }),
22805
+ handler: async (input) => {
22806
+ return apiPost(`/tailnet/${getTailnet()}/contacts/${encPath(input.contactType)}/resend-verification-email`);
22485
22807
  }
22486
22808
  }
22487
22809
  ];
@@ -22586,7 +22908,24 @@ var userTools = [
22586
22908
  role: external_exports.enum(["owner", "admin", "it-admin", "network-admin", "billing-admin", "auditor", "member"]).describe("The new role to assign")
22587
22909
  }),
22588
22910
  handler: async (input) => {
22589
- return apiPatch(`/users/${encPath(input.userId)}/role`, { role: input.role });
22911
+ return apiPost(`/users/${encPath(input.userId)}/role`, { role: input.role });
22912
+ }
22913
+ },
22914
+ {
22915
+ name: "tailscale_delete_user",
22916
+ description: "Delete a user from the tailnet. This is irreversible \u2014 the user and all their devices will be removed.",
22917
+ annotations: {
22918
+ title: "Delete user",
22919
+ readOnlyHint: false,
22920
+ destructiveHint: true,
22921
+ idempotentHint: false,
22922
+ openWorldHint: true
22923
+ },
22924
+ inputSchema: external_exports.object({
22925
+ userId: external_exports.string().describe("The user ID to delete")
22926
+ }),
22927
+ handler: async (input) => {
22928
+ return apiPost(`/users/${encPath(input.userId)}/delete`);
22590
22929
  }
22591
22930
  }
22592
22931
  ];
@@ -22705,6 +23044,23 @@ var webhookTools = [
22705
23044
  handler: async (input) => {
22706
23045
  return apiPost(`/webhooks/${encPath(input.webhookId)}/rotate`);
22707
23046
  }
23047
+ },
23048
+ {
23049
+ name: "tailscale_test_webhook",
23050
+ description: "Send a test event to a webhook endpoint to verify it is configured correctly and receiving events.",
23051
+ annotations: {
23052
+ title: "Test webhook",
23053
+ readOnlyHint: false,
23054
+ destructiveHint: false,
23055
+ idempotentHint: true,
23056
+ openWorldHint: true
23057
+ },
23058
+ inputSchema: external_exports.object({
23059
+ webhookId: external_exports.string().describe("The webhook ID to test")
23060
+ }),
23061
+ handler: async (input) => {
23062
+ return apiPost(`/webhooks/${encPath(input.webhookId)}/test`);
23063
+ }
22708
23064
  }
22709
23065
  ];
22710
23066
 
@@ -22807,7 +23163,7 @@ var workloadIdentityTools = [
22807
23163
  ];
22808
23164
 
22809
23165
  // src/index.ts
22810
- var version2 = true ? "0.4.0" : (await null).createRequire(import.meta.url)("../package.json").version;
23166
+ var version2 = true ? "0.5.1" : (await null).createRequire(import.meta.url)("../package.json").version;
22811
23167
  var subcommand = process.argv[2];
22812
23168
  if (subcommand === "deploy-acl") {
22813
23169
  const filePath = process.argv[3];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",