@yawlabs/tailscale-mcp 0.12.9 → 0.13.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 +38 -1
- package/dist/index.js +243 -154
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -454,10 +454,47 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
454
454
|
For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent — use the built-in CLI:
|
|
455
455
|
|
|
456
456
|
```bash
|
|
457
|
+
# PR check: validate the proposed policy without touching the tailnet
|
|
458
|
+
npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
|
|
459
|
+
|
|
460
|
+
# On merge: ETag fetch + validate + deploy with If-Match, fail-closed at every step
|
|
457
461
|
npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
|
|
458
462
|
```
|
|
459
463
|
|
|
460
|
-
|
|
464
|
+
Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
|
|
465
|
+
|
|
466
|
+
A complete GitHub Actions workflow — validate on PR, deploy on merge:
|
|
467
|
+
|
|
468
|
+
```yaml
|
|
469
|
+
name: tailscale-acl
|
|
470
|
+
on:
|
|
471
|
+
pull_request:
|
|
472
|
+
paths: ["tailscale/acl.json"]
|
|
473
|
+
push:
|
|
474
|
+
branches: [main]
|
|
475
|
+
paths: ["tailscale/acl.json"]
|
|
476
|
+
|
|
477
|
+
jobs:
|
|
478
|
+
acl:
|
|
479
|
+
runs-on: ubuntu-latest
|
|
480
|
+
env:
|
|
481
|
+
TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
|
|
482
|
+
TAILSCALE_TAILNET: your-tailnet.ts.net # or omit: defaults to the key's tailnet
|
|
483
|
+
steps:
|
|
484
|
+
- uses: actions/checkout@v4
|
|
485
|
+
- uses: actions/setup-node@v4
|
|
486
|
+
with: { node-version: 22 }
|
|
487
|
+
- name: Validate ACL
|
|
488
|
+
if: github.event_name == 'pull_request'
|
|
489
|
+
run: npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
|
|
490
|
+
- name: Deploy ACL
|
|
491
|
+
if: github.event_name == 'push'
|
|
492
|
+
run: npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
For reproducible deploys, replace `@latest` with a pinned version.
|
|
496
|
+
|
|
497
|
+
> **If you hand-roll this with `curl` instead:** Tailscale's ACL endpoint only returns the `ETag` header on **GET**, not HEAD. A `curl -I` (HEAD) ETag fetch silently yields an empty value — and an empty `If-Match` either deploys unguarded (clobbering concurrent edits) or trips your guard and fails the deploy. Fetch the ETag with a GET (`curl -fsS -D - -o /dev/null ...`), and fail the job if it comes back empty. The CLI above does all of this for you.
|
|
461
498
|
|
|
462
499
|
**Optional:** Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
|
|
463
500
|
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
8
|
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
-
|
|
9
|
+
try {
|
|
10
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
throw mod = 0, e;
|
|
13
|
+
}
|
|
10
14
|
};
|
|
11
15
|
var __export = (target, all) => {
|
|
12
16
|
for (var name in all)
|
|
@@ -28506,11 +28510,11 @@ var Protocol = class {
|
|
|
28506
28510
|
*
|
|
28507
28511
|
* The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
|
|
28508
28512
|
*/
|
|
28509
|
-
async connect(
|
|
28513
|
+
async connect(transport) {
|
|
28510
28514
|
if (this._transport) {
|
|
28511
28515
|
throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.");
|
|
28512
28516
|
}
|
|
28513
|
-
this._transport =
|
|
28517
|
+
this._transport = transport;
|
|
28514
28518
|
const _onclose = this.transport?.onclose;
|
|
28515
28519
|
this._transport.onclose = () => {
|
|
28516
28520
|
_onclose?.();
|
|
@@ -30100,8 +30104,8 @@ var McpServer = class {
|
|
|
30100
30104
|
*
|
|
30101
30105
|
* The `server` object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
|
|
30102
30106
|
*/
|
|
30103
|
-
async connect(
|
|
30104
|
-
return await this.server.connect(
|
|
30107
|
+
async connect(transport) {
|
|
30108
|
+
return await this.server.connect(transport);
|
|
30105
30109
|
}
|
|
30106
30110
|
/**
|
|
30107
30111
|
* Closes the connection.
|
|
@@ -31158,6 +31162,19 @@ async function executeFetch(method, url2, headers, body, timeoutMs) {
|
|
|
31158
31162
|
signal: AbortSignal.timeout(timeoutMs)
|
|
31159
31163
|
});
|
|
31160
31164
|
}
|
|
31165
|
+
function describeTransportError(err, method, attemptTimeoutMs) {
|
|
31166
|
+
if (err instanceof Error) {
|
|
31167
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
31168
|
+
return `${method} request timed out after ${attemptTimeoutMs}ms`;
|
|
31169
|
+
}
|
|
31170
|
+
const cause = err.cause;
|
|
31171
|
+
if (cause instanceof Error && cause.message) {
|
|
31172
|
+
return `${method} request failed: ${err.message} (${cause.message})`;
|
|
31173
|
+
}
|
|
31174
|
+
return `${method} request failed: ${err.message}`;
|
|
31175
|
+
}
|
|
31176
|
+
return `${method} request failed: ${String(err)}`;
|
|
31177
|
+
}
|
|
31161
31178
|
async function apiRequest(method, path, body, options) {
|
|
31162
31179
|
const headers = {};
|
|
31163
31180
|
if (options?.accept) {
|
|
@@ -31175,6 +31192,13 @@ async function apiRequest(method, path, body, options) {
|
|
|
31175
31192
|
fetchBody = JSON.stringify(body);
|
|
31176
31193
|
}
|
|
31177
31194
|
const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
|
|
31195
|
+
if (isAbsolute && !path.startsWith("https://api.tailscale.com/")) {
|
|
31196
|
+
return {
|
|
31197
|
+
ok: false,
|
|
31198
|
+
status: 0,
|
|
31199
|
+
error: `Absolute URL ${JSON.stringify(path)} is not on api.tailscale.com -- refusing to send an authenticated request elsewhere.`
|
|
31200
|
+
};
|
|
31201
|
+
}
|
|
31178
31202
|
const url2 = isAbsolute ? path : `${BASE_URL}${path}`;
|
|
31179
31203
|
const startedAt = Date.now();
|
|
31180
31204
|
debugLog(`${method} ${url2}`);
|
|
@@ -31183,17 +31207,38 @@ async function apiRequest(method, path, body, options) {
|
|
|
31183
31207
|
return withConcurrencyLimit(async () => {
|
|
31184
31208
|
headers.Authorization = await getAuthHeader();
|
|
31185
31209
|
let res;
|
|
31210
|
+
let lastTransportError;
|
|
31186
31211
|
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
|
|
31187
31212
|
const remaining = requestBudgetMs - (Date.now() - startedAt);
|
|
31188
31213
|
if (remaining <= 0) {
|
|
31189
31214
|
return {
|
|
31190
31215
|
ok: false,
|
|
31191
31216
|
status: 0,
|
|
31192
|
-
error: `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
|
|
31217
|
+
error: lastTransportError ? `${lastTransportError}; request budget of ${requestBudgetMs}ms exhausted before next attempt could begin.` : `Request budget of ${requestBudgetMs}ms exhausted before attempt could begin.`
|
|
31193
31218
|
};
|
|
31194
31219
|
}
|
|
31195
31220
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
31196
|
-
|
|
31221
|
+
let attemptRes;
|
|
31222
|
+
try {
|
|
31223
|
+
attemptRes = await executeFetch(method, url2, headers, fetchBody, attemptTimeoutMs);
|
|
31224
|
+
} catch (err) {
|
|
31225
|
+
const desc = describeTransportError(err, method, attemptTimeoutMs);
|
|
31226
|
+
lastTransportError = desc;
|
|
31227
|
+
if (!isRetryable || attempt === MAX_429_RETRIES) {
|
|
31228
|
+
return { ok: false, status: 0, error: desc };
|
|
31229
|
+
}
|
|
31230
|
+
const delay2 = compute429DelayMs(null, attempt);
|
|
31231
|
+
const elapsed3 = Date.now() - startedAt;
|
|
31232
|
+
if (requestBudgetMs - elapsed3 - delay2 <= 0) {
|
|
31233
|
+
return { ok: false, status: 0, error: `${desc}; request budget exhausted before retry.` };
|
|
31234
|
+
}
|
|
31235
|
+
debugLog(
|
|
31236
|
+
` -> transport error (attempt ${attempt + 1}/${MAX_429_RETRIES + 1}): ${desc}, retrying in ${delay2}ms`
|
|
31237
|
+
);
|
|
31238
|
+
await new Promise((r) => setTimeout(r, delay2));
|
|
31239
|
+
continue;
|
|
31240
|
+
}
|
|
31241
|
+
res = attemptRes;
|
|
31197
31242
|
if (res.status !== 429 || attempt === MAX_429_RETRIES || !isRetryable) break;
|
|
31198
31243
|
const delay = compute429DelayMs(res.headers.get("retry-after"), attempt);
|
|
31199
31244
|
const elapsed2 = Date.now() - startedAt;
|
|
@@ -31210,24 +31255,33 @@ async function apiRequest(method, path, body, options) {
|
|
|
31210
31255
|
const etag = response.headers.get("etag") || void 0;
|
|
31211
31256
|
const elapsed = Date.now() - startedAt;
|
|
31212
31257
|
debugLog(` <- ${response.status} (${elapsed}ms)`);
|
|
31213
|
-
|
|
31214
|
-
|
|
31258
|
+
try {
|
|
31259
|
+
if (options?.acceptRaw) {
|
|
31260
|
+
const rawBody = await response.text();
|
|
31261
|
+
if (!response.ok) {
|
|
31262
|
+
const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, rawBody) : extractErrorMessage(rawBody);
|
|
31263
|
+
return { ok: false, status: response.status, error: error51, rawBody, etag };
|
|
31264
|
+
}
|
|
31265
|
+
return { ok: true, status: response.status, rawBody, etag };
|
|
31266
|
+
}
|
|
31215
31267
|
if (!response.ok) {
|
|
31216
|
-
const
|
|
31217
|
-
|
|
31268
|
+
const errorBody = await response.text();
|
|
31269
|
+
const error51 = response.status === 401 || response.status === 403 ? formatAuthError(response.status, errorBody) : extractErrorMessage(errorBody);
|
|
31270
|
+
return { ok: false, status: response.status, error: error51, etag };
|
|
31218
31271
|
}
|
|
31219
|
-
|
|
31220
|
-
|
|
31221
|
-
|
|
31222
|
-
const
|
|
31223
|
-
|
|
31224
|
-
|
|
31225
|
-
|
|
31226
|
-
|
|
31227
|
-
|
|
31272
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
31273
|
+
return { ok: true, status: response.status, etag };
|
|
31274
|
+
}
|
|
31275
|
+
const data = await response.json();
|
|
31276
|
+
return { ok: true, status: response.status, data, etag };
|
|
31277
|
+
} catch (err) {
|
|
31278
|
+
return {
|
|
31279
|
+
ok: false,
|
|
31280
|
+
status: response.status,
|
|
31281
|
+
error: `Failed to read response body from ${method} ${url2} (HTTP ${response.status}): ${err instanceof Error ? err.message : String(err)}`,
|
|
31282
|
+
etag
|
|
31283
|
+
};
|
|
31228
31284
|
}
|
|
31229
|
-
const data = await response.json();
|
|
31230
|
-
return { ok: true, status: response.status, data, etag };
|
|
31231
31285
|
});
|
|
31232
31286
|
}
|
|
31233
31287
|
async function apiGet(path, options) {
|
|
@@ -31264,19 +31318,15 @@ function parseValidationError(rawBody) {
|
|
|
31264
31318
|
if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
|
|
31265
31319
|
return void 0;
|
|
31266
31320
|
}
|
|
31267
|
-
|
|
31268
|
-
let policy;
|
|
31321
|
+
function readPolicyFile(filePath) {
|
|
31269
31322
|
try {
|
|
31270
|
-
|
|
31323
|
+
return readFileSync(filePath, "utf-8");
|
|
31271
31324
|
} catch (err) {
|
|
31272
31325
|
console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
31273
31326
|
process.exit(1);
|
|
31274
31327
|
}
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31278
|
-
process.exit(1);
|
|
31279
|
-
}
|
|
31328
|
+
}
|
|
31329
|
+
async function validatePolicy(policy) {
|
|
31280
31330
|
const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
|
|
31281
31331
|
rawBody: policy,
|
|
31282
31332
|
contentType: "application/hujson",
|
|
@@ -31292,6 +31342,20 @@ async function deployAcl(filePath) {
|
|
|
31292
31342
|
console.error(`ACL validation failed: ${validationError}`);
|
|
31293
31343
|
process.exit(1);
|
|
31294
31344
|
}
|
|
31345
|
+
}
|
|
31346
|
+
async function validateAcl(filePath) {
|
|
31347
|
+
const policy = readPolicyFile(filePath);
|
|
31348
|
+
await validatePolicy(policy);
|
|
31349
|
+
console.log("ACL policy is valid");
|
|
31350
|
+
}
|
|
31351
|
+
async function deployAcl(filePath) {
|
|
31352
|
+
const policy = readPolicyFile(filePath);
|
|
31353
|
+
const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
|
|
31354
|
+
if (!getRes.ok || !getRes.etag) {
|
|
31355
|
+
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31356
|
+
process.exit(1);
|
|
31357
|
+
}
|
|
31358
|
+
await validatePolicy(policy);
|
|
31295
31359
|
const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31296
31360
|
rawBody: policy,
|
|
31297
31361
|
contentType: "application/hujson",
|
|
@@ -31300,7 +31364,13 @@ async function deployAcl(filePath) {
|
|
|
31300
31364
|
accept: "application/hujson"
|
|
31301
31365
|
});
|
|
31302
31366
|
if (!deployRes.ok) {
|
|
31303
|
-
|
|
31367
|
+
if (deployRes.status === 412) {
|
|
31368
|
+
console.error(
|
|
31369
|
+
`ACL deploy failed: the tailnet ACL changed between the ETag fetch and the deploy (concurrent edit). Re-run to retry against the current version. (${deployRes.error})`
|
|
31370
|
+
);
|
|
31371
|
+
} else {
|
|
31372
|
+
console.error(`ACL deploy failed: ${deployRes.error}`);
|
|
31373
|
+
}
|
|
31304
31374
|
process.exit(1);
|
|
31305
31375
|
}
|
|
31306
31376
|
console.log("ACL deployed successfully");
|
|
@@ -31319,25 +31389,25 @@ function parseReadonlyFlag(value) {
|
|
|
31319
31389
|
function filterTools(groups, options) {
|
|
31320
31390
|
const validNames = new Set(Object.keys(groups));
|
|
31321
31391
|
let profileGroups;
|
|
31322
|
-
let
|
|
31323
|
-
let
|
|
31392
|
+
let unknownProfile;
|
|
31393
|
+
let profileWouldFilter = false;
|
|
31324
31394
|
if (options.profile) {
|
|
31325
31395
|
const profileKey = options.profile.trim().toLowerCase();
|
|
31326
31396
|
if (Object.hasOwn(PROFILES, profileKey)) {
|
|
31327
31397
|
const preset = PROFILES[profileKey];
|
|
31328
|
-
|
|
31329
|
-
profileGroups =
|
|
31398
|
+
profileWouldFilter = preset.length > 0;
|
|
31399
|
+
profileGroups = profileWouldFilter ? [...preset] : void 0;
|
|
31330
31400
|
} else {
|
|
31331
|
-
|
|
31401
|
+
unknownProfile = profileKey;
|
|
31332
31402
|
}
|
|
31333
31403
|
}
|
|
31334
31404
|
const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
31335
|
-
const
|
|
31336
|
-
const explicitToolsAllUnknown =
|
|
31337
|
-
const effectiveExplicitTools = explicitToolsAllUnknown ? null :
|
|
31405
|
+
const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
|
|
31406
|
+
const explicitToolsAllUnknown = explicitTools?.every((g) => !validNames.has(g)) ?? false;
|
|
31407
|
+
const effectiveExplicitTools = explicitToolsAllUnknown ? null : explicitTools;
|
|
31338
31408
|
const effectiveGroups = effectiveExplicitTools ?? profileGroups ?? null;
|
|
31339
31409
|
const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
|
|
31340
|
-
const
|
|
31410
|
+
const unknownGroups = explicitTools ? explicitTools.filter((g) => !validNames.has(g)) : enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
|
|
31341
31411
|
const readonly2 = parseReadonlyFlag(options.readonly);
|
|
31342
31412
|
const out = [];
|
|
31343
31413
|
for (const [name, tools] of Object.entries(groups)) {
|
|
@@ -31347,11 +31417,11 @@ function filterTools(groups, options) {
|
|
|
31347
31417
|
out.push(t);
|
|
31348
31418
|
}
|
|
31349
31419
|
}
|
|
31350
|
-
const result = { tools: out, unknownGroups
|
|
31351
|
-
if (
|
|
31420
|
+
const result = { tools: out, unknownGroups };
|
|
31421
|
+
if (unknownProfile) result.unknownProfile = unknownProfile;
|
|
31352
31422
|
if (profileGroups && !effectiveExplicitTools) result.profileGroups = profileGroups;
|
|
31353
31423
|
if (effectiveExplicitTools) result.explicitTools = effectiveExplicitTools;
|
|
31354
|
-
if (
|
|
31424
|
+
if (profileWouldFilter) result.profileWouldFilter = true;
|
|
31355
31425
|
if (explicitToolsAllUnknown) result.toolsAllUnknown = true;
|
|
31356
31426
|
return result;
|
|
31357
31427
|
}
|
|
@@ -31565,8 +31635,11 @@ var aclTools = [
|
|
|
31565
31635
|
acceptRaw: true,
|
|
31566
31636
|
accept: "application/hujson"
|
|
31567
31637
|
});
|
|
31568
|
-
if (res.ok
|
|
31569
|
-
|
|
31638
|
+
if (res.ok) {
|
|
31639
|
+
const trimmed = res.rawBody?.trim();
|
|
31640
|
+
if (!trimmed || trimmed === "{}") {
|
|
31641
|
+
return { ...res, rawBody: "ACL policy is valid." };
|
|
31642
|
+
}
|
|
31570
31643
|
}
|
|
31571
31644
|
return res;
|
|
31572
31645
|
}
|
|
@@ -32516,7 +32589,7 @@ var keyTools = [
|
|
|
32516
32589
|
},
|
|
32517
32590
|
{
|
|
32518
32591
|
name: "tailscale_create_key",
|
|
32519
|
-
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.\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
|
|
32592
|
+
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.\n\nSECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).\n\nExamples:\n- Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}\n- OAuth client: {keyType:'client', scopes:['devices:read','dns']}\n- Federated (GitHub Actions): {keyType:'federated', scopes:['devices:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}",
|
|
32520
32593
|
annotations: {
|
|
32521
32594
|
title: "Create key",
|
|
32522
32595
|
readOnlyHint: false,
|
|
@@ -32756,7 +32829,7 @@ var localCliTools = [
|
|
|
32756
32829
|
"Target hostname, IP, or MagicDNS name (e.g. 'my-laptop', '100.64.0.1', 'my-laptop.tail-scale.ts.net')"
|
|
32757
32830
|
),
|
|
32758
32831
|
count: external_exports.number().int().positive().max(20).optional().describe(
|
|
32759
|
-
"Number of ping attempts (default
|
|
32832
|
+
"Number of ping attempts (max 20). If omitted, no -c flag is passed and the tailscale CLI uses its built-in default (retries until a direct path is found, typically <10 attempts). Higher explicit counts give a more complete latency picture but block the tool call for longer."
|
|
32760
32833
|
)
|
|
32761
32834
|
}),
|
|
32762
32835
|
handler: async (input) => {
|
|
@@ -32866,7 +32939,9 @@ var logStreamingTools = [
|
|
|
32866
32939
|
logType: external_exports.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
|
|
32867
32940
|
destinationType: external_exports.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
|
|
32868
32941
|
url: external_exports.string().optional().describe("Destination URL (required for non-s3 destinations)"),
|
|
32869
|
-
token: external_exports.string().optional().describe(
|
|
32942
|
+
token: external_exports.string().optional().describe(
|
|
32943
|
+
"Authentication token or API key for the destination. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
32944
|
+
),
|
|
32870
32945
|
user: external_exports.string().optional().describe("Username for the destination (if required)"),
|
|
32871
32946
|
uploadPeriodMinutes: external_exports.number().int().positive().max(1440).optional().describe("Minutes to wait between uploads (1-1440). Optional."),
|
|
32872
32947
|
compressionFormat: external_exports.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
|
|
@@ -32877,7 +32952,9 @@ var logStreamingTools = [
|
|
|
32877
32952
|
"(s3 only) Authentication mode. Required when destinationType is 's3'. Tailscale recommends 'rolearn'."
|
|
32878
32953
|
),
|
|
32879
32954
|
s3AccessKeyId: external_exports.string().optional().describe("(s3 only) AWS access key id. Required when s3AuthenticationType is 'accesskey'."),
|
|
32880
|
-
s3SecretAccessKey: external_exports.string().optional().describe(
|
|
32955
|
+
s3SecretAccessKey: external_exports.string().optional().describe(
|
|
32956
|
+
"(s3 only) AWS secret access key. Required when s3AuthenticationType is 'accesskey'. SENSITIVE: see the token field's note about MCP client logging."
|
|
32957
|
+
),
|
|
32881
32958
|
s3RoleArn: external_exports.string().optional().describe(
|
|
32882
32959
|
"(s3 only) IAM role ARN that Tailscale will assume. Required when s3AuthenticationType is 'rolearn'."
|
|
32883
32960
|
)
|
|
@@ -33051,7 +33128,9 @@ var postureTools = [
|
|
|
33051
33128
|
clientId: external_exports.string().optional().describe(
|
|
33052
33129
|
"Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank)"
|
|
33053
33130
|
),
|
|
33054
|
-
clientSecret: external_exports.string().describe(
|
|
33131
|
+
clientSecret: external_exports.string().describe(
|
|
33132
|
+
"The secret (auth key, token, etc.) used to authenticate with the provider. SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
33133
|
+
),
|
|
33055
33134
|
tenantId: external_exports.string().optional().describe("Microsoft Intune directory (tenant) ID. Other providers leave blank."),
|
|
33056
33135
|
cloudId: external_exports.string().optional().describe(
|
|
33057
33136
|
"Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank."
|
|
@@ -33081,7 +33160,9 @@ var postureTools = [
|
|
|
33081
33160
|
inputSchema: external_exports.object({
|
|
33082
33161
|
integrationId: external_exports.string().describe("The posture integration ID to update"),
|
|
33083
33162
|
clientId: external_exports.string().optional().describe("Updated client ID for the provider"),
|
|
33084
|
-
clientSecret: external_exports.string().optional().describe(
|
|
33163
|
+
clientSecret: external_exports.string().optional().describe(
|
|
33164
|
+
"Updated client secret for the provider (omit to retain the existing secret). SENSITIVE: passed straight to Tailscale and not echoed back, but MCP clients may log the input value you supply."
|
|
33165
|
+
),
|
|
33085
33166
|
tenantId: external_exports.string().optional().describe("Updated tenant ID"),
|
|
33086
33167
|
cloudId: external_exports.string().optional().describe("Updated cloud identifier (e.g. 'us-1', 'global', or provider FQDN)")
|
|
33087
33168
|
}),
|
|
@@ -33606,7 +33687,7 @@ var webhookTools = [
|
|
|
33606
33687
|
},
|
|
33607
33688
|
{
|
|
33608
33689
|
name: "tailscale_create_webhook",
|
|
33609
|
-
description: "Create a new webhook.",
|
|
33690
|
+
description: "Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
|
|
33610
33691
|
annotations: {
|
|
33611
33692
|
title: "Create webhook",
|
|
33612
33693
|
readOnlyHint: false,
|
|
@@ -33669,7 +33750,7 @@ var webhookTools = [
|
|
|
33669
33750
|
},
|
|
33670
33751
|
{
|
|
33671
33752
|
name: "tailscale_rotate_webhook_secret",
|
|
33672
|
-
description: "Rotate a webhook's secret. Returns the new secret \u2014 save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.",
|
|
33753
|
+
description: "Rotate a webhook's secret. Returns the new secret \u2014 save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.\n\nSECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.",
|
|
33673
33754
|
annotations: {
|
|
33674
33755
|
title: "Rotate webhook secret",
|
|
33675
33756
|
readOnlyHint: false,
|
|
@@ -33706,125 +33787,133 @@ var webhookTools = [
|
|
|
33706
33787
|
];
|
|
33707
33788
|
|
|
33708
33789
|
// src/index.ts
|
|
33709
|
-
var version2 = true ? "0.
|
|
33790
|
+
var version2 = true ? "0.13.2" : resolveVersionFallback();
|
|
33710
33791
|
var subcommand = process.argv[2];
|
|
33711
|
-
|
|
33792
|
+
var cliSubcommandHandled = false;
|
|
33793
|
+
if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
33794
|
+
cliSubcommandHandled = true;
|
|
33712
33795
|
const filePath = process.argv[3];
|
|
33713
33796
|
if (!filePath) {
|
|
33714
|
-
console.error(
|
|
33797
|
+
console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
|
|
33715
33798
|
process.exit(1);
|
|
33716
33799
|
}
|
|
33717
|
-
|
|
33800
|
+
const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
|
|
33801
|
+
run(filePath).then(() => process.exit(0)).catch((err) => {
|
|
33718
33802
|
console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
33719
33803
|
process.exit(1);
|
|
33720
33804
|
});
|
|
33721
|
-
process.exit(0);
|
|
33722
33805
|
} else if (subcommand === "version" || subcommand === "--version") {
|
|
33723
33806
|
console.log(version2);
|
|
33724
33807
|
process.exit(0);
|
|
33725
33808
|
} else if (subcommand !== void 0) {
|
|
33726
33809
|
console.error(
|
|
33727
|
-
`@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, version. Starting the MCP server.`
|
|
33810
|
+
`@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version. Starting the MCP server.`
|
|
33728
33811
|
);
|
|
33729
33812
|
}
|
|
33730
|
-
|
|
33731
|
-
|
|
33732
|
-
|
|
33733
|
-
|
|
33734
|
-
|
|
33735
|
-
|
|
33736
|
-
|
|
33737
|
-
|
|
33738
|
-
|
|
33739
|
-
|
|
33740
|
-
|
|
33741
|
-
|
|
33742
|
-
|
|
33743
|
-
|
|
33744
|
-
|
|
33745
|
-
|
|
33746
|
-
|
|
33747
|
-
|
|
33748
|
-
|
|
33749
|
-
|
|
33750
|
-
|
|
33751
|
-
|
|
33752
|
-
|
|
33753
|
-
|
|
33754
|
-
|
|
33755
|
-
|
|
33756
|
-
|
|
33757
|
-
|
|
33758
|
-
|
|
33759
|
-
|
|
33760
|
-
|
|
33761
|
-
|
|
33762
|
-
|
|
33763
|
-
|
|
33764
|
-
|
|
33765
|
-
|
|
33813
|
+
if (!cliSubcommandHandled) {
|
|
33814
|
+
const toolGroups = {
|
|
33815
|
+
status: statusTools,
|
|
33816
|
+
devices: deviceTools,
|
|
33817
|
+
acl: aclTools,
|
|
33818
|
+
dns: dnsTools,
|
|
33819
|
+
keys: keyTools,
|
|
33820
|
+
users: userTools,
|
|
33821
|
+
tailnet: tailnetTools,
|
|
33822
|
+
webhooks: webhookTools,
|
|
33823
|
+
posture: postureTools,
|
|
33824
|
+
audit: auditTools,
|
|
33825
|
+
invites: inviteTools,
|
|
33826
|
+
services: serviceTools,
|
|
33827
|
+
"log-streaming": logStreamingTools
|
|
33828
|
+
};
|
|
33829
|
+
const localCliEnabled = isLocalCliEnabled(process.env);
|
|
33830
|
+
if (localCliEnabled) {
|
|
33831
|
+
toolGroups["local-cli"] = localCliTools;
|
|
33832
|
+
}
|
|
33833
|
+
const {
|
|
33834
|
+
tools: allTools,
|
|
33835
|
+
unknownGroups,
|
|
33836
|
+
unknownProfile,
|
|
33837
|
+
explicitTools,
|
|
33838
|
+
profileWouldFilter,
|
|
33839
|
+
toolsAllUnknown
|
|
33840
|
+
} = filterTools(toolGroups, {
|
|
33841
|
+
tools: process.env.TAILSCALE_TOOLS,
|
|
33842
|
+
readonly: process.env.TAILSCALE_READONLY,
|
|
33843
|
+
profile: process.env.TAILSCALE_PROFILE
|
|
33844
|
+
});
|
|
33845
|
+
if (unknownGroups.length > 0) {
|
|
33846
|
+
const validNames = Object.keys(toolGroups);
|
|
33847
|
+
const fallbackNote = toolsAllUnknown ? " Every requested group was unknown, so TAILSCALE_TOOLS was ignored and the default tool set was loaded instead." : "";
|
|
33848
|
+
console.error(
|
|
33849
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_TOOLS includes unknown group(s): ${unknownGroups.join(", ")}. Valid groups: ${validNames.join(", ")}.${fallbackNote}`
|
|
33850
|
+
);
|
|
33851
|
+
}
|
|
33852
|
+
if (unknownProfile) {
|
|
33853
|
+
console.error(
|
|
33854
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: minimal, core, full. Falling back to no profile filter.`
|
|
33855
|
+
);
|
|
33856
|
+
}
|
|
33857
|
+
const server = new McpServer({
|
|
33858
|
+
name: "@yawlabs/tailscale-mcp",
|
|
33859
|
+
version: version2
|
|
33860
|
+
});
|
|
33861
|
+
for (const tool of allTools) {
|
|
33862
|
+
server.tool(tool.name, tool.description, tool.inputSchema.shape, tool.annotations, wrapToolHandler(tool));
|
|
33863
|
+
}
|
|
33864
|
+
server.resource(
|
|
33865
|
+
"tailnet-status",
|
|
33866
|
+
"tailscale://tailnet/status",
|
|
33867
|
+
{ description: "Current tailnet status including device count and settings", mimeType: "application/json" },
|
|
33868
|
+
tailnetStatusResource
|
|
33766
33869
|
);
|
|
33767
|
-
|
|
33768
|
-
|
|
33769
|
-
|
|
33770
|
-
|
|
33870
|
+
server.resource(
|
|
33871
|
+
"tailnet-devices",
|
|
33872
|
+
"tailscale://tailnet/devices",
|
|
33873
|
+
{ description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
|
|
33874
|
+
tailnetDevicesResource
|
|
33771
33875
|
);
|
|
33772
|
-
|
|
33773
|
-
|
|
33774
|
-
|
|
33775
|
-
|
|
33776
|
-
|
|
33777
|
-
|
|
33778
|
-
server.
|
|
33779
|
-
|
|
33780
|
-
|
|
33781
|
-
|
|
33782
|
-
|
|
33783
|
-
|
|
33784
|
-
|
|
33785
|
-
|
|
33786
|
-
|
|
33787
|
-
|
|
33788
|
-
|
|
33789
|
-
|
|
33790
|
-
|
|
33791
|
-
);
|
|
33792
|
-
|
|
33793
|
-
|
|
33794
|
-
|
|
33795
|
-
|
|
33796
|
-
|
|
33797
|
-
|
|
33798
|
-
|
|
33799
|
-
|
|
33800
|
-
|
|
33801
|
-
|
|
33802
|
-
description: "DNS configuration including nameservers, search paths, split DNS, and MagicDNS status",
|
|
33803
|
-
mimeType: "application/json"
|
|
33804
|
-
},
|
|
33805
|
-
tailnetDnsResource
|
|
33806
|
-
);
|
|
33807
|
-
var transport = new StdioServerTransport();
|
|
33808
|
-
await server.connect(transport);
|
|
33809
|
-
var readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
33810
|
-
var filterSuffix = formatBannerFilterSuffix({
|
|
33811
|
-
unknownProfile,
|
|
33812
|
-
explicitTools,
|
|
33813
|
-
profileWouldFilter,
|
|
33814
|
-
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
33815
|
-
readonlyMode,
|
|
33816
|
-
localCliEnabled
|
|
33817
|
-
});
|
|
33818
|
-
console.error(
|
|
33819
|
-
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
33820
|
-
);
|
|
33821
|
-
var hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
33822
|
-
if (!filterSuffix && hasCreds) {
|
|
33823
|
-
const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
|
|
33824
|
-
const coreCount = profileCount(PROFILES.core);
|
|
33825
|
-
const minimalCount = profileCount(PROFILES.minimal);
|
|
33876
|
+
server.resource(
|
|
33877
|
+
"tailnet-acl",
|
|
33878
|
+
"tailscale://tailnet/acl",
|
|
33879
|
+
{ description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
|
|
33880
|
+
tailnetAclResource
|
|
33881
|
+
);
|
|
33882
|
+
server.resource(
|
|
33883
|
+
"tailnet-dns",
|
|
33884
|
+
"tailscale://tailnet/dns",
|
|
33885
|
+
{
|
|
33886
|
+
description: "DNS configuration including nameservers, search paths, split DNS, and MagicDNS status",
|
|
33887
|
+
mimeType: "application/json"
|
|
33888
|
+
},
|
|
33889
|
+
tailnetDnsResource
|
|
33890
|
+
);
|
|
33891
|
+
const transport = new StdioServerTransport();
|
|
33892
|
+
server.connect(transport).catch((err) => {
|
|
33893
|
+
process.stderr.write(`tailscale-mcp: ${err instanceof Error ? err.message : String(err)}
|
|
33894
|
+
`);
|
|
33895
|
+
process.exit(1);
|
|
33896
|
+
});
|
|
33897
|
+
const readonlyMode = parseReadonlyFlag(process.env.TAILSCALE_READONLY);
|
|
33898
|
+
const filterSuffix = formatBannerFilterSuffix({
|
|
33899
|
+
unknownProfile,
|
|
33900
|
+
explicitTools,
|
|
33901
|
+
profileWouldFilter,
|
|
33902
|
+
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
33903
|
+
readonlyMode,
|
|
33904
|
+
localCliEnabled
|
|
33905
|
+
});
|
|
33826
33906
|
console.error(
|
|
33827
|
-
`@yawlabs/tailscale-mcp
|
|
33907
|
+
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
33828
33908
|
);
|
|
33909
|
+
const hasCreds = !!process.env.TAILSCALE_API_KEY || !!process.env.TAILSCALE_OAUTH_CLIENT_ID && !!process.env.TAILSCALE_OAUTH_CLIENT_SECRET;
|
|
33910
|
+
if (!filterSuffix && hasCreds) {
|
|
33911
|
+
const profileCount = (groups) => groups.reduce((n, g) => n + (toolGroups[g]?.length ?? 0), 0);
|
|
33912
|
+
const coreCount = profileCount(PROFILES.core);
|
|
33913
|
+
const minimalCount = profileCount(PROFILES.minimal);
|
|
33914
|
+
console.error(
|
|
33915
|
+
`@yawlabs/tailscale-mcp: tip \u2014 set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
|
|
33916
|
+
);
|
|
33917
|
+
}
|
|
33829
33918
|
}
|
|
33830
33919
|
//# 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.13.2",
|
|
4
4
|
"mcpName": "io.github.YawLabs/tailscale-mcp",
|
|
5
5
|
"description": "Tailscale MCP server for managing your tailnet from AI assistants",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"lint:fix": "biome check --write src/",
|
|
39
39
|
"prepublishOnly": "npm run clean && npm run build"
|
|
40
40
|
},
|
|
41
|
-
"dependencies": {},
|
|
42
41
|
"overrides": {
|
|
43
42
|
"hono": "^4.12.21",
|
|
44
43
|
"@hono/node-server": "^1.19.13",
|
|
@@ -50,7 +49,8 @@
|
|
|
50
49
|
"@biomejs/biome": "^2.4.12",
|
|
51
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
51
|
"@types/node": "^25.6.0",
|
|
53
|
-
"esbuild": "^0.28.
|
|
52
|
+
"esbuild": "^0.28.1",
|
|
53
|
+
"postject": "^1.0.0-alpha.6",
|
|
54
54
|
"typescript": "^6.0.3",
|
|
55
55
|
"zod": "^4.3.6"
|
|
56
56
|
},
|