@yawlabs/caddy-mcp 1.2.2 → 1.2.3
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 +1 -1
- package/dist/index.js +230 -195
- package/dist/server.js +230 -195
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
10
10
|
|
|
11
|
-
[](https://mcp.hosting/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&
|
|
11
|
+
[](https://mcp.hosting/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
|
|
12
12
|
|
|
13
13
|
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.
|
|
14
14
|
|
package/dist/index.js
CHANGED
|
@@ -60,11 +60,15 @@ function isTransientFailure(res) {
|
|
|
60
60
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
|
+
function isRetryableMethod(method, path) {
|
|
64
|
+
if (method !== "POST") return true;
|
|
65
|
+
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
66
|
+
}
|
|
63
67
|
async function caddyRequest(method, path, body, contentType, timeout) {
|
|
64
68
|
const maxRetries = getMaxRetries();
|
|
65
69
|
let attempt = 0;
|
|
66
70
|
let res = await attemptRequest(method, path, body, contentType, timeout);
|
|
67
|
-
while (isTransientFailure(res) && attempt < maxRetries) {
|
|
71
|
+
while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
|
|
68
72
|
attempt++;
|
|
69
73
|
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
|
|
70
74
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
@@ -236,6 +240,179 @@ function getMetrics() {
|
|
|
236
240
|
return caddyRequest("GET", "/metrics");
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
// src/tools/operational.ts
|
|
244
|
+
import { z } from "zod";
|
|
245
|
+
|
|
246
|
+
// src/format.ts
|
|
247
|
+
function formatResult(res) {
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
return {
|
|
250
|
+
isError: true,
|
|
251
|
+
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
255
|
+
const text = raw || "OK";
|
|
256
|
+
return { content: [{ type: "text", text }] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/tools/operational.ts
|
|
260
|
+
function describeServer(raw) {
|
|
261
|
+
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
262
|
+
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
263
|
+
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
264
|
+
const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
|
|
265
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
266
|
+
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
267
|
+
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
268
|
+
}
|
|
269
|
+
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
270
|
+
function metricNameFromLine(line) {
|
|
271
|
+
const trimmed = line.trimStart();
|
|
272
|
+
if (trimmed === "") return void 0;
|
|
273
|
+
if (trimmed.startsWith("#")) {
|
|
274
|
+
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
275
|
+
return m2 ? m2[1] : void 0;
|
|
276
|
+
}
|
|
277
|
+
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
278
|
+
return m ? m[1] : void 0;
|
|
279
|
+
}
|
|
280
|
+
function applyMetricsControls(raw, filter, maxLines) {
|
|
281
|
+
const lines = raw.split("\n");
|
|
282
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
283
|
+
let filtered;
|
|
284
|
+
if (filter && filter.length > 0) {
|
|
285
|
+
filtered = lines.filter((line) => {
|
|
286
|
+
if (line.trim() === "# EOF") return true;
|
|
287
|
+
const name = metricNameFromLine(line);
|
|
288
|
+
return name?.includes(filter) ?? false;
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
filtered = lines;
|
|
292
|
+
}
|
|
293
|
+
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
294
|
+
const dropped = filtered.length - maxLines;
|
|
295
|
+
const kept = filtered.slice(0, maxLines);
|
|
296
|
+
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
297
|
+
const keptHasEof = kept.some((l) => l.trim() === "# EOF");
|
|
298
|
+
if (!keptHasEof && filtered.slice(maxLines).some((l) => l.trim() === "# EOF")) {
|
|
299
|
+
kept.push("# EOF");
|
|
300
|
+
}
|
|
301
|
+
return kept.join("\n");
|
|
302
|
+
}
|
|
303
|
+
function findAcmeEmail(policies) {
|
|
304
|
+
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
305
|
+
const rawPolicy = policies[0];
|
|
306
|
+
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
307
|
+
const policy = rawPolicy;
|
|
308
|
+
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
309
|
+
const rawIssuer = policy.issuers[0];
|
|
310
|
+
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
311
|
+
const issuer = rawIssuer;
|
|
312
|
+
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
313
|
+
}
|
|
314
|
+
function registerOperationalTools(server) {
|
|
315
|
+
server.tool(
|
|
316
|
+
"caddy_status",
|
|
317
|
+
"Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
|
|
318
|
+
{},
|
|
319
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
320
|
+
async () => {
|
|
321
|
+
const res = await configGet();
|
|
322
|
+
if (!res.ok) return formatResult(res);
|
|
323
|
+
const config = res.data ?? {};
|
|
324
|
+
const servers = config.apps?.http?.servers ?? {};
|
|
325
|
+
const serverNames = Object.keys(servers);
|
|
326
|
+
const lines = ["Caddy is running", ""];
|
|
327
|
+
if (serverNames.length === 0) {
|
|
328
|
+
lines.push("No HTTP servers configured");
|
|
329
|
+
} else {
|
|
330
|
+
for (const name of serverNames) {
|
|
331
|
+
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
335
|
+
if (email) lines.push(`
|
|
336
|
+
ACME email: ${email}`);
|
|
337
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
server.tool(
|
|
341
|
+
"caddy_list_servers",
|
|
342
|
+
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
343
|
+
{},
|
|
344
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
345
|
+
async () => {
|
|
346
|
+
const res = await configGet("apps/http/servers");
|
|
347
|
+
if (!res.ok) return formatResult(res);
|
|
348
|
+
const servers = res.data ?? {};
|
|
349
|
+
const names = Object.keys(servers);
|
|
350
|
+
if (names.length === 0) {
|
|
351
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
352
|
+
}
|
|
353
|
+
const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
356
|
+
${lines.join("\n")}` }]
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
server.tool(
|
|
361
|
+
"caddy_upstreams",
|
|
362
|
+
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
363
|
+
{},
|
|
364
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
365
|
+
async () => formatResult(await getUpstreams())
|
|
366
|
+
);
|
|
367
|
+
server.tool(
|
|
368
|
+
"caddy_pki",
|
|
369
|
+
"Get PKI certificate authority info or the CA certificate chain.",
|
|
370
|
+
{
|
|
371
|
+
ca: z.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
372
|
+
certificates: z.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
373
|
+
},
|
|
374
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
375
|
+
async ({ ca, certificates }) => {
|
|
376
|
+
const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
|
|
377
|
+
return formatResult(res);
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
server.tool(
|
|
381
|
+
"caddy_metrics",
|
|
382
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
383
|
+
{
|
|
384
|
+
filter: z.string().optional().describe(
|
|
385
|
+
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
386
|
+
),
|
|
387
|
+
max_lines: z.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
388
|
+
},
|
|
389
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
390
|
+
async ({ filter, max_lines }) => {
|
|
391
|
+
const res = await getMetrics();
|
|
392
|
+
if (!res.ok) return formatResult(res);
|
|
393
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
394
|
+
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
395
|
+
const text = applyMetricsControls(raw, filter, limit);
|
|
396
|
+
return { content: [{ type: "text", text: text || "OK" }] };
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
server.tool(
|
|
400
|
+
"caddy_stop",
|
|
401
|
+
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
402
|
+
{ confirm: z.boolean().describe("Must be true to confirm shutdown") },
|
|
403
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
404
|
+
async ({ confirm }) => {
|
|
405
|
+
if (!confirm) {
|
|
406
|
+
return {
|
|
407
|
+
isError: true,
|
|
408
|
+
content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return formatResult(await stop());
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
239
416
|
// src/resources.ts
|
|
240
417
|
function registerResources(server) {
|
|
241
418
|
server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
|
|
@@ -270,15 +447,29 @@ function registerResources(server) {
|
|
|
270
447
|
server.resource(
|
|
271
448
|
"caddy-metrics",
|
|
272
449
|
"caddy://metrics",
|
|
273
|
-
{
|
|
450
|
+
{
|
|
451
|
+
description: "Prometheus metrics (text exposition format). Capped at the same default line count as the caddy_metrics tool -- on busy servers the raw body can be megabytes. Use the caddy_metrics tool for filtered or larger output."
|
|
452
|
+
},
|
|
274
453
|
async () => {
|
|
275
454
|
const res = await getMetrics();
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
return {
|
|
457
|
+
contents: [
|
|
458
|
+
{
|
|
459
|
+
uri: "caddy://metrics",
|
|
460
|
+
mimeType: "text/plain",
|
|
461
|
+
text: `Error: ${res.error}`
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
276
467
|
return {
|
|
277
468
|
contents: [
|
|
278
469
|
{
|
|
279
470
|
uri: "caddy://metrics",
|
|
280
471
|
mimeType: "text/plain",
|
|
281
|
-
text:
|
|
472
|
+
text: applyMetricsControls(raw, void 0, METRICS_DEFAULT_MAX_LINES)
|
|
282
473
|
}
|
|
283
474
|
]
|
|
284
475
|
};
|
|
@@ -304,22 +495,7 @@ function registerResources(server) {
|
|
|
304
495
|
}
|
|
305
496
|
|
|
306
497
|
// src/tools/adapt.ts
|
|
307
|
-
import { z } from "zod";
|
|
308
|
-
|
|
309
|
-
// src/format.ts
|
|
310
|
-
function formatResult(res) {
|
|
311
|
-
if (!res.ok) {
|
|
312
|
-
return {
|
|
313
|
-
isError: true,
|
|
314
|
-
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
318
|
-
const text = raw || "OK";
|
|
319
|
-
return { content: [{ type: "text", text }] };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// src/tools/adapt.ts
|
|
498
|
+
import { z as z2 } from "zod";
|
|
323
499
|
function formatWarning(w) {
|
|
324
500
|
if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
|
|
325
501
|
const obj = w;
|
|
@@ -332,8 +508,8 @@ function registerAdaptTools(server) {
|
|
|
332
508
|
"caddy_adapt",
|
|
333
509
|
"Convert a config in any registered adapter format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces, or for porting from nginx/yaml configs when Caddy is built with the matching adapter module ('caddyfile' is built-in; 'nginx', 'yaml', etc. require their adapter modules to be compiled into the Caddy binary). Returns the adapted JSON and any warnings separately.",
|
|
334
510
|
{
|
|
335
|
-
config:
|
|
336
|
-
adapter:
|
|
511
|
+
config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
|
|
512
|
+
adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
|
|
337
513
|
"Config format adapter. Must match an adapter Caddy was built with. Built-in: 'caddyfile' (default). Common external adapters: 'nginx' (caddy-nginx-adapter), 'yaml' (caddy-yaml)."
|
|
338
514
|
)
|
|
339
515
|
},
|
|
@@ -360,7 +536,7 @@ ${warnLines.join("\n")}` });
|
|
|
360
536
|
}
|
|
361
537
|
|
|
362
538
|
// src/tools/config.ts
|
|
363
|
-
import { z as
|
|
539
|
+
import { z as z3 } from "zod";
|
|
364
540
|
|
|
365
541
|
// src/snapshots.ts
|
|
366
542
|
var MAX_SNAPSHOTS = 10;
|
|
@@ -386,7 +562,7 @@ function registerConfigTools(server) {
|
|
|
386
562
|
server.tool(
|
|
387
563
|
"caddy_config_get",
|
|
388
564
|
"Read Caddy config at any JSON path. Returns the full config when path is empty, or a subtree at a specific path (e.g., 'apps/http/servers/srv0/routes').",
|
|
389
|
-
{ path:
|
|
565
|
+
{ path: z3.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
|
|
390
566
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
391
567
|
async ({ path }) => formatResult(await configGet(path))
|
|
392
568
|
);
|
|
@@ -394,9 +570,9 @@ function registerConfigTools(server) {
|
|
|
394
570
|
"caddy_config_set",
|
|
395
571
|
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
|
|
396
572
|
{
|
|
397
|
-
path:
|
|
398
|
-
value:
|
|
399
|
-
mode:
|
|
573
|
+
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
574
|
+
value: z3.any().describe("The JSON value to set at the path"),
|
|
575
|
+
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
400
576
|
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
|
|
401
577
|
)
|
|
402
578
|
},
|
|
@@ -409,7 +585,7 @@ function registerConfigTools(server) {
|
|
|
409
585
|
server.tool(
|
|
410
586
|
"caddy_config_delete",
|
|
411
587
|
"Delete config at a JSON path. Removes the config node at the specified path.",
|
|
412
|
-
{ path:
|
|
588
|
+
{ path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
|
|
413
589
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
414
590
|
async ({ path }) => formatResult(await configDelete(path))
|
|
415
591
|
);
|
|
@@ -417,26 +593,27 @@ function registerConfigTools(server) {
|
|
|
417
593
|
"caddy_load",
|
|
418
594
|
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
419
595
|
{
|
|
420
|
-
config:
|
|
421
|
-
format:
|
|
596
|
+
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
597
|
+
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
422
598
|
},
|
|
423
599
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
424
600
|
async ({ config, format }) => {
|
|
425
601
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
426
602
|
const current = await configGet();
|
|
427
|
-
|
|
603
|
+
const res = await loadConfig(config, contentType);
|
|
604
|
+
if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
|
|
428
605
|
saveSnapshot(current.data, "caddy_load");
|
|
429
606
|
}
|
|
430
|
-
return formatResult(
|
|
607
|
+
return formatResult(res);
|
|
431
608
|
}
|
|
432
609
|
);
|
|
433
610
|
server.tool(
|
|
434
611
|
"caddy_revert",
|
|
435
612
|
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load and kept in-memory (last 10). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
436
613
|
{
|
|
437
|
-
action:
|
|
438
|
-
index:
|
|
439
|
-
confirm:
|
|
614
|
+
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
615
|
+
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
616
|
+
confirm: z3.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
|
|
440
617
|
},
|
|
441
618
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
442
619
|
async ({ action, index, confirm }) => {
|
|
@@ -511,11 +688,11 @@ ${lines.join("\n")}` }] };
|
|
|
511
688
|
"caddy_config_by_id",
|
|
512
689
|
"Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
|
|
513
690
|
{
|
|
514
|
-
id:
|
|
515
|
-
action:
|
|
516
|
-
value:
|
|
517
|
-
subpath:
|
|
518
|
-
mode:
|
|
691
|
+
id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
692
|
+
action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
693
|
+
value: z3.any().optional().describe("New value (required for 'set' action)"),
|
|
694
|
+
subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
695
|
+
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
519
696
|
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
520
697
|
)
|
|
521
698
|
},
|
|
@@ -542,172 +719,30 @@ ${lines.join("\n")}` }] };
|
|
|
542
719
|
);
|
|
543
720
|
}
|
|
544
721
|
|
|
545
|
-
// src/tools/operational.ts
|
|
546
|
-
import { z as z3 } from "zod";
|
|
547
|
-
function describeServer(raw) {
|
|
548
|
-
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
549
|
-
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
550
|
-
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
551
|
-
const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
|
|
552
|
-
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
553
|
-
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
554
|
-
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
555
|
-
}
|
|
556
|
-
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
557
|
-
function metricNameFromLine(line) {
|
|
558
|
-
const trimmed = line.trimStart();
|
|
559
|
-
if (trimmed === "") return void 0;
|
|
560
|
-
if (trimmed.startsWith("#")) {
|
|
561
|
-
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
562
|
-
return m2 ? m2[1] : void 0;
|
|
563
|
-
}
|
|
564
|
-
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
565
|
-
return m ? m[1] : void 0;
|
|
566
|
-
}
|
|
567
|
-
function applyMetricsControls(raw, filter, maxLines) {
|
|
568
|
-
const lines = raw.split("\n");
|
|
569
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
570
|
-
let filtered;
|
|
571
|
-
if (filter && filter.length > 0) {
|
|
572
|
-
filtered = lines.filter((line) => {
|
|
573
|
-
if (line.trim() === "# EOF") return true;
|
|
574
|
-
const name = metricNameFromLine(line);
|
|
575
|
-
return name?.includes(filter) ?? false;
|
|
576
|
-
});
|
|
577
|
-
} else {
|
|
578
|
-
filtered = lines;
|
|
579
|
-
}
|
|
580
|
-
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
581
|
-
const dropped = filtered.length - maxLines;
|
|
582
|
-
const kept = filtered.slice(0, maxLines);
|
|
583
|
-
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
584
|
-
return kept.join("\n");
|
|
585
|
-
}
|
|
586
|
-
function findAcmeEmail(policies) {
|
|
587
|
-
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
588
|
-
const rawPolicy = policies[0];
|
|
589
|
-
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
590
|
-
const policy = rawPolicy;
|
|
591
|
-
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
592
|
-
const rawIssuer = policy.issuers[0];
|
|
593
|
-
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
594
|
-
const issuer = rawIssuer;
|
|
595
|
-
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
596
|
-
}
|
|
597
|
-
function registerOperationalTools(server) {
|
|
598
|
-
server.tool(
|
|
599
|
-
"caddy_status",
|
|
600
|
-
"Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
|
|
601
|
-
{},
|
|
602
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
603
|
-
async () => {
|
|
604
|
-
const res = await configGet();
|
|
605
|
-
if (!res.ok) return formatResult(res);
|
|
606
|
-
const config = res.data ?? {};
|
|
607
|
-
const servers = config.apps?.http?.servers ?? {};
|
|
608
|
-
const serverNames = Object.keys(servers);
|
|
609
|
-
const lines = ["Caddy is running", ""];
|
|
610
|
-
if (serverNames.length === 0) {
|
|
611
|
-
lines.push("No HTTP servers configured");
|
|
612
|
-
} else {
|
|
613
|
-
for (const name of serverNames) {
|
|
614
|
-
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
618
|
-
if (email) lines.push(`
|
|
619
|
-
ACME email: ${email}`);
|
|
620
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
621
|
-
}
|
|
622
|
-
);
|
|
623
|
-
server.tool(
|
|
624
|
-
"caddy_list_servers",
|
|
625
|
-
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
626
|
-
{},
|
|
627
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
628
|
-
async () => {
|
|
629
|
-
const res = await configGet("apps/http/servers");
|
|
630
|
-
if (!res.ok) return formatResult(res);
|
|
631
|
-
const servers = res.data ?? {};
|
|
632
|
-
const names = Object.keys(servers);
|
|
633
|
-
if (names.length === 0) {
|
|
634
|
-
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
635
|
-
}
|
|
636
|
-
const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
|
|
637
|
-
return {
|
|
638
|
-
content: [{ type: "text", text: `HTTP Servers:
|
|
639
|
-
${lines.join("\n")}` }]
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
);
|
|
643
|
-
server.tool(
|
|
644
|
-
"caddy_upstreams",
|
|
645
|
-
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
646
|
-
{},
|
|
647
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
648
|
-
async () => formatResult(await getUpstreams())
|
|
649
|
-
);
|
|
650
|
-
server.tool(
|
|
651
|
-
"caddy_pki",
|
|
652
|
-
"Get PKI certificate authority info or the CA certificate chain.",
|
|
653
|
-
{
|
|
654
|
-
ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
655
|
-
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
656
|
-
},
|
|
657
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
658
|
-
async ({ ca, certificates }) => {
|
|
659
|
-
const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
|
|
660
|
-
return formatResult(res);
|
|
661
|
-
}
|
|
662
|
-
);
|
|
663
|
-
server.tool(
|
|
664
|
-
"caddy_metrics",
|
|
665
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
666
|
-
{
|
|
667
|
-
filter: z3.string().optional().describe(
|
|
668
|
-
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
669
|
-
),
|
|
670
|
-
max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
671
|
-
},
|
|
672
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
673
|
-
async ({ filter, max_lines }) => {
|
|
674
|
-
const res = await getMetrics();
|
|
675
|
-
if (!res.ok) return formatResult(res);
|
|
676
|
-
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
677
|
-
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
678
|
-
const text = applyMetricsControls(raw, filter, limit);
|
|
679
|
-
return { content: [{ type: "text", text: text || "OK" }] };
|
|
680
|
-
}
|
|
681
|
-
);
|
|
682
|
-
server.tool(
|
|
683
|
-
"caddy_stop",
|
|
684
|
-
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
685
|
-
{ confirm: z3.boolean().describe("Must be true to confirm shutdown") },
|
|
686
|
-
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
687
|
-
async ({ confirm }) => {
|
|
688
|
-
if (!confirm) {
|
|
689
|
-
return {
|
|
690
|
-
isError: true,
|
|
691
|
-
content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
return formatResult(await stop());
|
|
695
|
-
}
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
722
|
// src/tools/routes.ts
|
|
700
723
|
import { z as z4 } from "zod";
|
|
701
724
|
function safeJoin(value) {
|
|
702
725
|
if (!Array.isArray(value)) return "";
|
|
703
726
|
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
704
727
|
}
|
|
728
|
+
function stripPort(host) {
|
|
729
|
+
if (host.startsWith("[")) {
|
|
730
|
+
const closeIdx = host.indexOf("]");
|
|
731
|
+
if (closeIdx !== -1) return host.substring(0, closeIdx + 1);
|
|
732
|
+
return host;
|
|
733
|
+
}
|
|
734
|
+
const colonIdx = host.lastIndexOf(":");
|
|
735
|
+
if (colonIdx === -1) return host;
|
|
736
|
+
const portCandidate = host.substring(colonIdx + 1);
|
|
737
|
+
if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
|
|
738
|
+
return host.substring(0, colonIdx);
|
|
739
|
+
}
|
|
705
740
|
function parseFrom(from) {
|
|
706
741
|
const cleaned = from.replace(/^https?:\/\//, "");
|
|
707
742
|
const match = {};
|
|
708
743
|
const slashIdx = cleaned.indexOf("/");
|
|
709
744
|
if (slashIdx > 0) {
|
|
710
|
-
match.host = [cleaned.substring(0, slashIdx)];
|
|
745
|
+
match.host = [stripPort(cleaned.substring(0, slashIdx))];
|
|
711
746
|
const path = cleaned.substring(slashIdx);
|
|
712
747
|
if (path !== "/") {
|
|
713
748
|
match.path = [path];
|
|
@@ -715,7 +750,7 @@ function parseFrom(from) {
|
|
|
715
750
|
} else if (cleaned.startsWith("/")) {
|
|
716
751
|
match.path = [cleaned];
|
|
717
752
|
} else {
|
|
718
|
-
match.host = [cleaned];
|
|
753
|
+
match.host = [stripPort(cleaned)];
|
|
719
754
|
}
|
|
720
755
|
return match;
|
|
721
756
|
}
|
package/dist/server.js
CHANGED
|
@@ -58,11 +58,15 @@ function isTransientFailure(res) {
|
|
|
58
58
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
|
+
function isRetryableMethod(method, path) {
|
|
62
|
+
if (method !== "POST") return true;
|
|
63
|
+
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
64
|
+
}
|
|
61
65
|
async function caddyRequest(method, path, body, contentType, timeout) {
|
|
62
66
|
const maxRetries = getMaxRetries();
|
|
63
67
|
let attempt = 0;
|
|
64
68
|
let res = await attemptRequest(method, path, body, contentType, timeout);
|
|
65
|
-
while (isTransientFailure(res) && attempt < maxRetries) {
|
|
69
|
+
while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
|
|
66
70
|
attempt++;
|
|
67
71
|
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
|
|
68
72
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
@@ -234,6 +238,179 @@ function getMetrics() {
|
|
|
234
238
|
return caddyRequest("GET", "/metrics");
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
// src/tools/operational.ts
|
|
242
|
+
import { z } from "zod";
|
|
243
|
+
|
|
244
|
+
// src/format.ts
|
|
245
|
+
function formatResult(res) {
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
return {
|
|
248
|
+
isError: true,
|
|
249
|
+
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
253
|
+
const text = raw || "OK";
|
|
254
|
+
return { content: [{ type: "text", text }] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/tools/operational.ts
|
|
258
|
+
function describeServer(raw) {
|
|
259
|
+
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
260
|
+
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
261
|
+
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
262
|
+
const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
|
|
263
|
+
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
264
|
+
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
265
|
+
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
266
|
+
}
|
|
267
|
+
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
268
|
+
function metricNameFromLine(line) {
|
|
269
|
+
const trimmed = line.trimStart();
|
|
270
|
+
if (trimmed === "") return void 0;
|
|
271
|
+
if (trimmed.startsWith("#")) {
|
|
272
|
+
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
273
|
+
return m2 ? m2[1] : void 0;
|
|
274
|
+
}
|
|
275
|
+
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
276
|
+
return m ? m[1] : void 0;
|
|
277
|
+
}
|
|
278
|
+
function applyMetricsControls(raw, filter, maxLines) {
|
|
279
|
+
const lines = raw.split("\n");
|
|
280
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
281
|
+
let filtered;
|
|
282
|
+
if (filter && filter.length > 0) {
|
|
283
|
+
filtered = lines.filter((line) => {
|
|
284
|
+
if (line.trim() === "# EOF") return true;
|
|
285
|
+
const name = metricNameFromLine(line);
|
|
286
|
+
return name?.includes(filter) ?? false;
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
filtered = lines;
|
|
290
|
+
}
|
|
291
|
+
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
292
|
+
const dropped = filtered.length - maxLines;
|
|
293
|
+
const kept = filtered.slice(0, maxLines);
|
|
294
|
+
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
295
|
+
const keptHasEof = kept.some((l) => l.trim() === "# EOF");
|
|
296
|
+
if (!keptHasEof && filtered.slice(maxLines).some((l) => l.trim() === "# EOF")) {
|
|
297
|
+
kept.push("# EOF");
|
|
298
|
+
}
|
|
299
|
+
return kept.join("\n");
|
|
300
|
+
}
|
|
301
|
+
function findAcmeEmail(policies) {
|
|
302
|
+
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
303
|
+
const rawPolicy = policies[0];
|
|
304
|
+
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
305
|
+
const policy = rawPolicy;
|
|
306
|
+
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
307
|
+
const rawIssuer = policy.issuers[0];
|
|
308
|
+
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
309
|
+
const issuer = rawIssuer;
|
|
310
|
+
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
311
|
+
}
|
|
312
|
+
function registerOperationalTools(server) {
|
|
313
|
+
server.tool(
|
|
314
|
+
"caddy_status",
|
|
315
|
+
"Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
|
|
316
|
+
{},
|
|
317
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
318
|
+
async () => {
|
|
319
|
+
const res = await configGet();
|
|
320
|
+
if (!res.ok) return formatResult(res);
|
|
321
|
+
const config = res.data ?? {};
|
|
322
|
+
const servers = config.apps?.http?.servers ?? {};
|
|
323
|
+
const serverNames = Object.keys(servers);
|
|
324
|
+
const lines = ["Caddy is running", ""];
|
|
325
|
+
if (serverNames.length === 0) {
|
|
326
|
+
lines.push("No HTTP servers configured");
|
|
327
|
+
} else {
|
|
328
|
+
for (const name of serverNames) {
|
|
329
|
+
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
333
|
+
if (email) lines.push(`
|
|
334
|
+
ACME email: ${email}`);
|
|
335
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
server.tool(
|
|
339
|
+
"caddy_list_servers",
|
|
340
|
+
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
341
|
+
{},
|
|
342
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
343
|
+
async () => {
|
|
344
|
+
const res = await configGet("apps/http/servers");
|
|
345
|
+
if (!res.ok) return formatResult(res);
|
|
346
|
+
const servers = res.data ?? {};
|
|
347
|
+
const names = Object.keys(servers);
|
|
348
|
+
if (names.length === 0) {
|
|
349
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
350
|
+
}
|
|
351
|
+
const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text", text: `HTTP Servers:
|
|
354
|
+
${lines.join("\n")}` }]
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
server.tool(
|
|
359
|
+
"caddy_upstreams",
|
|
360
|
+
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
361
|
+
{},
|
|
362
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
363
|
+
async () => formatResult(await getUpstreams())
|
|
364
|
+
);
|
|
365
|
+
server.tool(
|
|
366
|
+
"caddy_pki",
|
|
367
|
+
"Get PKI certificate authority info or the CA certificate chain.",
|
|
368
|
+
{
|
|
369
|
+
ca: z.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
370
|
+
certificates: z.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
371
|
+
},
|
|
372
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
373
|
+
async ({ ca, certificates }) => {
|
|
374
|
+
const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
|
|
375
|
+
return formatResult(res);
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
server.tool(
|
|
379
|
+
"caddy_metrics",
|
|
380
|
+
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
381
|
+
{
|
|
382
|
+
filter: z.string().optional().describe(
|
|
383
|
+
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
384
|
+
),
|
|
385
|
+
max_lines: z.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
386
|
+
},
|
|
387
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
388
|
+
async ({ filter, max_lines }) => {
|
|
389
|
+
const res = await getMetrics();
|
|
390
|
+
if (!res.ok) return formatResult(res);
|
|
391
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
392
|
+
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
393
|
+
const text = applyMetricsControls(raw, filter, limit);
|
|
394
|
+
return { content: [{ type: "text", text: text || "OK" }] };
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
server.tool(
|
|
398
|
+
"caddy_stop",
|
|
399
|
+
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
400
|
+
{ confirm: z.boolean().describe("Must be true to confirm shutdown") },
|
|
401
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
402
|
+
async ({ confirm }) => {
|
|
403
|
+
if (!confirm) {
|
|
404
|
+
return {
|
|
405
|
+
isError: true,
|
|
406
|
+
content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return formatResult(await stop());
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
237
414
|
// src/resources.ts
|
|
238
415
|
function registerResources(server) {
|
|
239
416
|
server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
|
|
@@ -268,15 +445,29 @@ function registerResources(server) {
|
|
|
268
445
|
server.resource(
|
|
269
446
|
"caddy-metrics",
|
|
270
447
|
"caddy://metrics",
|
|
271
|
-
{
|
|
448
|
+
{
|
|
449
|
+
description: "Prometheus metrics (text exposition format). Capped at the same default line count as the caddy_metrics tool -- on busy servers the raw body can be megabytes. Use the caddy_metrics tool for filtered or larger output."
|
|
450
|
+
},
|
|
272
451
|
async () => {
|
|
273
452
|
const res = await getMetrics();
|
|
453
|
+
if (!res.ok) {
|
|
454
|
+
return {
|
|
455
|
+
contents: [
|
|
456
|
+
{
|
|
457
|
+
uri: "caddy://metrics",
|
|
458
|
+
mimeType: "text/plain",
|
|
459
|
+
text: `Error: ${res.error}`
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
274
465
|
return {
|
|
275
466
|
contents: [
|
|
276
467
|
{
|
|
277
468
|
uri: "caddy://metrics",
|
|
278
469
|
mimeType: "text/plain",
|
|
279
|
-
text:
|
|
470
|
+
text: applyMetricsControls(raw, void 0, METRICS_DEFAULT_MAX_LINES)
|
|
280
471
|
}
|
|
281
472
|
]
|
|
282
473
|
};
|
|
@@ -302,22 +493,7 @@ function registerResources(server) {
|
|
|
302
493
|
}
|
|
303
494
|
|
|
304
495
|
// src/tools/adapt.ts
|
|
305
|
-
import { z } from "zod";
|
|
306
|
-
|
|
307
|
-
// src/format.ts
|
|
308
|
-
function formatResult(res) {
|
|
309
|
-
if (!res.ok) {
|
|
310
|
-
return {
|
|
311
|
-
isError: true,
|
|
312
|
-
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
316
|
-
const text = raw || "OK";
|
|
317
|
-
return { content: [{ type: "text", text }] };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// src/tools/adapt.ts
|
|
496
|
+
import { z as z2 } from "zod";
|
|
321
497
|
function formatWarning(w) {
|
|
322
498
|
if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
|
|
323
499
|
const obj = w;
|
|
@@ -330,8 +506,8 @@ function registerAdaptTools(server) {
|
|
|
330
506
|
"caddy_adapt",
|
|
331
507
|
"Convert a config in any registered adapter format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces, or for porting from nginx/yaml configs when Caddy is built with the matching adapter module ('caddyfile' is built-in; 'nginx', 'yaml', etc. require their adapter modules to be compiled into the Caddy binary). Returns the adapted JSON and any warnings separately.",
|
|
332
508
|
{
|
|
333
|
-
config:
|
|
334
|
-
adapter:
|
|
509
|
+
config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
|
|
510
|
+
adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
|
|
335
511
|
"Config format adapter. Must match an adapter Caddy was built with. Built-in: 'caddyfile' (default). Common external adapters: 'nginx' (caddy-nginx-adapter), 'yaml' (caddy-yaml)."
|
|
336
512
|
)
|
|
337
513
|
},
|
|
@@ -358,7 +534,7 @@ ${warnLines.join("\n")}` });
|
|
|
358
534
|
}
|
|
359
535
|
|
|
360
536
|
// src/tools/config.ts
|
|
361
|
-
import { z as
|
|
537
|
+
import { z as z3 } from "zod";
|
|
362
538
|
|
|
363
539
|
// src/snapshots.ts
|
|
364
540
|
var MAX_SNAPSHOTS = 10;
|
|
@@ -384,7 +560,7 @@ function registerConfigTools(server) {
|
|
|
384
560
|
server.tool(
|
|
385
561
|
"caddy_config_get",
|
|
386
562
|
"Read Caddy config at any JSON path. Returns the full config when path is empty, or a subtree at a specific path (e.g., 'apps/http/servers/srv0/routes').",
|
|
387
|
-
{ path:
|
|
563
|
+
{ path: z3.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
|
|
388
564
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
389
565
|
async ({ path }) => formatResult(await configGet(path))
|
|
390
566
|
);
|
|
@@ -392,9 +568,9 @@ function registerConfigTools(server) {
|
|
|
392
568
|
"caddy_config_set",
|
|
393
569
|
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
|
|
394
570
|
{
|
|
395
|
-
path:
|
|
396
|
-
value:
|
|
397
|
-
mode:
|
|
571
|
+
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
572
|
+
value: z3.any().describe("The JSON value to set at the path"),
|
|
573
|
+
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
398
574
|
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
|
|
399
575
|
)
|
|
400
576
|
},
|
|
@@ -407,7 +583,7 @@ function registerConfigTools(server) {
|
|
|
407
583
|
server.tool(
|
|
408
584
|
"caddy_config_delete",
|
|
409
585
|
"Delete config at a JSON path. Removes the config node at the specified path.",
|
|
410
|
-
{ path:
|
|
586
|
+
{ path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
|
|
411
587
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
412
588
|
async ({ path }) => formatResult(await configDelete(path))
|
|
413
589
|
);
|
|
@@ -415,26 +591,27 @@ function registerConfigTools(server) {
|
|
|
415
591
|
"caddy_load",
|
|
416
592
|
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Has a 60-second timeout to allow for TLS provisioning.",
|
|
417
593
|
{
|
|
418
|
-
config:
|
|
419
|
-
format:
|
|
594
|
+
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
595
|
+
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
|
|
420
596
|
},
|
|
421
597
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
422
598
|
async ({ config, format }) => {
|
|
423
599
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
424
600
|
const current = await configGet();
|
|
425
|
-
|
|
601
|
+
const res = await loadConfig(config, contentType);
|
|
602
|
+
if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
|
|
426
603
|
saveSnapshot(current.data, "caddy_load");
|
|
427
604
|
}
|
|
428
|
-
return formatResult(
|
|
605
|
+
return formatResult(res);
|
|
429
606
|
}
|
|
430
607
|
);
|
|
431
608
|
server.tool(
|
|
432
609
|
"caddy_revert",
|
|
433
610
|
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load and kept in-memory (last 10). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
434
611
|
{
|
|
435
|
-
action:
|
|
436
|
-
index:
|
|
437
|
-
confirm:
|
|
612
|
+
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
613
|
+
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
614
|
+
confirm: z3.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
|
|
438
615
|
},
|
|
439
616
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
440
617
|
async ({ action, index, confirm }) => {
|
|
@@ -509,11 +686,11 @@ ${lines.join("\n")}` }] };
|
|
|
509
686
|
"caddy_config_by_id",
|
|
510
687
|
"Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
|
|
511
688
|
{
|
|
512
|
-
id:
|
|
513
|
-
action:
|
|
514
|
-
value:
|
|
515
|
-
subpath:
|
|
516
|
-
mode:
|
|
689
|
+
id: z3.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
690
|
+
action: z3.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
691
|
+
value: z3.any().optional().describe("New value (required for 'set' action)"),
|
|
692
|
+
subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
693
|
+
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
517
694
|
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
518
695
|
)
|
|
519
696
|
},
|
|
@@ -540,172 +717,30 @@ ${lines.join("\n")}` }] };
|
|
|
540
717
|
);
|
|
541
718
|
}
|
|
542
719
|
|
|
543
|
-
// src/tools/operational.ts
|
|
544
|
-
import { z as z3 } from "zod";
|
|
545
|
-
function describeServer(raw) {
|
|
546
|
-
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
547
|
-
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
548
|
-
const hasExplicitTls = !!raw.tls_connection_policies;
|
|
549
|
-
const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
|
|
550
|
-
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
551
|
-
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
552
|
-
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
553
|
-
}
|
|
554
|
-
var METRICS_DEFAULT_MAX_LINES = 500;
|
|
555
|
-
function metricNameFromLine(line) {
|
|
556
|
-
const trimmed = line.trimStart();
|
|
557
|
-
if (trimmed === "") return void 0;
|
|
558
|
-
if (trimmed.startsWith("#")) {
|
|
559
|
-
const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
560
|
-
return m2 ? m2[1] : void 0;
|
|
561
|
-
}
|
|
562
|
-
const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
|
|
563
|
-
return m ? m[1] : void 0;
|
|
564
|
-
}
|
|
565
|
-
function applyMetricsControls(raw, filter, maxLines) {
|
|
566
|
-
const lines = raw.split("\n");
|
|
567
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
568
|
-
let filtered;
|
|
569
|
-
if (filter && filter.length > 0) {
|
|
570
|
-
filtered = lines.filter((line) => {
|
|
571
|
-
if (line.trim() === "# EOF") return true;
|
|
572
|
-
const name = metricNameFromLine(line);
|
|
573
|
-
return name?.includes(filter) ?? false;
|
|
574
|
-
});
|
|
575
|
-
} else {
|
|
576
|
-
filtered = lines;
|
|
577
|
-
}
|
|
578
|
-
if (filtered.length <= maxLines) return filtered.join("\n");
|
|
579
|
-
const dropped = filtered.length - maxLines;
|
|
580
|
-
const kept = filtered.slice(0, maxLines);
|
|
581
|
-
kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
|
|
582
|
-
return kept.join("\n");
|
|
583
|
-
}
|
|
584
|
-
function findAcmeEmail(policies) {
|
|
585
|
-
if (!Array.isArray(policies) || policies.length === 0) return void 0;
|
|
586
|
-
const rawPolicy = policies[0];
|
|
587
|
-
if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
|
|
588
|
-
const policy = rawPolicy;
|
|
589
|
-
if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
|
|
590
|
-
const rawIssuer = policy.issuers[0];
|
|
591
|
-
if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
|
|
592
|
-
const issuer = rawIssuer;
|
|
593
|
-
return typeof issuer.email === "string" ? issuer.email : void 0;
|
|
594
|
-
}
|
|
595
|
-
function registerOperationalTools(server) {
|
|
596
|
-
server.tool(
|
|
597
|
-
"caddy_status",
|
|
598
|
-
"Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
|
|
599
|
-
{},
|
|
600
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
601
|
-
async () => {
|
|
602
|
-
const res = await configGet();
|
|
603
|
-
if (!res.ok) return formatResult(res);
|
|
604
|
-
const config = res.data ?? {};
|
|
605
|
-
const servers = config.apps?.http?.servers ?? {};
|
|
606
|
-
const serverNames = Object.keys(servers);
|
|
607
|
-
const lines = ["Caddy is running", ""];
|
|
608
|
-
if (serverNames.length === 0) {
|
|
609
|
-
lines.push("No HTTP servers configured");
|
|
610
|
-
} else {
|
|
611
|
-
for (const name of serverNames) {
|
|
612
|
-
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
616
|
-
if (email) lines.push(`
|
|
617
|
-
ACME email: ${email}`);
|
|
618
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
619
|
-
}
|
|
620
|
-
);
|
|
621
|
-
server.tool(
|
|
622
|
-
"caddy_list_servers",
|
|
623
|
-
"List all configured HTTP servers with their names, listen addresses, route counts, and TLS status. Use this to discover server names before calling route tools.",
|
|
624
|
-
{},
|
|
625
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
626
|
-
async () => {
|
|
627
|
-
const res = await configGet("apps/http/servers");
|
|
628
|
-
if (!res.ok) return formatResult(res);
|
|
629
|
-
const servers = res.data ?? {};
|
|
630
|
-
const names = Object.keys(servers);
|
|
631
|
-
if (names.length === 0) {
|
|
632
|
-
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
633
|
-
}
|
|
634
|
-
const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
|
|
635
|
-
return {
|
|
636
|
-
content: [{ type: "text", text: `HTTP Servers:
|
|
637
|
-
${lines.join("\n")}` }]
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
);
|
|
641
|
-
server.tool(
|
|
642
|
-
"caddy_upstreams",
|
|
643
|
-
"Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
|
|
644
|
-
{},
|
|
645
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
646
|
-
async () => formatResult(await getUpstreams())
|
|
647
|
-
);
|
|
648
|
-
server.tool(
|
|
649
|
-
"caddy_pki",
|
|
650
|
-
"Get PKI certificate authority info or the CA certificate chain.",
|
|
651
|
-
{
|
|
652
|
-
ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
653
|
-
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
654
|
-
},
|
|
655
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
656
|
-
async ({ ca, certificates }) => {
|
|
657
|
-
const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
|
|
658
|
-
return formatResult(res);
|
|
659
|
-
}
|
|
660
|
-
);
|
|
661
|
-
server.tool(
|
|
662
|
-
"caddy_metrics",
|
|
663
|
-
"Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more. Output can be megabytes on busy servers -- use `filter` to keep only metrics whose name contains a substring (e.g. 'http_requests' or 'tls'); HELP/TYPE comment lines for retained metrics are kept. Filter-mode drops blank lines and free-form '# comment' lines (including '# EOF'); only '# HELP'/'# TYPE' lines for matching metrics are kept. Use `max_lines` to cap the response (default 500); a trailing comment reports how many lines were dropped.",
|
|
664
|
-
{
|
|
665
|
-
filter: z3.string().optional().describe(
|
|
666
|
-
"Substring to match against metric names. Keeps sample lines whose metric name contains this substring, plus their `# HELP` and `# TYPE` comment lines. Empty/absent = no filtering. Label values are NOT matched -- use a Prometheus-aware client for label filtering."
|
|
667
|
-
),
|
|
668
|
-
max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
|
|
669
|
-
},
|
|
670
|
-
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
671
|
-
async ({ filter, max_lines }) => {
|
|
672
|
-
const res = await getMetrics();
|
|
673
|
-
if (!res.ok) return formatResult(res);
|
|
674
|
-
const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
|
|
675
|
-
const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
|
|
676
|
-
const text = applyMetricsControls(raw, filter, limit);
|
|
677
|
-
return { content: [{ type: "text", text: text || "OK" }] };
|
|
678
|
-
}
|
|
679
|
-
);
|
|
680
|
-
server.tool(
|
|
681
|
-
"caddy_stop",
|
|
682
|
-
"Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
|
|
683
|
-
{ confirm: z3.boolean().describe("Must be true to confirm shutdown") },
|
|
684
|
-
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
685
|
-
async ({ confirm }) => {
|
|
686
|
-
if (!confirm) {
|
|
687
|
-
return {
|
|
688
|
-
isError: true,
|
|
689
|
-
content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
return formatResult(await stop());
|
|
693
|
-
}
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
720
|
// src/tools/routes.ts
|
|
698
721
|
import { z as z4 } from "zod";
|
|
699
722
|
function safeJoin(value) {
|
|
700
723
|
if (!Array.isArray(value)) return "";
|
|
701
724
|
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
702
725
|
}
|
|
726
|
+
function stripPort(host) {
|
|
727
|
+
if (host.startsWith("[")) {
|
|
728
|
+
const closeIdx = host.indexOf("]");
|
|
729
|
+
if (closeIdx !== -1) return host.substring(0, closeIdx + 1);
|
|
730
|
+
return host;
|
|
731
|
+
}
|
|
732
|
+
const colonIdx = host.lastIndexOf(":");
|
|
733
|
+
if (colonIdx === -1) return host;
|
|
734
|
+
const portCandidate = host.substring(colonIdx + 1);
|
|
735
|
+
if (portCandidate.length === 0 || !/^\d+$/.test(portCandidate)) return host;
|
|
736
|
+
return host.substring(0, colonIdx);
|
|
737
|
+
}
|
|
703
738
|
function parseFrom(from) {
|
|
704
739
|
const cleaned = from.replace(/^https?:\/\//, "");
|
|
705
740
|
const match = {};
|
|
706
741
|
const slashIdx = cleaned.indexOf("/");
|
|
707
742
|
if (slashIdx > 0) {
|
|
708
|
-
match.host = [cleaned.substring(0, slashIdx)];
|
|
743
|
+
match.host = [stripPort(cleaned.substring(0, slashIdx))];
|
|
709
744
|
const path = cleaned.substring(slashIdx);
|
|
710
745
|
if (path !== "/") {
|
|
711
746
|
match.path = [path];
|
|
@@ -713,7 +748,7 @@ function parseFrom(from) {
|
|
|
713
748
|
} else if (cleaned.startsWith("/")) {
|
|
714
749
|
match.path = [cleaned];
|
|
715
750
|
} else {
|
|
716
|
-
match.host = [cleaned];
|
|
751
|
+
match.host = [stripPort(cleaned)];
|
|
717
752
|
}
|
|
718
753
|
return match;
|
|
719
754
|
}
|