@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.
- package/README.md +4 -3
- package/dist/index.js +240 -52
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
21364
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
22045
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
22077
|
-
|
|
22078
|
-
|
|
22079
|
-
|
|
22080
|
-
|
|
22081
|
-
|
|
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
|
|
22091
|
-
|
|
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
|
-
|
|
22103
|
-
|
|
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
|
|
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
|
|
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
|
|
22136
|
-
description: external_exports.string().optional().describe("Updated description
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22825
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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];
|