@yawlabs/caddy-mcp 1.2.1 → 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
 
@@ -116,7 +116,7 @@ Use the same JSON block shown above in any of these.
116
116
  ### TLS & config conversion (2)
117
117
 
118
118
  - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. PATCH first; on a fresh install, POSTs a minimal config. On an existing config it deep-merges into the issuer path and PUTs the result back, preserving siblings (custom certs, `on_demand`, additional policies). Refuses with a shape-specific error if the existing structure is unexpected — never clobbers.
119
- - **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
119
+ - **caddy_adapt** — Convert a config in any registered adapter format to Caddy JSON without applying it. `caddyfile` (built-in, default) plus any adapter module compiled into your Caddy binary — e.g., `nginx` ([caddy-nginx-adapter](https://github.com/caddyserver/nginx-adapter)), `yaml` ([caddy-yaml](https://github.com/abiosoft/caddy-yaml)). Great for previewing or porting from existing configs.
120
120
 
121
121
  ### Server operations (6)
122
122
 
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;
@@ -330,10 +506,12 @@ function formatWarning(w) {
330
506
  function registerAdaptTools(server) {
331
507
  server.tool(
332
508
  "caddy_adapt",
333
- "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
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)"),
336
- adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
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(
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)."
514
+ )
337
515
  },
338
516
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
339
517
  async ({ config, adapter }) => {
@@ -358,7 +536,7 @@ ${warnLines.join("\n")}` });
358
536
  }
359
537
 
360
538
  // src/tools/config.ts
361
- import { z as z2 } from "zod";
539
+ import { z as z3 } from "zod";
362
540
 
363
541
  // src/snapshots.ts
364
542
  var MAX_SNAPSHOTS = 10;
@@ -384,7 +562,7 @@ function registerConfigTools(server) {
384
562
  server.tool(
385
563
  "caddy_config_get",
386
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').",
387
- { 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')") },
388
566
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
389
567
  async ({ path }) => formatResult(await configGet(path))
390
568
  );
@@ -392,9 +570,9 @@ function registerConfigTools(server) {
392
570
  "caddy_config_set",
393
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.",
394
572
  {
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(
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(
398
576
  "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
399
577
  )
400
578
  },
@@ -407,7 +585,7 @@ function registerConfigTools(server) {
407
585
  server.tool(
408
586
  "caddy_config_delete",
409
587
  "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')") },
588
+ { path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')") },
411
589
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
412
590
  async ({ path }) => formatResult(await configDelete(path))
413
591
  );
@@ -415,26 +593,27 @@ function registerConfigTools(server) {
415
593
  "caddy_load",
416
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.",
417
595
  {
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'")
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'")
420
598
  },
421
599
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
422
600
  async ({ config, format }) => {
423
601
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
424
602
  const current = await configGet();
425
- if (current.ok && isSnapshotableConfig(current.data)) {
603
+ const res = await loadConfig(config, contentType);
604
+ if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
426
605
  saveSnapshot(current.data, "caddy_load");
427
606
  }
428
- return formatResult(await loadConfig(config, contentType));
607
+ return formatResult(res);
429
608
  }
430
609
  );
431
610
  server.tool(
432
611
  "caddy_revert",
433
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).",
434
613
  {
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)")
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)")
438
617
  },
439
618
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
440
619
  async ({ action, index, confirm }) => {
@@ -509,11 +688,11 @@ ${lines.join("\n")}` }] };
509
688
  "caddy_config_by_id",
510
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.",
511
690
  {
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(
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(
517
696
  "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
518
697
  )
519
698
  },
@@ -540,172 +719,30 @@ ${lines.join("\n")}` }] };
540
719
  );
541
720
  }
542
721
 
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
722
  // src/tools/routes.ts
698
723
  import { z as z4 } from "zod";
699
724
  function safeJoin(value) {
700
725
  if (!Array.isArray(value)) return "";
701
726
  return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
702
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
+ }
703
740
  function parseFrom(from) {
704
741
  const cleaned = from.replace(/^https?:\/\//, "");
705
742
  const match = {};
706
743
  const slashIdx = cleaned.indexOf("/");
707
744
  if (slashIdx > 0) {
708
- match.host = [cleaned.substring(0, slashIdx)];
745
+ match.host = [stripPort(cleaned.substring(0, slashIdx))];
709
746
  const path = cleaned.substring(slashIdx);
710
747
  if (path !== "/") {
711
748
  match.path = [path];
@@ -713,7 +750,7 @@ function parseFrom(from) {
713
750
  } else if (cleaned.startsWith("/")) {
714
751
  match.path = [cleaned];
715
752
  } else {
716
- match.host = [cleaned];
753
+ match.host = [stripPort(cleaned)];
717
754
  }
718
755
  return match;
719
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;
@@ -328,10 +504,12 @@ function formatWarning(w) {
328
504
  function registerAdaptTools(server) {
329
505
  server.tool(
330
506
  "caddy_adapt",
331
- "Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
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)"),
334
- adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
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(
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)."
512
+ )
335
513
  },
336
514
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
337
515
  async ({ config, adapter }) => {
@@ -356,7 +534,7 @@ ${warnLines.join("\n")}` });
356
534
  }
