@yawlabs/tailscale-mcp 0.9.1 → 0.10.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.
- package/README.md +23 -7
- package/dist/index.js +251 -63
- 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
|
-
**Ask your agent questions about your tailnet and have it act on the answers.**
|
|
8
|
+
**Ask your agent questions about your tailnet and have it act on the answers.** 89 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ unit tests and an opt-in live-tailnet integration suite.
|
|
9
9
|
|
|
10
10
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
11
11
|
|
|
@@ -107,7 +107,7 @@ That's it. Now ask your agent:
|
|
|
107
107
|
|
|
108
108
|
## Too many tools? Subset them.
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
89 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
|
|
111
111
|
|
|
112
112
|
### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
|
|
113
113
|
|
|
@@ -120,9 +120,9 @@ That's it. Now ask your agent:
|
|
|
120
120
|
}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
- **`minimal`** (
|
|
124
|
-
- **`core`** (
|
|
125
|
-
- **`full`** (
|
|
123
|
+
- **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
|
|
124
|
+
- **`core`** (47 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
|
|
125
|
+
- **`full`** (89 tools, default) — everything. Same as omitting the env var.
|
|
126
126
|
|
|
127
127
|
### Option 2: `TAILSCALE_TOOLS` (explicit group list)
|
|
128
128
|
|
|
@@ -158,7 +158,7 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
|
|
|
158
158
|
The server logs the active filter to stderr on startup:
|
|
159
159
|
|
|
160
160
|
```
|
|
161
|
-
@yawlabs/tailscale-mcp v0.9.1 ready (
|
|
161
|
+
@yawlabs/tailscale-mcp v0.9.1 ready (20 tools, profile=minimal, readonly)
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
If you don't set any filter, startup prints a tip pointing you at the profiles.
|
|
@@ -182,6 +182,21 @@ The server checks for an API key first, then falls back to OAuth. If neither is
|
|
|
182
182
|
|
|
183
183
|
**Tailnet:** Uses your default tailnet automatically. Set `TAILSCALE_TAILNET` to specify one explicitly.
|
|
184
184
|
|
|
185
|
+
## Reliability and debugging
|
|
186
|
+
|
|
187
|
+
**429 retry (built-in).** API responses with HTTP 429 are retried up to 3 times, honoring the `Retry-After` header (both seconds-integer and HTTP-date forms). Falls back to exponential backoff with jitter, capped at 30s per wait. No env var needed — this is on by default. Workflows like "rotate every key older than 90 days" no longer fail mid-loop on Tailscale's per-tenant rate limits.
|
|
188
|
+
|
|
189
|
+
**`TAILSCALE_DEBUG=1`** — log every HTTP method, URL, status, and elapsed time to stderr. Authorization headers are never logged. Use this when a tool returns an unexpected error and you want to see the actual request that went out. Example:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
[tailscale-mcp] GET https://api.tailscale.com/api/v2/tailnet/-/devices
|
|
193
|
+
[tailscale-mcp] <- 200 (148ms)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**`TAILSCALE_MAX_CONCURRENT=N`** — cap in-flight API requests at `N`. Default is unlimited (no behavior change for users who don't opt in). Useful when an agent fans out aggressively against a tailnet that has stricter limits than the per-call retry can absorb.
|
|
197
|
+
|
|
198
|
+
**Friendlier error messages.** JSON error bodies of the form `{"message":"..."}` or `{"error":"..."}` are unwrapped before display, so you see the prose explanation instead of raw JSON. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
|
|
199
|
+
|
|
185
200
|
## Resources (4)
|
|
186
201
|
|
|
187
202
|
MCP Resources expose read-only data clients can browse without a tool call.
|
|
@@ -205,7 +220,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
205
220
|
</details>
|
|
206
221
|
|
|
207
222
|
<details>
|
|
208
|
-
<summary><strong>Devices</strong> (
|
|
223
|
+
<summary><strong>Devices</strong> (17 tools)</summary>
|
|
209
224
|
|
|
210
225
|
| Tool | Description |
|
|
211
226
|
|------|-------------|
|
|
@@ -213,6 +228,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
213
228
|
| `tailscale_get_device` | Get detailed info for a specific device |
|
|
214
229
|
| `tailscale_authorize_device` | Authorize a pending device |
|
|
215
230
|
| `tailscale_deauthorize_device` | Deauthorize a device |
|
|
231
|
+
| `tailscale_set_devices_authorized` | Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
|
|
216
232
|
| `tailscale_delete_device` | Remove a device from the tailnet |
|
|
217
233
|
| `tailscale_rename_device` | Rename a device |
|
|
218
234
|
| `tailscale_expire_device` | Expire a device's key, forcing re-authentication |
|
package/dist/index.js
CHANGED
|
@@ -30116,6 +30116,9 @@ var StdioServerTransport = class {
|
|
|
30116
30116
|
// src/api.ts
|
|
30117
30117
|
var BASE_URL = "https://api.tailscale.com/api/v2";
|
|
30118
30118
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
30119
|
+
var MAX_429_RETRIES = 3;
|
|
30120
|
+
var DEFAULT_429_DELAY_MS = 1e3;
|
|
30121
|
+
var MAX_429_DELAY_MS = 3e4;
|
|
30119
30122
|
var oauthToken = null;
|
|
30120
30123
|
var oauthRefreshPromise = null;
|
|
30121
30124
|
function getAuthConfig() {
|
|
@@ -30157,7 +30160,8 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
30157
30160
|
});
|
|
30158
30161
|
if (!res.ok) {
|
|
30159
30162
|
const body = await res.text();
|
|
30160
|
-
|
|
30163
|
+
const guidance = res.status === 401 || res.status === 403 ? " Verify TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, and that the client has the scopes your tools need (https://login.tailscale.com/admin/settings/oauth)." : "";
|
|
30164
|
+
throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
|
|
30161
30165
|
}
|
|
30162
30166
|
const data = await res.json();
|
|
30163
30167
|
oauthToken = {
|
|
@@ -30218,6 +30222,71 @@ function formatAuthError(apiBody) {
|
|
|
30218
30222
|
}
|
|
30219
30223
|
return lines.join("\n");
|
|
30220
30224
|
}
|
|
30225
|
+
function extractErrorMessage(body) {
|
|
30226
|
+
if (!body) return body;
|
|
30227
|
+
const trimmed = body.trim();
|
|
30228
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return body;
|
|
30229
|
+
try {
|
|
30230
|
+
const parsed = JSON.parse(trimmed);
|
|
30231
|
+
if (parsed && typeof parsed === "object") {
|
|
30232
|
+
const obj = parsed;
|
|
30233
|
+
if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
|
|
30234
|
+
if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
|
|
30235
|
+
}
|
|
30236
|
+
} catch {
|
|
30237
|
+
}
|
|
30238
|
+
return body;
|
|
30239
|
+
}
|
|
30240
|
+
var inFlight = 0;
|
|
30241
|
+
var concurrencyQueue = [];
|
|
30242
|
+
function getConcurrencyLimit() {
|
|
30243
|
+
const raw = process.env.TAILSCALE_MAX_CONCURRENT;
|
|
30244
|
+
if (!raw) return 0;
|
|
30245
|
+
const n = Number.parseInt(raw, 10);
|
|
30246
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
30247
|
+
}
|
|
30248
|
+
async function withConcurrencyLimit(fn) {
|
|
30249
|
+
const limit = getConcurrencyLimit();
|
|
30250
|
+
if (limit === 0) return fn();
|
|
30251
|
+
if (inFlight >= limit) {
|
|
30252
|
+
await new Promise((resolve) => concurrencyQueue.push(resolve));
|
|
30253
|
+
}
|
|
30254
|
+
inFlight++;
|
|
30255
|
+
try {
|
|
30256
|
+
return await fn();
|
|
30257
|
+
} finally {
|
|
30258
|
+
inFlight--;
|
|
30259
|
+
const next = concurrencyQueue.shift();
|
|
30260
|
+
if (next) next();
|
|
30261
|
+
}
|
|
30262
|
+
}
|
|
30263
|
+
function debugLog(...parts) {
|
|
30264
|
+
if (process.env.TAILSCALE_DEBUG === "1" || process.env.TAILSCALE_DEBUG === "true") {
|
|
30265
|
+
console.error("[tailscale-mcp]", ...parts);
|
|
30266
|
+
}
|
|
30267
|
+
}
|
|
30268
|
+
function compute429DelayMs(retryAfter, attempt) {
|
|
30269
|
+
if (retryAfter) {
|
|
30270
|
+
const asInt = Number.parseInt(retryAfter, 10);
|
|
30271
|
+
if (Number.isFinite(asInt) && asInt >= 0) {
|
|
30272
|
+
return Math.min(asInt * 1e3, MAX_429_DELAY_MS);
|
|
30273
|
+
}
|
|
30274
|
+
const asDate = Date.parse(retryAfter);
|
|
30275
|
+
if (Number.isFinite(asDate)) {
|
|
30276
|
+
return Math.max(0, Math.min(asDate - Date.now(), MAX_429_DELAY_MS));
|
|
30277
|
+
}
|
|
30278
|
+
}
|
|
30279
|
+
const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
|
|
30280
|
+
return base + Math.floor(Math.random() * 250);
|
|
30281
|
+
}
|
|
30282
|
+
async function executeFetch(method, url2, headers, body) {
|
|
30283
|
+
return fetch(url2, {
|
|
30284
|
+
method,
|
|
30285
|
+
headers,
|
|
30286
|
+
body,
|
|
30287
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
30288
|
+
});
|
|
30289
|
+
}
|
|
30221
30290
|
async function apiRequest(method, path, body, options) {
|
|
30222
30291
|
const auth = await getAuthHeader();
|
|
30223
30292
|
const headers = {
|
|
@@ -30238,31 +30307,41 @@ async function apiRequest(method, path, body, options) {
|
|
|
30238
30307
|
fetchBody = JSON.stringify(body);
|
|
30239
30308
|
}
|
|
30240
30309
|
const url2 = path.startsWith("http") ? path : `${BASE_URL}${path}`;
|
|
30241
|
-
const
|
|
30242
|
-
|
|
30243
|
-
|
|
30244
|
-
|
|
30245
|
-
|
|
30310
|
+
const startedAt = Date.now();
|
|
30311
|
+
debugLog(`${method} ${url2}`);
|
|
30312
|
+
return withConcurrencyLimit(async () => {
|
|
30313
|
+
let res;
|
|
30314
|
+
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
30315
|
+
res = await executeFetch(method, url2, headers, fetchBody);
|
|
30316
|
+
if (res.status !== 429 || attempt === MAX_429_RETRIES) break;
|
|
30317
|
+
const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
|
|
30318
|
+
debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
30319
|
+
await res.text().catch(() => void 0);
|
|
30320
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
30321
|
+
}
|
|
30322
|
+
const response = res;
|
|
30323
|
+
const etag = response.headers.get("etag") || void 0;
|
|
30324
|
+
const elapsed = Date.now() - startedAt;
|
|
30325
|
+
debugLog(` <- ${response.status} (${elapsed}ms)`);
|
|
30326
|
+
if (options?.acceptRaw) {
|
|
30327
|
+
const rawBody = await response.text();
|
|
30328
|
+
if (!response.ok) {
|
|
30329
|
+
const error48 = response.status === 401 ? formatAuthError(rawBody) : extractErrorMessage(rawBody);
|
|
30330
|
+
return { ok: false, status: response.status, error: error48, rawBody, etag };
|
|
30331
|
+
}
|
|
30332
|
+
return { ok: true, status: response.status, rawBody, etag };
|
|
30333
|
+
}
|
|
30334
|
+
if (!response.ok) {
|
|
30335
|
+
const errorBody = await response.text();
|
|
30336
|
+
const error48 = response.status === 401 ? formatAuthError(errorBody) : extractErrorMessage(errorBody);
|
|
30337
|
+
return { ok: false, status: response.status, error: error48, etag };
|
|
30338
|
+
}
|
|
30339
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
30340
|
+
return { ok: true, status: response.status, etag };
|
|
30341
|
+
}
|
|
30342
|
+
const data = await response.json();
|
|
30343
|
+
return { ok: true, status: response.status, data, etag };
|
|
30246
30344
|
});
|
|
30247
|
-
const etag = res.headers.get("etag") || void 0;
|
|
30248
|
-
if (options?.acceptRaw) {
|
|
30249
|
-
const rawBody = await res.text();
|
|
30250
|
-
if (!res.ok) {
|
|
30251
|
-
const error48 = res.status === 401 ? formatAuthError(rawBody) : rawBody;
|
|
30252
|
-
return { ok: false, status: res.status, error: error48, rawBody, etag };
|
|
30253
|
-
}
|
|
30254
|
-
return { ok: true, status: res.status, rawBody, etag };
|
|
30255
|
-
}
|
|
30256
|
-
if (!res.ok) {
|
|
30257
|
-
const errorBody = await res.text();
|
|
30258
|
-
const error48 = res.status === 401 ? formatAuthError(errorBody) : errorBody;
|
|
30259
|
-
return { ok: false, status: res.status, error: error48, etag };
|
|
30260
|
-
}
|
|
30261
|
-
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
30262
|
-
return { ok: true, status: res.status, etag };
|
|
30263
|
-
}
|
|
30264
|
-
const data = await res.json();
|
|
30265
|
-
return { ok: true, status: res.status, data, etag };
|
|
30266
30345
|
}
|
|
30267
30346
|
async function apiGet(path, options) {
|
|
30268
30347
|
return apiRequest("GET", path, void 0, options);
|
|
@@ -30270,14 +30349,14 @@ async function apiGet(path, options) {
|
|
|
30270
30349
|
async function apiPost(path, body, options) {
|
|
30271
30350
|
return apiRequest("POST", path, body, options);
|
|
30272
30351
|
}
|
|
30273
|
-
async function apiPut(path, body) {
|
|
30274
|
-
return apiRequest("PUT", path, body);
|
|
30352
|
+
async function apiPut(path, body, options) {
|
|
30353
|
+
return apiRequest("PUT", path, body, options);
|
|
30275
30354
|
}
|
|
30276
|
-
async function apiPatch(path, body) {
|
|
30277
|
-
return apiRequest("PATCH", path, body);
|
|
30355
|
+
async function apiPatch(path, body, options) {
|
|
30356
|
+
return apiRequest("PATCH", path, body, options);
|
|
30278
30357
|
}
|
|
30279
|
-
async function apiDelete(path) {
|
|
30280
|
-
return apiRequest("DELETE", path);
|
|
30358
|
+
async function apiDelete(path, options) {
|
|
30359
|
+
return apiRequest("DELETE", path, void 0, options);
|
|
30281
30360
|
}
|
|
30282
30361
|
|
|
30283
30362
|
// src/cli.ts
|
|
@@ -30378,14 +30457,14 @@ var aclTools = [
|
|
|
30378
30457
|
accept: "application/hujson"
|
|
30379
30458
|
});
|
|
30380
30459
|
if (res.ok && res.etag) {
|
|
30381
|
-
|
|
30382
|
-
|
|
30383
|
-
|
|
30384
|
-
|
|
30385
|
-
|
|
30386
|
-
|
|
30387
|
-
|
|
30388
|
-
};
|
|
30460
|
+
const footer = [
|
|
30461
|
+
"",
|
|
30462
|
+
`// ETag: ${res.etag}`,
|
|
30463
|
+
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
30464
|
+
"// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
|
|
30465
|
+
""
|
|
30466
|
+
].join("\n");
|
|
30467
|
+
return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
|
|
30389
30468
|
}
|
|
30390
30469
|
return res;
|
|
30391
30470
|
}
|
|
@@ -30476,6 +30555,19 @@ function assertRFC3339(value, label) {
|
|
|
30476
30555
|
throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
|
|
30477
30556
|
}
|
|
30478
30557
|
}
|
|
30558
|
+
var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
30559
|
+
function assertLogRange(start, end, label) {
|
|
30560
|
+
const startMs = Date.parse(start);
|
|
30561
|
+
const endMs = end ? Date.parse(end) : Date.now();
|
|
30562
|
+
if (endMs < startMs) {
|
|
30563
|
+
throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
|
|
30564
|
+
}
|
|
30565
|
+
if (endMs - startMs > MAX_LOG_RANGE_MS) {
|
|
30566
|
+
throw new Error(
|
|
30567
|
+
`${label}: range exceeds the 30-day Tailscale API limit. start=${start} end=${end ?? "<now>"}. Split the query into <=30-day windows.`
|
|
30568
|
+
);
|
|
30569
|
+
}
|
|
30570
|
+
}
|
|
30479
30571
|
var auditTools = [
|
|
30480
30572
|
{
|
|
30481
30573
|
name: "tailscale_get_audit_log",
|
|
@@ -30494,6 +30586,7 @@ var auditTools = [
|
|
|
30494
30586
|
handler: async (input) => {
|
|
30495
30587
|
assertRFC3339(input.start, "start");
|
|
30496
30588
|
if (input.end) assertRFC3339(input.end, "end");
|
|
30589
|
+
assertLogRange(input.start, input.end, "tailscale_get_audit_log");
|
|
30497
30590
|
const params = new URLSearchParams({ start: input.start });
|
|
30498
30591
|
if (input.end) params.set("end", input.end);
|
|
30499
30592
|
return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
|
|
@@ -30516,6 +30609,7 @@ var auditTools = [
|
|
|
30516
30609
|
handler: async (input) => {
|
|
30517
30610
|
assertRFC3339(input.start, "start");
|
|
30518
30611
|
if (input.end) assertRFC3339(input.end, "end");
|
|
30612
|
+
assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs");
|
|
30519
30613
|
const params = new URLSearchParams({ start: input.start });
|
|
30520
30614
|
if (input.end) params.set("end", input.end);
|
|
30521
30615
|
return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
|
|
@@ -30687,7 +30781,16 @@ var deviceTools = [
|
|
|
30687
30781
|
},
|
|
30688
30782
|
inputSchema: external_exports3.object({
|
|
30689
30783
|
deviceId: external_exports3.string().describe("The device ID"),
|
|
30690
|
-
routes: external_exports3.array(
|
|
30784
|
+
routes: external_exports3.array(
|
|
30785
|
+
external_exports3.string().refine(
|
|
30786
|
+
// Accept v4 (10.0.0.0/24) or v6 (fd7a:115c::/48) CIDRs. Routes can be either.
|
|
30787
|
+
// Loose check: must contain a '/' followed by 1-3 digits, and the address part
|
|
30788
|
+
// must look like an IPv4 quad-dotted or an IPv6 colon-form. The Tailscale API
|
|
30789
|
+
// is the authoritative validator; this just rejects obvious typos client-side.
|
|
30790
|
+
(s) => /^([\d.]+|[\da-fA-F:]+)\/\d{1,3}$/.test(s),
|
|
30791
|
+
{ message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" }
|
|
30792
|
+
)
|
|
30793
|
+
).describe(
|
|
30691
30794
|
"Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
|
|
30692
30795
|
)
|
|
30693
30796
|
}),
|
|
@@ -30791,7 +30894,7 @@ var deviceTools = [
|
|
|
30791
30894
|
},
|
|
30792
30895
|
inputSchema: external_exports3.object({
|
|
30793
30896
|
deviceId: external_exports3.string().describe("The device ID"),
|
|
30794
|
-
ipv4: external_exports3.string().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
|
|
30897
|
+
ipv4: external_exports3.string().ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
|
|
30795
30898
|
}),
|
|
30796
30899
|
handler: async (input) => {
|
|
30797
30900
|
return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
|
|
@@ -30817,6 +30920,48 @@ var deviceTools = [
|
|
|
30817
30920
|
});
|
|
30818
30921
|
}
|
|
30819
30922
|
},
|
|
30923
|
+
{
|
|
30924
|
+
name: "tailscale_set_devices_authorized",
|
|
30925
|
+
description: "Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.",
|
|
30926
|
+
annotations: {
|
|
30927
|
+
title: "Set devices authorized (bulk)",
|
|
30928
|
+
readOnlyHint: false,
|
|
30929
|
+
// Deauthorize is destructive; authorize is not. Mark destructive so MCP
|
|
30930
|
+
// clients gate the bulk call the safer way.
|
|
30931
|
+
destructiveHint: true,
|
|
30932
|
+
idempotentHint: true,
|
|
30933
|
+
openWorldHint: true
|
|
30934
|
+
},
|
|
30935
|
+
inputSchema: external_exports3.object({
|
|
30936
|
+
deviceIds: external_exports3.array(external_exports3.string().min(1)).min(1).describe("Device IDs to update"),
|
|
30937
|
+
authorized: external_exports3.boolean().describe("true to authorize, false to deauthorize")
|
|
30938
|
+
}),
|
|
30939
|
+
handler: async (input) => {
|
|
30940
|
+
const unique = [...new Set(input.deviceIds)];
|
|
30941
|
+
const results = await Promise.all(
|
|
30942
|
+
unique.map(async (deviceId) => {
|
|
30943
|
+
const res = await apiPost(`/device/${encPath(deviceId)}/authorized`, { authorized: input.authorized });
|
|
30944
|
+
return { deviceId, res };
|
|
30945
|
+
})
|
|
30946
|
+
);
|
|
30947
|
+
const succeeded = [];
|
|
30948
|
+
const failed = {};
|
|
30949
|
+
for (const { deviceId, res } of results) {
|
|
30950
|
+
if (res.ok) succeeded.push(deviceId);
|
|
30951
|
+
else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
|
|
30952
|
+
}
|
|
30953
|
+
const failedCount = Object.keys(failed).length;
|
|
30954
|
+
if (failedCount === unique.length) {
|
|
30955
|
+
const first = Object.values(failed)[0];
|
|
30956
|
+
return {
|
|
30957
|
+
ok: false,
|
|
30958
|
+
status: first.status,
|
|
30959
|
+
error: `All ${failedCount} device updates failed: ${JSON.stringify(failed)}`
|
|
30960
|
+
};
|
|
30961
|
+
}
|
|
30962
|
+
return { ok: true, status: 200, data: { authorized: input.authorized, succeeded, failed } };
|
|
30963
|
+
}
|
|
30964
|
+
},
|
|
30820
30965
|
{
|
|
30821
30966
|
name: "tailscale_batch_update_posture_attributes",
|
|
30822
30967
|
description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics \u2014 pass null as the attribute config to delete.",
|
|
@@ -31094,7 +31239,7 @@ var inviteTools = [
|
|
|
31094
31239
|
deviceId: external_exports3.string().describe("The device ID to create an invite for"),
|
|
31095
31240
|
multiUse: external_exports3.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
31096
31241
|
allowExitNode: external_exports3.boolean().optional().describe("Whether the invited device can be used as an exit node (default: false)"),
|
|
31097
|
-
email: external_exports3.string().optional().describe("Email address to send the invite to")
|
|
31242
|
+
email: external_exports3.string().email().optional().describe("Email address to send the invite to")
|
|
31098
31243
|
}),
|
|
31099
31244
|
handler: async (input) => {
|
|
31100
31245
|
const body = {};
|
|
@@ -31182,7 +31327,7 @@ var inviteTools = [
|
|
|
31182
31327
|
openWorldHint: true
|
|
31183
31328
|
},
|
|
31184
31329
|
inputSchema: external_exports3.object({
|
|
31185
|
-
email: external_exports3.string().optional().describe("Email address to send the invite to"),
|
|
31330
|
+
email: external_exports3.string().email().optional().describe("Email address to send the invite to"),
|
|
31186
31331
|
role: external_exports3.enum(["member", "admin", "it-admin", "network-admin", "billing-admin", "auditor"]).optional().describe("Role to assign to the invited user (default: member)")
|
|
31187
31332
|
}),
|
|
31188
31333
|
handler: async (input) => {
|
|
@@ -31233,7 +31378,8 @@ var inviteTools = [
|
|
|
31233
31378
|
title: "Resend device invite",
|
|
31234
31379
|
readOnlyHint: false,
|
|
31235
31380
|
destructiveHint: false,
|
|
31236
|
-
|
|
31381
|
+
// Each call delivers a separate email to the recipient.
|
|
31382
|
+
idempotentHint: false,
|
|
31237
31383
|
openWorldHint: true
|
|
31238
31384
|
},
|
|
31239
31385
|
inputSchema: external_exports3.object({
|
|
@@ -31250,7 +31396,8 @@ var inviteTools = [
|
|
|
31250
31396
|
title: "Resend user invite",
|
|
31251
31397
|
readOnlyHint: false,
|
|
31252
31398
|
destructiveHint: false,
|
|
31253
|
-
|
|
31399
|
+
// Each call delivers a separate email to the recipient.
|
|
31400
|
+
idempotentHint: false,
|
|
31254
31401
|
openWorldHint: true
|
|
31255
31402
|
},
|
|
31256
31403
|
inputSchema: external_exports3.object({
|
|
@@ -31343,7 +31490,10 @@ var keyTools = [
|
|
|
31343
31490
|
}
|
|
31344
31491
|
const body = {};
|
|
31345
31492
|
if (keyType !== "auth") body.keyType = keyType;
|
|
31346
|
-
if (input.description !== void 0)
|
|
31493
|
+
if (input.description !== void 0) {
|
|
31494
|
+
const sanitized = sanitizeDescription(input.description);
|
|
31495
|
+
if (sanitized.length > 0) body.description = sanitized;
|
|
31496
|
+
}
|
|
31347
31497
|
if (keyType === "auth") {
|
|
31348
31498
|
body.capabilities = {
|
|
31349
31499
|
devices: {
|
|
@@ -31414,7 +31564,10 @@ var keyTools = [
|
|
|
31414
31564
|
handler: async (input) => {
|
|
31415
31565
|
validateTags(input.tags);
|
|
31416
31566
|
const body = {};
|
|
31417
|
-
if (input.description !== void 0)
|
|
31567
|
+
if (input.description !== void 0) {
|
|
31568
|
+
const sanitized = sanitizeDescription(input.description);
|
|
31569
|
+
if (sanitized.length > 0) body.description = sanitized;
|
|
31570
|
+
}
|
|
31418
31571
|
if (input.scopes !== void 0) body.scopes = input.scopes;
|
|
31419
31572
|
if (input.tags !== void 0) body.tags = input.tags;
|
|
31420
31573
|
if (input.issuer !== void 0) body.issuer = input.issuer;
|
|
@@ -31498,7 +31651,7 @@ var logStreamingTools = [
|
|
|
31498
31651
|
url: external_exports3.string().optional().describe("Destination URL (required for non-s3 destinations)"),
|
|
31499
31652
|
token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
|
|
31500
31653
|
user: external_exports3.string().optional().describe("Username for the destination (if required)"),
|
|
31501
|
-
uploadPeriodMinutes: external_exports3.number().optional().describe("Minutes to wait between uploads (
|
|
31654
|
+
uploadPeriodMinutes: external_exports3.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
|
|
31502
31655
|
compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
|
|
31503
31656
|
s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
|
|
31504
31657
|
s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
|
|
@@ -31513,6 +31666,30 @@ var logStreamingTools = [
|
|
|
31513
31666
|
)
|
|
31514
31667
|
}),
|
|
31515
31668
|
handler: async (input) => {
|
|
31669
|
+
if (input.destinationType === "s3") {
|
|
31670
|
+
const missing = [];
|
|
31671
|
+
if (!input.s3Bucket) missing.push("s3Bucket");
|
|
31672
|
+
if (!input.s3Region) missing.push("s3Region");
|
|
31673
|
+
if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
|
|
31674
|
+
if (input.s3AuthenticationType === "accesskey") {
|
|
31675
|
+
if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
|
|
31676
|
+
if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
|
|
31677
|
+
} else if (input.s3AuthenticationType === "rolearn") {
|
|
31678
|
+
if (!input.s3RoleArn) missing.push("s3RoleArn");
|
|
31679
|
+
}
|
|
31680
|
+
if (missing.length > 0) {
|
|
31681
|
+
throw new Error(
|
|
31682
|
+
`destinationType 's3' requires: ${missing.join(", ")}. For 'rolearn' auth, call tailscale_create_aws_external_id first to get the external ID for your IAM role trust policy.`
|
|
31683
|
+
);
|
|
31684
|
+
}
|
|
31685
|
+
} else {
|
|
31686
|
+
const missing = [];
|
|
31687
|
+
if (!input.url) missing.push("url");
|
|
31688
|
+
if (!input.token) missing.push("token");
|
|
31689
|
+
if (missing.length > 0) {
|
|
31690
|
+
throw new Error(`destinationType '${input.destinationType}' requires: ${missing.join(", ")}.`);
|
|
31691
|
+
}
|
|
31692
|
+
}
|
|
31516
31693
|
const { logType, ...body } = input;
|
|
31517
31694
|
const cleanBody = {};
|
|
31518
31695
|
for (const [key, value] of Object.entries(body)) {
|
|
@@ -31711,7 +31888,7 @@ var postureTools = [
|
|
|
31711
31888
|
var serviceTools = [
|
|
31712
31889
|
{
|
|
31713
31890
|
name: "tailscale_list_services",
|
|
31714
|
-
description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices.",
|
|
31891
|
+
description: "List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note: services are created implicitly when a node first advertises one (`tailscale up --advertise-services=svc:name`); there is no API endpoint to create a service from this MCP. Use the update/delete/approval tools here once the service exists.",
|
|
31715
31892
|
annotations: {
|
|
31716
31893
|
title: "List services",
|
|
31717
31894
|
readOnlyHint: true,
|
|
@@ -31966,17 +32143,24 @@ var tailnetTools = [
|
|
|
31966
32143
|
openWorldHint: true
|
|
31967
32144
|
},
|
|
31968
32145
|
inputSchema: external_exports3.object({
|
|
31969
|
-
account: external_exports3.object({ email: external_exports3.string() }).optional().describe("Account contact email"),
|
|
31970
|
-
support: external_exports3.object({ email: external_exports3.string() }).optional().describe("Support contact email"),
|
|
31971
|
-
security: external_exports3.object({ email: external_exports3.string() }).optional().describe("Security contact email")
|
|
32146
|
+
account: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Account contact email"),
|
|
32147
|
+
support: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Support contact email"),
|
|
32148
|
+
security: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Security contact email")
|
|
31972
32149
|
}),
|
|
31973
32150
|
handler: async (input) => {
|
|
32151
|
+
const types = ["account", "support", "security"].filter((t) => input[t] !== void 0);
|
|
32152
|
+
const results = await Promise.all(
|
|
32153
|
+
types.map(async (contactType) => {
|
|
32154
|
+
const res = await apiPatch(
|
|
32155
|
+
`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`,
|
|
32156
|
+
input[contactType]
|
|
32157
|
+
);
|
|
32158
|
+
return { contactType, res };
|
|
32159
|
+
})
|
|
32160
|
+
);
|
|
31974
32161
|
const applied = {};
|
|
31975
32162
|
const failed = {};
|
|
31976
|
-
for (const contactType
|
|
31977
|
-
const value = input[contactType];
|
|
31978
|
-
if (value === void 0) continue;
|
|
31979
|
-
const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
|
|
32163
|
+
for (const { contactType, res } of results) {
|
|
31980
32164
|
if (res.ok) applied[contactType] = res.data;
|
|
31981
32165
|
else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
|
|
31982
32166
|
}
|
|
@@ -31999,7 +32183,8 @@ var tailnetTools = [
|
|
|
31999
32183
|
title: "Resend contact verification",
|
|
32000
32184
|
readOnlyHint: false,
|
|
32001
32185
|
destructiveHint: false,
|
|
32002
|
-
|
|
32186
|
+
// Each call sends a separate verification email.
|
|
32187
|
+
idempotentHint: false,
|
|
32003
32188
|
openWorldHint: true
|
|
32004
32189
|
},
|
|
32005
32190
|
inputSchema: external_exports3.object({
|
|
@@ -32205,7 +32390,7 @@ var webhookTools = [
|
|
|
32205
32390
|
openWorldHint: true
|
|
32206
32391
|
},
|
|
32207
32392
|
inputSchema: external_exports3.object({
|
|
32208
|
-
endpointUrl: external_exports3.string().describe("The URL to send webhook events to"),
|
|
32393
|
+
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
|
|
32209
32394
|
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
|
|
32210
32395
|
}),
|
|
32211
32396
|
handler: async (input) => {
|
|
@@ -32227,7 +32412,7 @@ var webhookTools = [
|
|
|
32227
32412
|
},
|
|
32228
32413
|
inputSchema: external_exports3.object({
|
|
32229
32414
|
webhookId: external_exports3.string().describe("The webhook ID to update"),
|
|
32230
|
-
endpointUrl: external_exports3.string().optional().describe("New URL to send webhook events to"),
|
|
32415
|
+
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
|
|
32231
32416
|
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
|
|
32232
32417
|
}),
|
|
32233
32418
|
handler: async (input) => {
|
|
@@ -32281,7 +32466,9 @@ var webhookTools = [
|
|
|
32281
32466
|
title: "Test webhook",
|
|
32282
32467
|
readOnlyHint: false,
|
|
32283
32468
|
destructiveHint: false,
|
|
32284
|
-
|
|
32469
|
+
// Each invocation delivers a separate test event to the endpoint —
|
|
32470
|
+
// not idempotent in the strict sense.
|
|
32471
|
+
idempotentHint: false,
|
|
32285
32472
|
openWorldHint: true
|
|
32286
32473
|
},
|
|
32287
32474
|
inputSchema: external_exports3.object({
|
|
@@ -32294,7 +32481,7 @@ var webhookTools = [
|
|
|
32294
32481
|
];
|
|
32295
32482
|
|
|
32296
32483
|
// src/index.ts
|
|
32297
|
-
var version2 = true ? "0.
|
|
32484
|
+
var version2 = true ? "0.10.1" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
32298
32485
|
var subcommand = process.argv[2];
|
|
32299
32486
|
if (subcommand === "deploy-acl") {
|
|
32300
32487
|
const filePath = process.argv[3];
|
|
@@ -32468,9 +32655,10 @@ var filterSuffix = [
|
|
|
32468
32655
|
console.error(
|
|
32469
32656
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
32470
32657
|
);
|
|
32471
|
-
|
|
32658
|
+
var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
32659
|
+
if (!filterSuffix && hasCreds) {
|
|
32472
32660
|
console.error(
|
|
32473
|
-
"@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (
|
|
32661
|
+
"@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (47 tools) or =minimal (20) to load a smaller tool surface. See README."
|
|
32474
32662
|
);
|
|
32475
32663
|
}
|
|
32476
32664
|
//# sourceMappingURL=index.js.map
|