@yawlabs/tailscale-mcp 0.11.0 → 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 +98 -22
- 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,11 +31315,27 @@ 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
|
|
|
31320
31324
|
// src/server-wiring.ts
|
|
31325
|
+
function isLocalCliEnabled(env) {
|
|
31326
|
+
return env.TAILSCALE_LOCAL_CLI === "1" || env.TAILSCALE_LOCAL_CLI === "true";
|
|
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
|
+
}
|
|
31321
31339
|
function wrapToolHandler(tool) {
|
|
31322
31340
|
return async (input) => {
|
|
31323
31341
|
try {
|
|
@@ -31354,7 +31372,11 @@ async function tailnetStatusResource(uri) {
|
|
|
31354
31372
|
]);
|
|
31355
31373
|
const data = {
|
|
31356
31374
|
tailnet: getTailnet(),
|
|
31357
|
-
|
|
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,
|
|
31358
31380
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
31359
31381
|
};
|
|
31360
31382
|
const errors = {};
|
|
@@ -31623,6 +31645,11 @@ var deviceTools = [
|
|
|
31623
31645
|
if (input.fields) params.set("fields", input.fields);
|
|
31624
31646
|
if (input.filters) {
|
|
31625
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
|
+
}
|
|
31626
31653
|
params.set(key, value);
|
|
31627
31654
|
}
|
|
31628
31655
|
}
|
|
@@ -32627,10 +32654,12 @@ async function runTailscaleCli(args, options = {}) {
|
|
|
32627
32654
|
}
|
|
32628
32655
|
|
|
32629
32656
|
// src/tools/local-cli.ts
|
|
32657
|
+
var HOSTNAME_LABEL = /^[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?$/;
|
|
32630
32658
|
function isValidPingTarget(s) {
|
|
32631
32659
|
if (s.length === 0 || s.length > 253) return false;
|
|
32632
32660
|
if (net2.isIP(s)) return true;
|
|
32633
|
-
|
|
32661
|
+
const labels = s.split(".");
|
|
32662
|
+
return labels.every((label) => label.length >= 1 && label.length <= 63 && HOSTNAME_LABEL.test(label));
|
|
32634
32663
|
}
|
|
32635
32664
|
var localCliTools = [
|
|
32636
32665
|
{
|
|
@@ -32805,6 +32834,21 @@ var logStreamingTools = [
|
|
|
32805
32834
|
);
|
|
32806
32835
|
}
|
|
32807
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
|
+
}
|
|
32808
32852
|
const missing = [];
|
|
32809
32853
|
if (!input.url) missing.push("url");
|
|
32810
32854
|
if (!input.token) missing.push("token");
|
|
@@ -33055,7 +33099,7 @@ var serviceTools = [
|
|
|
33055
33099
|
ports: external_exports.array(
|
|
33056
33100
|
external_exports.object({
|
|
33057
33101
|
protocol: external_exports.enum(["tcp", "udp"]).describe("Protocol (tcp or udp)"),
|
|
33058
|
-
port: external_exports.number().describe("Port number")
|
|
33102
|
+
port: external_exports.number().int().min(1).max(65535).describe("Port number (1-65535)")
|
|
33059
33103
|
})
|
|
33060
33104
|
).optional().describe("Ports the service listens on"),
|
|
33061
33105
|
tags: external_exports.array(external_exports.string()).optional().describe("ACL tags for the service"),
|
|
@@ -33176,7 +33220,11 @@ var statusTools = [
|
|
|
33176
33220
|
const data = {
|
|
33177
33221
|
connected: true,
|
|
33178
33222
|
tailnet: getTailnet(),
|
|
33179
|
-
|
|
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,
|
|
33180
33228
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
33181
33229
|
};
|
|
33182
33230
|
const errors = {};
|
|
@@ -33451,7 +33499,7 @@ var userTools = [
|
|
|
33451
33499
|
];
|
|
33452
33500
|
|
|
33453
33501
|
// src/tools/webhooks.ts
|
|
33454
|
-
var
|
|
33502
|
+
var STATIC_WEBHOOK_EVENT_TYPES = [
|
|
33455
33503
|
"nodeCreated",
|
|
33456
33504
|
"nodeNeedsApproval",
|
|
33457
33505
|
"nodeApproved",
|
|
@@ -33471,6 +33519,31 @@ var webhookEventTypes = [
|
|
|
33471
33519
|
"subnetIPForwardingNotEnabled",
|
|
33472
33520
|
"exitNodeIPForwardingNotEnabled"
|
|
33473
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
|
+
});
|
|
33474
33547
|
var webhookTools = [
|
|
33475
33548
|
{
|
|
33476
33549
|
name: "tailscale_list_webhooks",
|
|
@@ -33516,7 +33589,7 @@ var webhookTools = [
|
|
|
33516
33589
|
},
|
|
33517
33590
|
inputSchema: external_exports.object({
|
|
33518
33591
|
endpointUrl: external_exports.string().url().refine((u) => u.startsWith("https://"), "endpointUrl must use https://").describe("The HTTPS URL to send webhook events to"),
|
|
33519
|
-
subscriptions:
|
|
33592
|
+
subscriptions: webhookSubscriptionsSchema.describe("Event types to subscribe to (at least one)")
|
|
33520
33593
|
}),
|
|
33521
33594
|
handler: async (input) => {
|
|
33522
33595
|
return apiPost(`/tailnet/${getTailnet()}/webhooks`, {
|
|
@@ -33538,7 +33611,7 @@ var webhookTools = [
|
|
|
33538
33611
|
inputSchema: external_exports.object({
|
|
33539
33612
|
webhookId: external_exports.string().describe("The webhook ID to update"),
|
|
33540
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"),
|
|
33541
|
-
subscriptions:
|
|
33614
|
+
subscriptions: webhookSubscriptionsSchema.optional().describe("Updated list of event types to subscribe to (at least one)")
|
|
33542
33615
|
}),
|
|
33543
33616
|
handler: async (input) => {
|
|
33544
33617
|
const body = {};
|
|
@@ -33606,7 +33679,7 @@ var webhookTools = [
|
|
|
33606
33679
|
];
|
|
33607
33680
|
|
|
33608
33681
|
// src/index.ts
|
|
33609
|
-
var version2 = true ? "0.
|
|
33682
|
+
var version2 = true ? "0.12.0" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
33610
33683
|
var subcommand = process.argv[2];
|
|
33611
33684
|
if (subcommand === "deploy-acl") {
|
|
33612
33685
|
const filePath = process.argv[3];
|
|
@@ -33638,13 +33711,16 @@ var toolGroups = {
|
|
|
33638
33711
|
services: serviceTools,
|
|
33639
33712
|
"log-streaming": logStreamingTools
|
|
33640
33713
|
};
|
|
33641
|
-
|
|
33714
|
+
var localCliEnabled = isLocalCliEnabled(process.env);
|
|
33715
|
+
if (localCliEnabled) {
|
|
33642
33716
|
toolGroups["local-cli"] = localCliTools;
|
|
33643
33717
|
}
|
|
33644
33718
|
var {
|
|
33645
33719
|
tools: allTools,
|
|
33646
33720
|
unknownGroups,
|
|
33647
|
-
unknownProfile
|
|
33721
|
+
unknownProfile,
|
|
33722
|
+
explicitTools,
|
|
33723
|
+
profileWouldFilter
|
|
33648
33724
|
} = filterTools(toolGroups, {
|
|
33649
33725
|
tools: process.env.TAILSCALE_TOOLS,
|
|
33650
33726
|
readonly: process.env.TAILSCALE_READONLY,
|
|
@@ -33698,14 +33774,14 @@ server.resource(
|
|
|
33698
33774
|
var transport = new StdioServerTransport();
|
|
33699
33775
|
await server.connect(transport);
|
|
33700
33776
|
var readonlyMode = process.env.TAILSCALE_READONLY === "1" || process.env.TAILSCALE_READONLY === "true";
|
|
33701
|
-
var
|
|
33702
|
-
|
|
33703
|
-
|
|
33704
|
-
|
|
33705
|
-
|
|
33706
|
-
readonlyMode
|
|
33707
|
-
localCliEnabled
|
|
33708
|
-
|
|
33777
|
+
var filterSuffix = formatBannerFilterSuffix({
|
|
33778
|
+
unknownProfile,
|
|
33779
|
+
explicitTools,
|
|
33780
|
+
profileWouldFilter,
|
|
33781
|
+
profileEnv: process.env.TAILSCALE_PROFILE,
|
|
33782
|
+
readonlyMode,
|
|
33783
|
+
localCliEnabled
|
|
33784
|
+
});
|
|
33709
33785
|
console.error(
|
|
33710
33786
|
`@yawlabs/tailscale-mcp v${version2} ready (${allTools.length} tools${filterSuffix ? `, ${filterSuffix}` : ""})`
|
|
33711
33787
|
);
|