357
535
 
358
536
  // src/tools/config.ts
359
- import { z as z2 } from "zod";
537
+ import { z as z3 } from "zod";
360
538
 
361
539
  // src/snapshots.ts
362
540
  var MAX_SNAPSHOTS = 10;
@@ -382,7 +560,7 @@ function registerConfigTools(server) {
382
560
  server.tool(
383
561
  "caddy_config_get",
384
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').",
385
- { 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')") },
386
564
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
387
565
  async ({ path }) => formatResult(await configGet(path))
388
566
  );
@@ -390,9 +568,9 @@ function registerConfigTools(server) {
390
568
  "caddy_config_set",
391
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.",
392
570
  {
393
- path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
394
- value: z2.any().describe("The JSON value to set at the path"),
395
- 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(
396
574
  "'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
397
575
  )
398
576
  },
@@ -405,7 +583,7 @@ function registerConfigTools(server) {
405
583
  server.tool(
406
584
  "caddy_config_delete",
407
585
  "Delete config at a JSON path. Removes the config node at the specified path.",
408
- { 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')") },
409
587
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
410
588
  async ({ path }) => formatResult(await configDelete(path))
411
589
  );
@@ -413,26 +591,27 @@ function registerConfigTools(server) {
413
591
  "caddy_load",
414
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.",
415
593
  {
416
- config: z2.union([z2.record(z2.string(), z2.any()), z2.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
417
- 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'")
418
596
  },
419
597
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
420
598
  async ({ config, format }) => {
421
599
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
422
600
  const current = await configGet();
423
- if (current.ok && isSnapshotableConfig(current.data)) {
601
+ const res = await loadConfig(config, contentType);
602
+ if (res.ok && current.ok && isSnapshotableConfig(current.data)) {
424
603
  saveSnapshot(current.data, "caddy_load");
425
604
  }
426
- return formatResult(await loadConfig(config, contentType));
605
+ return formatResult(res);
427
606
  }
428
607
  );
429
608
  server.tool(
430
609
  "caddy_revert",
431
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).",
432
611
  {
433
- action: z2.enum(["list", "save", "apply"]).describe("Action to perform"),
434
- index: z2.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
435
- 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)")
436
615
  },
437
616
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
438
617
  async ({ action, index, confirm }) => {
@@ -507,11 +686,11 @@ ${lines.join("\n")}` }] };
507
686
  "caddy_config_by_id",
508
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.",
509
688
  {
510
- id: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
511
- action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
512
- value: z2.any().optional().describe("New value (required for 'set' action)"),
513
- subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
514
- 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(
515
694
  "For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
516
695
  )
517
696
  },
@@ -538,172 +717,30 @@ ${lines.join("\n")}` }] };
538
717
  );
539
718
  }
540
719
 
541
- // src/tools/operational.ts
542
- import { z as z3 } from "zod";
543
- function describeServer(raw) {
544
- const listen = Array.isArray(raw.listen) ? raw.listen : [];
545
- const routes = Array.isArray(raw.routes) ? raw.routes : [];
546
- const hasExplicitTls = !!raw.tls_connection_policies;
547
- const listensHttps = listen.some((l) => typeof l === "string" && l.includes(":443"));
548
- const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
549
- const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
550
- return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
551
- }
552
- var METRICS_DEFAULT_MAX_LINES = 500;
553
- function metricNameFromLine(line) {
554
- const trimmed = line.trimStart();
555
- if (trimmed === "") return void 0;
556
- if (trimmed.startsWith("#")) {
557
- const m2 = trimmed.match(/^#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/);
558
- return m2 ? m2[1] : void 0;
559
- }
560
- const m = trimmed.match(/^([A-Za-z_:][A-Za-z0-9_:]*)/);
561
- return m ? m[1] : void 0;
562
- }
563
- function applyMetricsControls(raw, filter, maxLines) {
564
- const lines = raw.split("\n");
565
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
566
- let filtered;
567
- if (filter && filter.length > 0) {
568
- filtered = lines.filter((line) => {
569
- if (line.trim() === "# EOF") return true;
570
- const name = metricNameFromLine(line);
571
- return name?.includes(filter) ?? false;
572
- });
573
- } else {
574
- filtered = lines;
575
- }
576
- if (filtered.length <= maxLines) return filtered.join("\n");
577
- const dropped = filtered.length - maxLines;
578
- const kept = filtered.slice(0, maxLines);
579
- kept.push(`# [truncated, ${dropped} lines omitted -- use filter to narrow]`);
580
- return kept.join("\n");
581
- }
582
- function findAcmeEmail(policies) {
583
- if (!Array.isArray(policies) || policies.length === 0) return void 0;
584
- const rawPolicy = policies[0];
585
- if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return void 0;
586
- const policy = rawPolicy;
587
- if (!Array.isArray(policy.issuers) || policy.issuers.length === 0) return void 0;
588
- const rawIssuer = policy.issuers[0];
589
- if (!rawIssuer || typeof rawIssuer !== "object" || Array.isArray(rawIssuer)) return void 0;
590
- const issuer = rawIssuer;
591
- return typeof issuer.email === "string" ? issuer.email : void 0;
592
- }
593
- function registerOperationalTools(server) {
594
- server.tool(
595
- "caddy_status",
596
- "Check Caddy connectivity and get a config summary: servers, routes, listen addresses, and TLS status.",
597
- {},
598
- { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
599
- async () => {
600
- const res = await configGet();
601
- if (!res.ok) return formatResult(res);
602
- const config = res.data ?? {};
603
- const servers = config.apps?.http?.servers ?? {};
604
- const serverNames = Object.keys(servers);
605
- const lines = ["Caddy is running", ""];
606
- if (serverNames.length === 0) {
607
- lines.push("No HTTP servers configured");
608
- } else {
609
- for (const name of serverNames) {
610
- lines.push(`Server "${name}": ${describeServer(servers[name])}`);
611
- }
612
- }
613
- const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
614
- if (email) lines.push(`
615
- ACME email: ${email}`);
616
- return { content: [{ type: "text", text: lines.join("\n") }] };
617
- }
618
- );
619
- server.tool(
620
- "caddy_list_servers",
621
- "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.",
622
- {},
623
- { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
624
- async () => {
625
- const res = await configGet("apps/http/servers");
626
- if (!res.ok) return formatResult(res);
627
- const servers = res.data ?? {};
628
- const names = Object.keys(servers);
629
- if (names.length === 0) {
630
- return { content: [{ type: "text", text: "No HTTP servers configured" }] };
631
- }
632
- const lines = names.map((name) => ` ${name}: ${describeServer(servers[name])}`);
633
- return {
634
- content: [{ type: "text", text: `HTTP Servers:
635
- ${lines.join("\n")}` }]
636
- };
637
- }
638
- );
639
- server.tool(
640
- "caddy_upstreams",
641
- "Get the current health status of all reverse proxy upstreams. Shows address, active requests, and failure counts.",
642
- {},
643
- { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
644
- async () => formatResult(await getUpstreams())
645
- );
646
- server.tool(
647
- "caddy_pki",
648
- "Get PKI certificate authority info or the CA certificate chain.",
649
- {
650
- ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
651
- certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
652
- },
653
- { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
654
- async ({ ca, certificates }) => {
655
- const res = certificates ? await getPkiCertificates(ca) : await getPki(ca);
656
- return formatResult(res);
657
- }
658
- );
659
- server.tool(
660
- "caddy_metrics",
661
- "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.",
662
- {
663
- filter: z3.string().optional().describe(
664
- "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."
665
- ),
666
- max_lines: z3.number().int().positive().optional().describe("Maximum number of output lines (default 500). Excess lines are dropped and a summary is appended.")
667
- },
668
- { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
669
- async ({ filter, max_lines }) => {
670
- const res = await getMetrics();
671
- if (!res.ok) return formatResult(res);
672
- const raw = typeof res.data === "string" ? res.data : res.data !== void 0 ? String(res.data) : "";
673
- const limit = max_lines ?? METRICS_DEFAULT_MAX_LINES;
674
- const text = applyMetricsControls(raw, filter, limit);
675
- return { content: [{ type: "text", text: text || "OK" }] };
676
- }
677
- );
678
- server.tool(
679
- "caddy_stop",
680
- "Gracefully shut down the Caddy server. Requires confirm=true to prevent accidental shutdown.",
681
- { confirm: z3.boolean().describe("Must be true to confirm shutdown") },
682
- { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
683
- async ({ confirm }) => {
684
- if (!confirm) {
685
- return {
686
- isError: true,
687
- content: [{ type: "text", text: "Error: confirm must be true to shut down Caddy" }]
688
- };
689
- }
690
- return formatResult(await stop());
691
- }
692
- );
693
- }
694
-
695
720
  // src/tools/routes.ts
696
721
  import { z as z4 } from "zod";
697
722
  function safeJoin(value) {
698
723
  if (!Array.isArray(value)) return "";
699
724
  return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
700
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
+ }
701
738
  function parseFrom(from) {
702
739
  const cleaned = from.replace(/^https?:\/\//, "");
703
740
  const match = {};
704
741
  const slashIdx = cleaned.indexOf("/");
705
742
  if (slashIdx > 0) {
706
- match.host = [cleaned.substring(0, slashIdx)];
743
+ match.host = [stripPort(cleaned.substring(0, slashIdx))];
707
744
  const path = cleaned.substring(slashIdx);
708
745
  if (path !== "/") {
709
746
  match.path = [path];
@@ -711,7 +748,7 @@ function parseFrom(from) {
711
748
  } else if (cleaned.startsWith("/")) {
712
749
  match.path = [cleaned];
713
750
  } else {
714
- match.host = [cleaned];
751
+ match.host = [stripPort(cleaned)];
715
752
  }
716
753
  return match;
717
754
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.2.1",
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)",