@yawlabs/tailscale-mcp 0.11.1 → 0.12.0
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 +3 -1
- package/dist/index.js +90 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
11
11
|
|
|
12
|
-
[](https://mcp.hosting/install?name=Tailscale&command=npx&args=-y%2C%40yawlabs%2Ftailscale-mcp&
|
|
12
|
+
[](https://mcp.hosting/install?name=Tailscale&command=npx&args=-y%2C%40yawlabs%2Ftailscale-mcp&description=Manage%20your%20Tailscale%20tailnet%20-%20devices%2C%20ACLs%2C%20DNS%2C%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Ftailscale-mcp)
|
|
13
13
|
|
|
14
14
|
One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
|
|
15
15
|
|
|
@@ -197,6 +197,8 @@ The server checks for an API key first, then falls back to OAuth. If neither is
|
|
|
197
197
|
|
|
198
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
199
|
|
|
200
|
+
**`TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB`** — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate `subscriptions` against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. Please also [open an issue](https://github.com/YawLabs/tailscale-mcp/issues) so the static list catches up.
|
|
201
|
+
|
|
200
202
|
**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
203
|
|
|
202
204
|
## Local CLI integration (opt-in)
|
package/dist/index.js
CHANGED
|
@@ -31288,18 +31288,20 @@ function filterTools(groups, options) {
|
|
|
31288
31288
|
const validNames = new Set(Object.keys(groups));
|
|
31289
31289
|
let profileGroups;
|
|
31290
31290
|
let unknownProfile2;
|
|
31291
|
+
let profileWouldFilter2 = false;
|
|
31291
31292
|
if (options.profile) {
|
|
31292
31293
|
const profileKey = options.profile.trim().toLowerCase();
|
|
31293
31294
|
if (Object.hasOwn(PROFILES, profileKey)) {
|
|
31294
31295
|
const preset = PROFILES[profileKey];
|
|
31295
|
-
|
|
31296
|
+
profileWouldFilter2 = preset.length > 0;
|
|
31297
|
+
profileGroups = profileWouldFilter2 ? [...preset] : void 0;
|
|
31296
31298
|
} else {
|
|
31297
31299
|
unknownProfile2 = profileKey;
|
|
31298
31300
|
}
|
|
31299
31301
|
}
|
|
31300
31302
|
const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
31301
|
-
const
|
|
31302
|
-
const effectiveGroups =
|
|
31303
|
+
const explicitTools2 = parsedTools && parsedTools.length > 0 ? parsedTools : null;
|
|
31304
|
+
const effectiveGroups = explicitTools2 ?? profileGroups ?? null;
|
|
31303
31305
|
const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
|
|
31304
31306
|
const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
|
|
31305
31307
|
const readonly2 = options.readonly === "1" || options.readonly === "true";
|
|
@@ -31313,7 +31315,9 @@ function filterTools(groups, options) {
|
|
|
31313
31315
|
}
|
|
31314
31316
|
const result = { tools: out, unknownGroups: unknownGroups2 };
|
|
31315
31317
|
if (unknownProfile2) result.unknownProfile = unknownProfile2;
|
|
31316
|
-
if (profileGroups && !
|
|
31318
|
+
if (profileGroups && !explicitTools2) result.profileGroups = profileGroups;
|
|
31319
|
+
if (explicitTools2) result.explicitTools = explicitTools2;
|
|
31320
|
+
if (profileWouldFilter2) result.profileWouldFilter = true;
|
|
31317
31321
|
return result;
|
|
31318
31322
|
}
|
|
31319
31323
|
|
|
@@ -31321,6 +31325,17 @@ function filterTools(groups, options) {
|
|
|
31321
31325
|
function isLocalCliEnabled(env) {
|
|
31322
31326
|
return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
|
|
31323
31327
|
}
|
|
31328
|
+
function formatBannerFilterSuffix(inputs) {
|
|
31329
|
+
const profileValid = !!inputs.profileEnv && !inputs.unknownProfile;
|
|
31330
|
+
const profileLabel = profileValid ? inputs.explicitTools && inputs.profileWouldFilter ? `profile=${inputs.profileEnv} (overridden by TAILSCALE_TOOLS)` : `profile=${inputs.profileEnv}` : null;
|
|
31331
|
+
const groupsLabel = inputs.explicitTools ? `groups=${inputs.explicitTools.join(",")}` : null;
|
|
31332
|
+
return [
|
|
31333
|
+
profileLabel,
|
|
31334
|
+
groupsLabel,
|
|
31335
|
+
inputs.readonlyMode ? "readonly" : null,
|
|
31336
|
+
inputs.localCliEnabled ? "local-cli=on" : null
|
|
31337
|
+
].filter(Boolean).join(", ");
|
|
31338
|
+
}
|
|
31324
31339
|
function wrapToolHandler(tool) {
|
|
31325
31340
|
return async (input) => {
|
|
31326
31341
|
try {
|
|
@@ -31357,7 +31372,11 @@ async function tailnetStatusResource(uri) {
|
|
|
31357
31372
|
]);
|
|
31358
31373
|
const data = {
|
|
31359
31374
|
tailnet: getTailnet(),
|
|
31360
|
-
|
|
31375
|
+
// `?? null` (not `?? 0`): the request succeeded but the body was missing
|
|
31376
|
+
// a `devices` array (204 / empty content-length / unexpected shape).
|
|
31377
|
+
// Reporting `0` in that case would be confidently wrong; null signals
|
|
31378
|
+
// "unknown" so the caller doesn't conflate it with an actually-empty tailnet.
|
|
31379
|
+
deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
|
|
31361
31380
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
31362
31381
|
};
|
|
31363
31382
|
const errors = {};
|
|
@@ -31626,6 +31645,11 @@ var deviceTools = [
|
|
|
31626
31645
|
if (input.fields) params.set("fields", input.fields);
|
|
31627
31646
|
if (input.filters) {
|
|
31628
31647
|
for (const [key, value] of Object.entries(input.filters)) {
|
|
31648
|
+
if (key === "fields") {
|
|
31649
|
+
throw new Error(
|
|
31650
|
+
"filters.fields is not allowed -- use the top-level 'fields' parameter to select which device fields to return."
|
|
31651
|
+
);
|
|
31652
|
+
}
|
|
31629
31653
|
params.set(key, value);
|
|
31630
31654
|
}
|
|
31631
31655
|
}
|
|
@@ -32810,6 +32834,21 @@ var logStreamingTools = [
|
|
|
32810
32834
|
);
|
|
32811
32835
|
}
|
|
32812
32836
|
} else {
|
|
32837
|
+
const s3Only = [
|
|
32838
|
+
"s3Bucket",
|
|
32839
|
+
"s3Region",
|
|
32840
|
+
"s3KeyPrefix",
|
|
32841
|
+
"s3AuthenticationType",
|
|
32842
|
+
"s3AccessKeyId",
|
|
32843
|
+
"s3SecretAccessKey",
|
|
32844
|
+
"s3RoleArn"
|
|
32845
|
+
];
|
|
32846
|
+
const wrongFields = s3Only.filter((f) => input[f] !== void 0);
|
|
32847
|
+
if (wrongFields.length > 0) {
|
|
32848
|
+
throw new Error(
|
|
32849
|
+
`${wrongFields.join(", ")} can only be used with destinationType 's3', not '${input.destinationType}'.`
|
|
32850
|
+
);
|
|
32851
|
+
}
|
|
32813
32852
|
const missing = [];
|
|
32814
32853
|
if (!input.url) missing.push("url");
|
|
32815
32854
|
if (!input.token) missing.push("token");
|
|
@@ -33060,7 +33099,7 @@ var serviceTools = [
|
|
|
33060
33099
|
ports: external_exports.array(
|
|
33061
33100
|
external_exports.object({
|
|
33062
33101
|
protocol: external_exports.enum(["tcp", "udp"]).describe("Protocol (tcp or udp)"),
|
|
33063
|
-
port: external_exports.number().describe("Port number")
|
|
33102
|
+
port: external_exports.number().int().min(1).max(65535).describe("Port number (1-65535)")
|
|
33064
33103
|
})
|
|
33065
33104
|
).optional().describe("Ports the service listens on"),
|
|
33066
33105
|
tags: external_exports.array(external_exports.string()).optional().describe("ACL tags for the service"),
|
|
@@ -33181,7 +33220,11 @@ var statusTools = [
|
|
|
33181
33220
|
const data = {
|
|
33182
33221
|
connected: true,
|
|
33183
33222
|
tailnet: getTailnet(),
|
|
33184
|
-
|
|
33223
|
+
// `?? null` (not `?? 0`): the request succeeded but the body was missing
|
|
33224
|
+
// a `devices` array (204 / empty content-length / unexpected shape).
|
|
33225
|
+
// Reporting `0` in that case would be confidently wrong; null signals
|
|
33226
|
+
// "unknown" so the caller doesn't conflate it with an actually-empty tailnet.
|
|
33227
|
+
deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? null : null,
|
|
33185
33228
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
33186
33229
|
};
|
|
33187
33230
|
const errors = {};
|
|
@@ -33456,7 +33499,7 @@ var userTools = [
|
|
|
33456
33499
|
];
|
|
33457
33500
|
|
|
33458
33501
|
// src/tools/webhooks.ts
|
|
33459
|
-
var
|
|
33502
|
+
var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
33460
33503
|
"nodeCreated",
|
|
33461
33504
|
"nodeNeedsApproval",
|
|
33462
33505
|
"nodeApproved",
|
|
@@ -33476,6 +33519,31 @@ var webhookEventTypes = [
|
|
|
33476
33519
|
"subnetIPForwardingNotEnabled",
|
|
33477
33520
|
"exitNodeIPForwardingNotEnabled"
|
|
33478
33521
|
];
|
|
33522
|
+
function getAllowedWebhookEvents() {
|
|
33523
|
+
const raw = process.env.TAILSCALE_EXTRA_WEBHOOK_EVENTS;
|
|
33524
|
+
if (!raw) return new Set(STATIC_WEBHOOK_EVENT_TYPES);
|
|
33525
|
+
const extras = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
33526
|
+
return /* @__PURE__ */ new Set([...STATIC_WEBHOOK_EVENT_TYPES, ...extras]);
|
|
33527
|
+
}
|
|
33528
|
+
var webhookSubscriptionsSchema = external_exports.array(external_exports.string()).min(1).superRefine((arr, ctx) => {
|
|
33529
|
+
const allowed = getAllowedWebhookEvents();
|
|
33530
|
+
let knownEventsList = null;
|
|
33531
|
+
for (let i = 0; i < arr.length; i++) {
|
|
33532
|
+
const value = arr[i];
|
|
33533
|
+
if (!allowed.has(value)) {
|
|
33534
|
+
knownEventsList ??= [...allowed].sort().join(", ");
|
|
33535
|
+
ctx.addIssue({
|
|
33536
|
+
code: "custom",
|
|
33537
|
+
// `path: [i]` so the issue locates the bad element. Zod prepends the
|
|
33538
|
+
// parent path (e.g. "subscriptions") when reporting through the
|
|
33539
|
+
// surrounding object schema, producing a final path of
|
|
33540
|
+
// ["subscriptions", i] in error.issues.
|
|
33541
|
+
path: [i],
|
|
33542
|
+
message: `Unknown webhook event ${JSON.stringify(value)}. Known events: ${knownEventsList}. To allow a new event Tailscale has shipped before this package updates, set TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventName1,eventName2 in your MCP config.`
|
|
33543
|
+
});
|
|
33544
|
+
}
|
|
33545
|
+
}
|
|
33546
|
+
});
|
|
33479
33547
|
var webhookTools = [
|
|
33480
33548
|
{
|
|
33481
33549
|
name: "tailscale_list_webhooks",
|
|
@@ -33521,7 +33589,7 @@ var webhookTools = [
|
|
|
33521
33589
|
},
|
|
33522
33590
|
inputSchema: external_exports.object({
|
|
33523
33591
|
endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
|
|
33524
|
-
subscriptions:
|
|
33592
|
+
subscriptions: webhookSubscriptionsSchema.describe("Event types to subscribe to (at least one)")
|
|
33525
33593
|
}),
|
|
33526
33594
|
handler: async (input) => {
|
|
33527
33595
|
return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
|
|
@@ -33543,7 +33611,7 @@ var webhookTools = [
|
|
|
33543
33611
|
inputSchema: external_exports.object({
|
|
33544
33612
|
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
33545
33613
|
endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").optional().describe("New HTTPS URL to send webhook events to"),
|
|
33546
|
-
subscriptions:
|
|
33614
|
+
subscriptions: webhookSubscriptionsSchema.optional().describe("Updated list of event types to subscribe to (at least one)")
|
|
33547
33615
|
}),
|
|
33548
33616
|
handler: async (input) => {
|
|
33549
33617
|
const body = {};
|
|
@@ -33611,7 +33679,7 @@ var webhookTools = [
|
|
|
33611
33679
|
];
|
|
33612
33680
|
|
|
33613
33681
|
// src/index.ts
|
|
33614
|
-
var version2 = true ? "0.
|
|
33682
|
+
var version2 = true ? "0.12.0" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
33615
33683
|
var subcommand = process.argv[2];
|
|
33616
33684
|
if (subcommand === "deploy-acl") {
|
|
33617
33685
|
const filePath = process.argv[3];
|
|
@@ -33650,7 +33718,9 @@ if (localCliEnabled) {
|
|
|
33650
33718
|
var {
|
|
33651
33719
|
tools: allTools,
|
|
33652
33720
|
unknownGroups,
|
|
33653
|
-
unknownProfile
|
|
33721
|
+
unknownProfile,
|
|
33722
|
+
explicitTools,
|
|
33723
|
+
profileWouldFilter
|
|
33654
33724
|
} = filterTools(toolGroups, {
|
|
33655
33725
|
tools: process.env.TAILSCALE_TOOLS,
|
|
33656
33726
|
readonly: process.env.TAILSCALE_READONLY,
|
|
@@ -33704,13 +33774,14 @@ server.resource(
|
|
|
33704
33774
|
var transport = new StdioServerTransport();
|
|
33705
33775
|
await server.connect(transport);
|
|
33706
33776
|
var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
|
|
33707
|
-
var
|
|
33708
|
-
|
|
33709
|
-
|
|
33710
|
-
|
|
33711
|
-
|
|
33712
|
-
|
|
33713
|
-
|
|
33777
|
+
var filterSuffix = formatBannerFilterSuffix({
|
|
33778
|
+
unknownProfile,
|
|
33779
|
+
explicitTools,
|
|
33780
|
+
profileWouldFilter,
|
|
33781
|
+
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
33782
|
+
readonlyMode,
|
|
33783
|
+
localCliEnabled
|
|
33784
|
+
});
|
|
33714
33785
|
console.error(
|
|
33715
33786
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
33716
33787
|
);
|