@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.
- package/README.md +4 -3
- package/dist/index.js +245 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/tailscale-mcp/stargazers)
|
|
6
6
|
[](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [](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.**
|
|
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 (
|
|
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> (
|
|
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.
|
|
21251
|
-
return { ...res,
|
|
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
|
-
|
|
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 =
|
|
21354
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
22035
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
22067
|
-
|
|
22068
|
-
|
|
22069
|
-
|
|
22070
|
-
|
|
22071
|
-
|
|
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
|
-
|
|
22075
|
-
|
|
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
|
-
|
|
22087
|
-
|
|
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
|
|
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
|
|
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
|
|
22120
|
-
description: external_exports.string().optional().describe("Updated description
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22797
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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];
|