@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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Built and maintained by [Yaw Labs](https://yaw.sh).
10
10
 
11
- [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&env=CADDY_ADMIN_URL%2CCADDY_API_TOKEN&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
11
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](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
- { description: "Prometheus metrics (text exposition format)" },
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: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
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: z.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
336
- adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
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 z2 } from "zod";
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: z2.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
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: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
398
- value: z2.any().describe("The JSON value to set at the path"),
399
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
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: z2.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
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: z2.union([z2.record(z2.string(), z2.any()), z2.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
421
- format: z2.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
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
- if (current.ok && isSnapshotableConfig(current.data)) {
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(await loadConfig(config, contentType));
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: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
438
- index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
439
- confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
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: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
515
- action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
516
- value: z2.any().optional().describe("New value (required for 'set' action)"),
517
- subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
518
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
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
- { description: "Prometheus metrics (text exposition format)" },
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: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
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: z.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
334
- adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
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 z2 } from "zod";
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: z2.string().optional().default("").describe("Config path (e.g., 'apps/http/servers/srv0')") },
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: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
396
- value: z2.any().describe("The JSON value to set at the path"),
397
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
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: z2.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
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: z2.union([z2.record(z2.string(), z2.any()), z2.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
419
- format: z2.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'")
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
- if (current.ok && isSnapshotableConfig(current.data)) {
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(await loadConfig(config, contentType));
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: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
436
- index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
437
- confirm: z2.boolean().optional().default(false).describe("Must be true to actually apply a snapshot (safety)")
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: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
513
- action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
514
- value: z2.any().optional().describe("New value (required for 'set' action)"),
515
- subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
516
- mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "MCP server for managing Caddy web servers via the admin API",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",