@yawlabs/tailscale-mcp 0.9.1 → 0.10.2
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 +25 -7
- package/dist/index.js +292 -73
- package/package.json +3 -2
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,23 @@ 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
|
+
**`TAILSCALE_REQUEST_BUDGET_MS=N`** — total wall-clock budget per request, including 429 retries and their sleeps. Default `90000` (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the 429 immediately instead of holding the line. Tune lower if your MCP client has a tighter outer timeout. 429s on non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
|
|
199
|
+
|
|
200
|
+
**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).
|
|
201
|
+
|
|
185
202
|
## Resources (4)
|
|
186
203
|
|
|
187
204
|
MCP Resources expose read-only data clients can browse without a tool call.
|
|
@@ -205,7 +222,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
205
222
|
</details>
|
|
206
223
|
|
|
207
224
|
<details>
|
|
208
|
-
<summary><strong>Devices</strong> (
|
|
225
|
+
<summary><strong>Devices</strong> (17 tools)</summary>
|
|
209
226
|
|
|
210
227
|
| Tool | Description |
|
|
211
228
|
|------|-------------|
|
|
@@ -213,6 +230,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
213
230
|
| `tailscale_get_device` | Get detailed info for a specific device |
|
|
214
231
|
| `tailscale_authorize_device` | Authorize a pending device |
|
|
215
232
|
| `tailscale_deauthorize_device` | Deauthorize a device |
|
|
233
|
+
| `tailscale_set_devices_authorized` | Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
|
|
216
234
|
| `tailscale_delete_device` | Remove a device from the tailnet |
|
|
217
235
|
| `tailscale_rename_device` | Rename a device |
|
|
218
236
|
| `tailscale_expire_device` | Expire a device's key, forcing re-authentication |
|
package/dist/index.js
CHANGED
|
@@ -30116,20 +30116,31 @@ 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;
|
|
30122
|
+
var MAX_REQUEST_BUDGET_MS = 9e4;
|
|
30123
|
+
var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
|
|
30119
30124
|
var oauthToken = null;
|
|
30120
30125
|
var oauthRefreshPromise = null;
|
|
30121
30126
|
function getAuthConfig() {
|
|
30122
30127
|
const apiKey = process.env.TAILSCALE_API_KEY;
|
|
30123
30128
|
const oauthClientId = process.env.TAILSCALE_OAUTH_CLIENT_ID;
|
|
30124
30129
|
const oauthClientSecret = process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
30125
|
-
if (apiKey) {
|
|
30126
|
-
|
|
30130
|
+
if (apiKey !== void 0) {
|
|
30131
|
+
const trimmedKey = apiKey.trim();
|
|
30132
|
+
if (trimmedKey === "") {
|
|
30127
30133
|
throw new Error("TAILSCALE_API_KEY is set but empty. Provide a valid API key.");
|
|
30128
30134
|
}
|
|
30129
|
-
return { kind: "apiKey", apiKey };
|
|
30135
|
+
return { kind: "apiKey", apiKey: trimmedKey };
|
|
30130
30136
|
}
|
|
30131
|
-
if (oauthClientId
|
|
30132
|
-
|
|
30137
|
+
if (oauthClientId !== void 0 || oauthClientSecret !== void 0) {
|
|
30138
|
+
const trimmedId = (oauthClientId ?? "").trim();
|
|
30139
|
+
const trimmedSecret = (oauthClientSecret ?? "").trim();
|
|
30140
|
+
if (trimmedId === "" || trimmedSecret === "") {
|
|
30141
|
+
throw new Error("TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET must both be set and non-empty.");
|
|
30142
|
+
}
|
|
30143
|
+
return { kind: "oauth", clientId: trimmedId, clientSecret: trimmedSecret };
|
|
30133
30144
|
}
|
|
30134
30145
|
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.' : "";
|
|
30135
30146
|
throw new Error(
|
|
@@ -30157,7 +30168,8 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
30157
30168
|
});
|
|
30158
30169
|
if (!res.ok) {
|
|
30159
30170
|
const body = await res.text();
|
|
30160
|
-
|
|
30171
|
+
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)." : "";
|
|
30172
|
+
throw new Error(`OAuth token exchange failed (${res.status}): ${body}.${guidance}`);
|
|
30161
30173
|
}
|
|
30162
30174
|
const data = await res.json();
|
|
30163
30175
|
oauthToken = {
|
|
@@ -30218,6 +30230,77 @@ function formatAuthError(apiBody) {
|
|
|
30218
30230
|
}
|
|
30219
30231
|
return lines.join("\n");
|
|
30220
30232
|
}
|
|
30233
|
+
function extractErrorMessage(body) {
|
|
30234
|
+
if (!body) return body;
|
|
30235
|
+
const trimmed = body.trim();
|
|
30236
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return body;
|
|
30237
|
+
try {
|
|
30238
|
+
const parsed = JSON.parse(trimmed);
|
|
30239
|
+
if (parsed && typeof parsed === "object") {
|
|
30240
|
+
const obj = parsed;
|
|
30241
|
+
if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
|
|
30242
|
+
if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
|
|
30243
|
+
}
|
|
30244
|
+
} catch {
|
|
30245
|
+
}
|
|
30246
|
+
return body;
|
|
30247
|
+
}
|
|
30248
|
+
var inFlight = 0;
|
|
30249
|
+
var concurrencyQueue = [];
|
|
30250
|
+
function getConcurrencyLimit() {
|
|
30251
|
+
const raw = process.env.TAILSCALE_MAX_CONCURRENT;
|
|
30252
|
+
if (!raw) return 0;
|
|
30253
|
+
const n = Number.parseInt(raw, 10);
|
|
30254
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
30255
|
+
}
|
|
30256
|
+
function getRequestBudgetMs() {
|
|
30257
|
+
const raw = process.env.TAILSCALE_REQUEST_BUDGET_MS;
|
|
30258
|
+
if (!raw) return MAX_REQUEST_BUDGET_MS;
|
|
30259
|
+
const n = Number.parseInt(raw, 10);
|
|
30260
|
+
return Number.isFinite(n) && n > 0 ? n : MAX_REQUEST_BUDGET_MS;
|
|
30261
|
+
}
|
|
30262
|
+
async function withConcurrencyLimit(fn) {
|
|
30263
|
+
const limit = getConcurrencyLimit();
|
|
30264
|
+
if (limit === 0) return fn();
|
|
30265
|
+
if (inFlight >= limit) {
|
|
30266
|
+
await new Promise((resolve) => concurrencyQueue.push(resolve));
|
|
30267
|
+
}
|
|
30268
|
+
inFlight++;
|
|
30269
|
+
try {
|
|
30270
|
+
return await fn();
|
|
30271
|
+
} finally {
|
|
30272
|
+
inFlight--;
|
|
30273
|
+
const next = concurrencyQueue.shift();
|
|
30274
|
+
if (next) next();
|
|
30275
|
+
}
|
|
30276
|
+
}
|
|
30277
|
+
function debugLog(...parts) {
|
|
30278
|
+
if (process.env.TAILSCALE_DEBUG === "1" || process.env.TAILSCALE_DEBUG === "true") {
|
|
30279
|
+
console.error("[tailscale-mcp]", ...parts);
|
|
30280
|
+
}
|
|
30281
|
+
}
|
|
30282
|
+
function compute429DelayMs(retryAfter, attempt) {
|
|
30283
|
+
if (retryAfter) {
|
|
30284
|
+
const asInt = Number.parseInt(retryAfter, 10);
|
|
30285
|
+
if (Number.isFinite(asInt) && asInt >= 0) {
|
|
30286
|
+
return Math.min(asInt * 1e3, MAX_429_DELAY_MS);
|
|
30287
|
+
}
|
|
30288
|
+
const asDate = Date.parse(retryAfter);
|
|
30289
|
+
if (Number.isFinite(asDate)) {
|
|
30290
|
+
return Math.max(0, Math.min(asDate - Date.now(), MAX_429_DELAY_MS));
|
|
30291
|
+
}
|
|
30292
|
+
}
|
|
30293
|
+
const base = Math.min(DEFAULT_429_DELAY_MS * 2 ** attempt, MAX_429_DELAY_MS);
|
|
30294
|
+
return base + Math.floor(Math.random() * 250);
|
|
30295
|
+
}
|
|
30296
|
+
async function executeFetch(method, url2, headers, body) {
|
|
30297
|
+
return fetch(url2, {
|
|
30298
|
+
method,
|
|
30299
|
+
headers,
|
|
30300
|
+
body,
|
|
30301
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
30302
|
+
});
|
|
30303
|
+
}
|
|
30221
30304
|
async function apiRequest(method, path, body, options) {
|
|
30222
30305
|
const auth = await getAuthHeader();
|
|
30223
30306
|
const headers = {
|
|
@@ -30238,31 +30321,48 @@ async function apiRequest(method, path, body, options) {
|
|
|
30238
30321
|
fetchBody = JSON.stringify(body);
|
|
30239
30322
|
}
|
|
30240
30323
|
const url2 = path.startsWith("http") ? path : `${BASE_URL}${path}`;
|
|
30241
|
-
const
|
|
30242
|
-
|
|
30243
|
-
|
|
30244
|
-
|
|
30245
|
-
|
|
30246
|
-
|
|
30247
|
-
|
|
30248
|
-
|
|
30249
|
-
|
|
30250
|
-
|
|
30251
|
-
const
|
|
30252
|
-
|
|
30324
|
+
const startedAt = Date.now();
|
|
30325
|
+
debugLog(`${method} ${url2}`);
|
|
30326
|
+
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
30327
|
+
const requestBudgetMs = getRequestBudgetMs();
|
|
30328
|
+
return withConcurrencyLimit(async () => {
|
|
30329
|
+
let res;
|
|
30330
|
+
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
30331
|
+
res = await executeFetch(method, url2, headers, fetchBody);
|
|
30332
|
+
if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
|
|
30333
|
+
const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
|
|
30334
|
+
const elapsed2 = Date.now() - startedAt;
|
|
30335
|
+
if (elapsed2 + delay + REQUEST_TIMEOUT_MS > requestBudgetMs) {
|
|
30336
|
+
debugLog(` -> 429 (attempt ${attempt + 1}), giving up: budget exhausted (${elapsed2}ms + ${delay}ms)`);
|
|
30337
|
+
break;
|
|
30338
|
+
}
|
|
30339
|
+
debugLog(` -> 429 (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}), retrying in ${delay}ms`);
|
|
30340
|
+
await res.text().catch(() => void 0);
|
|
30341
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
30253
30342
|
}
|
|
30254
|
-
|
|
30255
|
-
|
|
30256
|
-
|
|
30257
|
-
|
|
30258
|
-
|
|
30259
|
-
|
|
30260
|
-
|
|
30261
|
-
|
|
30262
|
-
|
|
30263
|
-
|
|
30264
|
-
|
|
30265
|
-
|
|
30343
|
+
const response = res;
|
|
30344
|
+
const etag = response.headers.get("etag") || void 0;
|
|
30345
|
+
const elapsed = Date.now() - startedAt;
|
|
30346
|
+
debugLog(` <- ${response.status} (${elapsed}ms)`);
|
|
30347
|
+
if (options?.acceptRaw) {
|
|
30348
|
+
const rawBody = await response.text();
|
|
30349
|
+
if (!response.ok) {
|
|
30350
|
+
const error48 = response.status === 401 ? formatAuthError(rawBody) : extractErrorMessage(rawBody);
|
|
30351
|
+
return { ok: false, status: response.status, error: error48, rawBody, etag };
|
|
30352
|
+
}
|
|
30353
|
+
return { ok: true, status: response.status, rawBody, etag };
|
|
30354
|
+
}
|
|
30355
|
+
if (!response.ok) {
|
|
30356
|
+
const errorBody = await response.text();
|
|
30357
|
+
const error48 = response.status === 401 ? formatAuthError(errorBody) : extractErrorMessage(errorBody);
|
|
30358
|
+
return { ok: false, status: response.status, error: error48, etag };
|
|
30359
|
+
}
|
|
30360
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
30361
|
+
return { ok: true, status: response.status, etag };
|
|
30362
|
+
}
|
|
30363
|
+
const data = await response.json();
|
|
30364
|
+
return { ok: true, status: response.status, data, etag };
|
|
30365
|
+
});
|
|
30266
30366
|
}
|
|
30267
30367
|
async function apiGet(path, options) {
|
|
30268
30368
|
return apiRequest("GET", path, void 0, options);
|
|
@@ -30270,14 +30370,14 @@ async function apiGet(path, options) {
|
|
|
30270
30370
|
async function apiPost(path, body, options) {
|
|
30271
30371
|
return apiRequest("POST", path, body, options);
|
|
30272
30372
|
}
|
|
30273
|
-
async function apiPut(path, body) {
|
|
30274
|
-
return apiRequest("PUT", path, body);
|
|
30373
|
+
async function apiPut(path, body, options) {
|
|
30374
|
+
return apiRequest("PUT", path, body, options);
|
|
30275
30375
|
}
|
|
30276
|
-
async function apiPatch(path, body) {
|
|
30277
|
-
return apiRequest("PATCH", path, body);
|
|
30376
|
+
async function apiPatch(path, body, options) {
|
|
30377
|
+
return apiRequest("PATCH", path, body, options);
|
|
30278
30378
|
}
|
|
30279
|
-
async function apiDelete(path) {
|
|
30280
|
-
return apiRequest("DELETE", path);
|
|
30379
|
+
async function apiDelete(path, options) {
|
|
30380
|
+
return apiRequest("DELETE", path, void 0, options);
|
|
30281
30381
|
}
|
|
30282
30382
|
|
|
30283
30383
|
// src/cli.ts
|
|
@@ -30332,7 +30432,7 @@ function filterTools(groups, options) {
|
|
|
30332
30432
|
let unknownProfile2;
|
|
30333
30433
|
if (options.profile) {
|
|
30334
30434
|
const profileKey = options.profile.trim().toLowerCase();
|
|
30335
|
-
if (profileKey
|
|
30435
|
+
if (Object.hasOwn(PROFILES, profileKey)) {
|
|
30336
30436
|
const preset = PROFILES[profileKey];
|
|
30337
30437
|
profileGroups = preset.length > 0 ? [...preset] : void 0;
|
|
30338
30438
|
} else {
|
|
@@ -30378,14 +30478,14 @@ var aclTools = [
|
|
|
30378
30478
|
accept: "application/hujson"
|
|
30379
30479
|
});
|
|
30380
30480
|
if (res.ok && res.etag) {
|
|
30381
|
-
|
|
30382
|
-
|
|
30383
|
-
|
|
30384
|
-
|
|
30385
|
-
|
|
30386
|
-
|
|
30387
|
-
|
|
30388
|
-
};
|
|
30481
|
+
const footer = [
|
|
30482
|
+
"",
|
|
30483
|
+
`// ETag: ${res.etag}`,
|
|
30484
|
+
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
30485
|
+
"// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
|
|
30486
|
+
""
|
|
30487
|
+
].join("\n");
|
|
30488
|
+
return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
|
|
30389
30489
|
}
|
|
30390
30490
|
return res;
|
|
30391
30491
|
}
|
|
@@ -30472,8 +30572,28 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
|
|
|
30472
30572
|
// src/tools/audit.ts
|
|
30473
30573
|
function assertRFC3339(value, label) {
|
|
30474
30574
|
const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
30575
|
+
const err = () => new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
|
|
30475
30576
|
if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
|
|
30476
|
-
throw
|
|
30577
|
+
throw err();
|
|
30578
|
+
}
|
|
30579
|
+
const dateOnly = value.slice(0, 10);
|
|
30580
|
+
const [y, m, d] = dateOnly.split("-").map(Number);
|
|
30581
|
+
const utc = /* @__PURE__ */ new Date(`${dateOnly}T00:00:00Z`);
|
|
30582
|
+
if (Number.isNaN(utc.getTime()) || utc.getUTCFullYear() !== y || utc.getUTCMonth() + 1 !== m || utc.getUTCDate() !== d) {
|
|
30583
|
+
throw err();
|
|
30584
|
+
}
|
|
30585
|
+
}
|
|
30586
|
+
var MAX_LOG_RANGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
30587
|
+
function assertLogRange(start, end, label) {
|
|
30588
|
+
const startMs = Date.parse(start);
|
|
30589
|
+
const endMs = end ? Date.parse(end) : Date.now();
|
|
30590
|
+
if (endMs < startMs) {
|
|
30591
|
+
throw new Error(`${label}: end must be >= start. start=${start} end=${end ?? "<now>"}`);
|
|
30592
|
+
}
|
|
30593
|
+
if (endMs - startMs > MAX_LOG_RANGE_MS) {
|
|
30594
|
+
throw new Error(
|
|
30595
|
+
`${label}: range exceeds the 30-day Tailscale API limit. start=${start} end=${end ?? "<now>"}. Split the query into <=30-day windows.`
|
|
30596
|
+
);
|
|
30477
30597
|
}
|
|
30478
30598
|
}
|
|
30479
30599
|
var auditTools = [
|
|
@@ -30494,6 +30614,7 @@ var auditTools = [
|
|
|
30494
30614
|
handler: async (input) => {
|
|
30495
30615
|
assertRFC3339(input.start, "start");
|
|
30496
30616
|
if (input.end) assertRFC3339(input.end, "end");
|
|
30617
|
+
assertLogRange(input.start, input.end, "tailscale_get_audit_log");
|
|
30497
30618
|
const params = new URLSearchParams({ start: input.start });
|
|
30498
30619
|
if (input.end) params.set("end", input.end);
|
|
30499
30620
|
return apiGet(`/tailnet/${getTailnet()}/logging/configuration?${params}`);
|
|
@@ -30516,6 +30637,7 @@ var auditTools = [
|
|
|
30516
30637
|
handler: async (input) => {
|
|
30517
30638
|
assertRFC3339(input.start, "start");
|
|
30518
30639
|
if (input.end) assertRFC3339(input.end, "end");
|
|
30640
|
+
assertLogRange(input.start, input.end, "tailscale_get_network_flow_logs");
|
|
30519
30641
|
const params = new URLSearchParams({ start: input.start });
|
|
30520
30642
|
if (input.end) params.set("end", input.end);
|
|
30521
30643
|
return apiGet(`/tailnet/${getTailnet()}/logging/network?${params}`);
|
|
@@ -30687,7 +30809,16 @@ var deviceTools = [
|
|
|
30687
30809
|
},
|
|
30688
30810
|
inputSchema: external_exports3.object({
|
|
30689
30811
|
deviceId: external_exports3.string().describe("The device ID"),
|
|
30690
|
-
routes: external_exports3.array(
|
|
30812
|
+
routes: external_exports3.array(
|
|
30813
|
+
external_exports3.string().refine(
|
|
30814
|
+
// Accept v4 (10.0.0.0/24) or v6 (fd7a:115c::/48) CIDRs. Routes can be either.
|
|
30815
|
+
// Loose check: must contain a '/' followed by 1-3 digits, and the address part
|
|
30816
|
+
// must look like an IPv4 quad-dotted or an IPv6 colon-form. The Tailscale API
|
|
30817
|
+
// is the authoritative validator; this just rejects obvious typos client-side.
|
|
30818
|
+
(s) => /^([\d.]+|[\da-fA-F:]+)\/\d{1,3}$/.test(s),
|
|
30819
|
+
{ message: "must be a CIDR (e.g. '10.0.0.0/24' or 'fd7a:115c::/48')" }
|
|
30820
|
+
)
|
|
30821
|
+
).describe(
|
|
30691
30822
|
"Full list of CIDR routes to enable (e.g. ['10.0.0.0/24', '192.168.1.0/24']). Replaces existing enabled routes."
|
|
30692
30823
|
)
|
|
30693
30824
|
}),
|
|
@@ -30791,7 +30922,7 @@ var deviceTools = [
|
|
|
30791
30922
|
},
|
|
30792
30923
|
inputSchema: external_exports3.object({
|
|
30793
30924
|
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')")
|
|
30925
|
+
ipv4: external_exports3.string().ipv4().describe("The new Tailscale IPv4 address for the device (e.g. '100.64.0.1')")
|
|
30795
30926
|
}),
|
|
30796
30927
|
handler: async (input) => {
|
|
30797
30928
|
return apiPost(`/device/${encPath(input.deviceId)}/ip`, { ipv4: input.ipv4 });
|
|
@@ -30817,6 +30948,48 @@ var deviceTools = [
|
|
|
30817
30948
|
});
|
|
30818
30949
|
}
|
|
30819
30950
|
},
|
|
30951
|
+
{
|
|
30952
|
+
name: "tailscale_set_devices_authorized",
|
|
30953
|
+
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.",
|
|
30954
|
+
annotations: {
|
|
30955
|
+
title: "Set devices authorized (bulk)",
|
|
30956
|
+
readOnlyHint: false,
|
|
30957
|
+
// Deauthorize is destructive; authorize is not. Mark destructive so MCP
|
|
30958
|
+
// clients gate the bulk call the safer way.
|
|
30959
|
+
destructiveHint: true,
|
|
30960
|
+
idempotentHint: true,
|
|
30961
|
+
openWorldHint: true
|
|
30962
|
+
},
|
|
30963
|
+
inputSchema: external_exports3.object({
|
|
30964
|
+
deviceIds: external_exports3.array(external_exports3.string().min(1)).min(1).describe("Device IDs to update"),
|
|
30965
|
+
authorized: external_exports3.boolean().describe("true to authorize, false to deauthorize")
|
|
30966
|
+
}),
|
|
30967
|
+
handler: async (input) => {
|
|
30968
|
+
const unique = [...new Set(input.deviceIds)];
|
|
30969
|
+
const results = await Promise.all(
|
|
30970
|
+
unique.map(async (deviceId) => {
|
|
30971
|
+
const res = await apiPost(`/device/${encPath(deviceId)}/authorized`, { authorized: input.authorized });
|
|
30972
|
+
return { deviceId, res };
|
|
30973
|
+
})
|
|
30974
|
+
);
|
|
30975
|
+
const succeeded = [];
|
|
30976
|
+
const failed = {};
|
|
30977
|
+
for (const { deviceId, res } of results) {
|
|
30978
|
+
if (res.ok) succeeded.push(deviceId);
|
|
30979
|
+
else failed[deviceId] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
|
|
30980
|
+
}
|
|
30981
|
+
const failedCount = Object.keys(failed).length;
|
|
30982
|
+
if (failedCount === unique.length) {
|
|
30983
|
+
const first = Object.values(failed)[0];
|
|
30984
|
+
return {
|
|
30985
|
+
ok: false,
|
|
30986
|
+
status: first.status,
|
|
30987
|
+
error: `All ${failedCount} device updates failed: ${JSON.stringify(failed)}`
|
|
30988
|
+
};
|
|
30989
|
+
}
|
|
30990
|
+
return { ok: true, status: 200, data: { authorized: input.authorized, succeeded, failed } };
|
|
30991
|
+
}
|
|
30992
|
+
},
|
|
30820
30993
|
{
|
|
30821
30994
|
name: "tailscale_batch_update_posture_attributes",
|
|
30822
30995
|
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.",
|
|
@@ -31055,6 +31228,9 @@ var dnsTools = [
|
|
|
31055
31228
|
for (const [key, value] of Object.entries(input)) {
|
|
31056
31229
|
if (value !== void 0) body[key] = value;
|
|
31057
31230
|
}
|
|
31231
|
+
if (Object.keys(body).length === 0) {
|
|
31232
|
+
throw new Error("No fields to update. Provide at least one of: dns, searchPaths, splitDns, magicDNS.");
|
|
31233
|
+
}
|
|
31058
31234
|
return apiPost(`/tailnet/${getTailnet()}/dns/configuration`, body);
|
|
31059
31235
|
}
|
|
31060
31236
|
}
|
|
@@ -31094,7 +31270,7 @@ var inviteTools = [
|
|
|
31094
31270
|
deviceId: external_exports3.string().describe("The device ID to create an invite for"),
|
|
31095
31271
|
multiUse: external_exports3.boolean().optional().describe("Whether the invite can be used more than once (default: false)"),
|
|
31096
31272
|
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")
|
|
31273
|
+
email: external_exports3.string().email().optional().describe("Email address to send the invite to")
|
|
31098
31274
|
}),
|
|
31099
31275
|
handler: async (input) => {
|
|
31100
31276
|
const body = {};
|
|
@@ -31182,7 +31358,7 @@ var inviteTools = [
|
|
|
31182
31358
|
openWorldHint: true
|
|
31183
31359
|
},
|
|
31184
31360
|
inputSchema: external_exports3.object({
|
|
31185
|
-
email: external_exports3.string().optional().describe("Email address to send the invite to"),
|
|
31361
|
+
email: external_exports3.string().email().optional().describe("Email address to send the invite to"),
|
|
31186
31362
|
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
31363
|
}),
|
|
31188
31364
|
handler: async (input) => {
|
|
@@ -31233,7 +31409,8 @@ var inviteTools = [
|
|
|
31233
31409
|
title: "Resend device invite",
|
|
31234
31410
|
readOnlyHint: false,
|
|
31235
31411
|
destructiveHint: false,
|
|
31236
|
-
|
|
31412
|
+
// Each call delivers a separate email to the recipient.
|
|
31413
|
+
idempotentHint: false,
|
|
31237
31414
|
openWorldHint: true
|
|
31238
31415
|
},
|
|
31239
31416
|
inputSchema: external_exports3.object({
|
|
@@ -31250,7 +31427,8 @@ var inviteTools = [
|
|
|
31250
31427
|
title: "Resend user invite",
|
|
31251
31428
|
readOnlyHint: false,
|
|
31252
31429
|
destructiveHint: false,
|
|
31253
|
-
|
|
31430
|
+
// Each call delivers a separate email to the recipient.
|
|
31431
|
+
idempotentHint: false,
|
|
31254
31432
|
openWorldHint: true
|
|
31255
31433
|
},
|
|
31256
31434
|
inputSchema: external_exports3.object({
|
|
@@ -31343,7 +31521,10 @@ var keyTools = [
|
|
|
31343
31521
|
}
|
|
31344
31522
|
const body = {};
|
|
31345
31523
|
if (keyType !== "auth") body.keyType = keyType;
|
|
31346
|
-
if (input.description !== void 0)
|
|
31524
|
+
if (input.description !== void 0) {
|
|
31525
|
+
const sanitized = sanitizeDescription(input.description);
|
|
31526
|
+
if (sanitized.length > 0) body.description = sanitized;
|
|
31527
|
+
}
|
|
31347
31528
|
if (keyType === "auth") {
|
|
31348
31529
|
body.capabilities = {
|
|
31349
31530
|
devices: {
|
|
@@ -31414,7 +31595,10 @@ var keyTools = [
|
|
|
31414
31595
|
handler: async (input) => {
|
|
31415
31596
|
validateTags(input.tags);
|
|
31416
31597
|
const body = {};
|
|
31417
|
-
if (input.description !== void 0)
|
|
31598
|
+
if (input.description !== void 0) {
|
|
31599
|
+
const sanitized = sanitizeDescription(input.description);
|
|
31600
|
+
if (sanitized.length > 0) body.description = sanitized;
|
|
31601
|
+
}
|
|
31418
31602
|
if (input.scopes !== void 0) body.scopes = input.scopes;
|
|
31419
31603
|
if (input.tags !== void 0) body.tags = input.tags;
|
|
31420
31604
|
if (input.issuer !== void 0) body.issuer = input.issuer;
|
|
@@ -31498,7 +31682,7 @@ var logStreamingTools = [
|
|
|
31498
31682
|
url: external_exports3.string().optional().describe("Destination URL (required for non-s3 destinations)"),
|
|
31499
31683
|
token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
|
|
31500
31684
|
user: external_exports3.string().optional().describe("Username for the destination (if required)"),
|
|
31501
|
-
uploadPeriodMinutes: external_exports3.number().optional().describe("Minutes to wait between uploads (
|
|
31685
|
+
uploadPeriodMinutes: external_exports3.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
|
|
31502
31686
|
compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
|
|
31503
31687
|
s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
|
|
31504
31688
|
s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
|
|
@@ -31513,6 +31697,30 @@ var logStreamingTools = [
|
|
|
31513
31697
|
)
|
|
31514
31698
|
}),
|
|
31515
31699
|
handler: async (input) => {
|
|
31700
|
+
if (input.destinationType === "s3") {
|
|
31701
|
+
const missing = [];
|
|
31702
|
+
if (!input.s3Bucket) missing.push("s3Bucket");
|
|
31703
|
+
if (!input.s3Region) missing.push("s3Region");
|
|
31704
|
+
if (!input.s3AuthenticationType) missing.push("s3AuthenticationType");
|
|
31705
|
+
if (input.s3AuthenticationType === "accesskey") {
|
|
31706
|
+
if (!input.s3AccessKeyId) missing.push("s3AccessKeyId");
|
|
31707
|
+
if (!input.s3SecretAccessKey) missing.push("s3SecretAccessKey");
|
|
31708
|
+
} else if (input.s3AuthenticationType === "rolearn") {
|
|
31709
|
+
if (!input.s3RoleArn) missing.push("s3RoleArn");
|
|
31710
|
+
}
|
|
31711
|
+
if (missing.length > 0) {
|
|
31712
|
+
throw new Error(
|
|
31713
|
+
`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.`
|
|
31714
|
+
);
|
|
31715
|
+
}
|
|
31716
|
+
} else {
|
|
31717
|
+
const missing = [];
|
|
31718
|
+
if (!input.url) missing.push("url");
|
|
31719
|
+
if (!input.token) missing.push("token");
|
|
31720
|
+
if (missing.length > 0) {
|
|
31721
|
+
throw new Error(`destinationType '${input.destinationType}' requires: ${missing.join(", ")}.`);
|
|
31722
|
+
}
|
|
31723
|
+
}
|
|
31516
31724
|
const { logType, ...body } = input;
|
|
31517
31725
|
const cleanBody = {};
|
|
31518
31726
|
for (const [key, value] of Object.entries(body)) {
|
|
@@ -31711,7 +31919,7 @@ var postureTools = [
|
|
|
31711
31919
|
var serviceTools = [
|
|
31712
31920
|
{
|
|
31713
31921
|
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.",
|
|
31922
|
+
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
31923
|
annotations: {
|
|
31716
31924
|
title: "List services",
|
|
31717
31925
|
readOnlyHint: true,
|
|
@@ -31957,7 +32165,7 @@ var tailnetTools = [
|
|
|
31957
32165
|
},
|
|
31958
32166
|
{
|
|
31959
32167
|
name: "tailscale_set_contacts",
|
|
31960
|
-
description: "Update tailnet contact information.",
|
|
32168
|
+
description: "Update tailnet contact information. Each provided contact type (account/support/security) is PATCHed in parallel; per-type errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the response is data: { applied, failed } -- inspect data.failed for per-type error details.",
|
|
31961
32169
|
annotations: {
|
|
31962
32170
|
title: "Set contacts",
|
|
31963
32171
|
readOnlyHint: false,
|
|
@@ -31966,17 +32174,24 @@ var tailnetTools = [
|
|
|
31966
32174
|
openWorldHint: true
|
|
31967
32175
|
},
|
|
31968
32176
|
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")
|
|
32177
|
+
account: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Account contact email"),
|
|
32178
|
+
support: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Support contact email"),
|
|
32179
|
+
security: external_exports3.object({ email: external_exports3.string().email() }).optional().describe("Security contact email")
|
|
31972
32180
|
}),
|
|
31973
32181
|
handler: async (input) => {
|
|
32182
|
+
const types = ["account", "support", "security"].filter((t) => input[t] !== void 0);
|
|
32183
|
+
const results = await Promise.all(
|
|
32184
|
+
types.map(async (contactType) => {
|
|
32185
|
+
const res = await apiPatch(
|
|
32186
|
+
`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`,
|
|
32187
|
+
input[contactType]
|
|
32188
|
+
);
|
|
32189
|
+
return { contactType, res };
|
|
32190
|
+
})
|
|
32191
|
+
);
|
|
31974
32192
|
const applied = {};
|
|
31975
32193
|
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);
|
|
32194
|
+
for (const { contactType, res } of results) {
|
|
31980
32195
|
if (res.ok) applied[contactType] = res.data;
|
|
31981
32196
|
else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
|
|
31982
32197
|
}
|
|
@@ -31999,7 +32214,8 @@ var tailnetTools = [
|
|
|
31999
32214
|
title: "Resend contact verification",
|
|
32000
32215
|
readOnlyHint: false,
|
|
32001
32216
|
destructiveHint: false,
|
|
32002
|
-
|
|
32217
|
+
// Each call sends a separate verification email.
|
|
32218
|
+
idempotentHint: false,
|
|
32003
32219
|
openWorldHint: true
|
|
32004
32220
|
},
|
|
32005
32221
|
inputSchema: external_exports3.object({
|
|
@@ -32205,8 +32421,8 @@ var webhookTools = [
|
|
|
32205
32421
|
openWorldHint: true
|
|
32206
32422
|
},
|
|
32207
32423
|
inputSchema: external_exports3.object({
|
|
32208
|
-
endpointUrl: external_exports3.string().describe("The URL to send webhook events to"),
|
|
32209
|
-
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).describe("Event types to subscribe to")
|
|
32424
|
+
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
|
|
32425
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).describe("Event types to subscribe to (at least one)")
|
|
32210
32426
|
}),
|
|
32211
32427
|
handler: async (input) => {
|
|
32212
32428
|
return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
|
|
@@ -32227,8 +32443,8 @@ var webhookTools = [
|
|
|
32227
32443
|
},
|
|
32228
32444
|
inputSchema: external_exports3.object({
|
|
32229
32445
|
webhookId: external_exports3.string().describe("The webhook ID to update"),
|
|
32230
|
-
endpointUrl: external_exports3.string().optional().describe("New URL to send webhook events to"),
|
|
32231
|
-
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).optional().describe("Updated list of event types to subscribe to")
|
|
32446
|
+
endpointUrl: external_exports3.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
|
|
32447
|
+
subscriptions: external_exports3.array(external_exports3.enum(webhookEventTypes)).min(1).optional().describe("Updated list of event types to subscribe to (at least one)")
|
|
32232
32448
|
}),
|
|
32233
32449
|
handler: async (input) => {
|
|
32234
32450
|
const body = {};
|
|
@@ -32281,7 +32497,9 @@ var webhookTools = [
|
|
|
32281
32497
|
title: "Test webhook",
|
|
32282
32498
|
readOnlyHint: false,
|
|
32283
32499
|
destructiveHint: false,
|
|
32284
|
-
|
|
32500
|
+
// Each invocation delivers a separate test event to the endpoint —
|
|
32501
|
+
// not idempotent in the strict sense.
|
|
32502
|
+
idempotentHint: false,
|
|
32285
32503
|
openWorldHint: true
|
|
32286
32504
|
},
|
|
32287
32505
|
inputSchema: external_exports3.object({
|
|
@@ -32294,7 +32512,7 @@ var webhookTools = [
|
|
|
32294
32512
|
];
|
|
32295
32513
|
|
|
32296
32514
|
// src/index.ts
|
|
32297
|
-
var version2 = true ? "0.
|
|
32515
|
+
var version2 = true ? "0.10.2" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
32298
32516
|
var subcommand = process.argv[2];
|
|
32299
32517
|
if (subcommand === "deploy-acl") {
|
|
32300
32518
|
const filePath = process.argv[3];
|
|
@@ -32468,9 +32686,10 @@ var filterSuffix = [
|
|
|
32468
32686
|
console.error(
|
|
32469
32687
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
32470
32688
|
);
|
|
32471
|
-
|
|
32689
|
+
var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
32690
|
+
if (!filterSuffix && hasCreds) {
|
|
32472
32691
|
console.error(
|
|
32473
|
-
"@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (
|
|
32692
|
+
"@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (47 tools) or =minimal (20) to load a smaller tool surface. See README."
|
|
32474
32693
|
);
|
|
32475
32694
|
}
|
|
32476
32695
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Tailscale MCP server for managing your tailnet from AI assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "YawLabs <contact@yaw.sh>",
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc && node build.mjs",
|
|
31
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
31
32
|
"dev": "tsc --watch",
|
|
32
33
|
"start": "node dist/index.js",
|
|
33
34
|
"test": "npm run build && node --test dist/**/*.test.js",
|
|
34
35
|
"test:ci": "npm run test",
|
|
35
36
|
"lint": "biome check src/",
|
|
36
37
|
"lint:fix": "biome check --write src/",
|
|
37
|
-
"prepublishOnly": "npm run build"
|
|
38
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {},
|
|
40
41
|
"overrides": {
|