@yawlabs/caddy-mcp 1.1.0 → 1.2.1

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
@@ -81,6 +81,7 @@ That's it. Now ask your AI assistant:
81
81
  | `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
82
82
  | `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
83
83
  | `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. Hard-capped at 5. Set to `0` to disable. |
84
+ | `CADDY_LOAD_TIMEOUT` | `60000` | Timeout in ms for the `/load` endpoint; raise for ACME-heavy bring-ups where provisioning many certificates can exceed the default. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
84
85
 
85
86
  **Alternate MCP clients:**
86
87
 
@@ -107,14 +108,14 @@ Use the same JSON block shown above in any of these.
107
108
 
108
109
  ### Route operations (4)
109
110
 
110
- - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`.
111
+ - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`. Pass an optional `id` for idempotent writes — repeat calls replace the route in place instead of duplicating.
111
112
  - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
112
113
  - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
113
114
  - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
114
115
 
115
116
  ### TLS & config conversion (2)
116
117
 
117
- - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. Falls back gracefully when paths don't yet exist.
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.
118
119
  - **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
119
120
 
120
121
  ### Server operations (6)
@@ -122,7 +123,7 @@ Use the same JSON block shown above in any of these.
122
123
  - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
123
124
  - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
124
125
  - **caddy_upstreams** — Reverse proxy backend health.
125
- - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes).
126
+ - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes). Optional `filter` (substring match on metric name, keeps `# HELP` / `# TYPE` lines for retained metrics) and `max_lines` (default 500) keep responses compact on busy servers.
126
127
  - **caddy_pki** — CA info and certificate chains (default CA: `local`).
127
128
  - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
128
129
 
@@ -144,6 +145,26 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
144
145
  → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
145
146
  ```
146
147
 
148
+ ### Idempotent reverse proxy (safe to re-run from automation)
149
+
150
+ ```
151
+ > "Make sure api.example.com points at localhost:3000, with a stable id"
152
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
153
+ # First call creates the route under @id="api-prod".
154
+ # Subsequent calls with the same id REPLACE in place — no duplicate routes.
155
+ # Refuses with a clear error if "api-prod" is already in use by a non-route
156
+ # config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
157
+ ```
158
+
159
+ ### Filter Prometheus metrics
160
+
161
+ ```
162
+ > "Just the HTTP request metrics, please"
163
+ → caddy_metrics({ filter: "http_requests" })
164
+ # Keeps sample lines whose metric name contains "http_requests",
165
+ # plus their `# HELP` / `# TYPE` lines. Drops the rest.
166
+ ```
167
+
147
168
  ### Preview a Caddyfile before applying it
148
169
 
149
170
  ```
@@ -213,7 +234,7 @@ npm install
213
234
  npm run lint # Biome check
214
235
  npm run lint:fix # Auto-fix
215
236
  npm run build # tsup bundle
216
- npm test # Vitest (106 unit tests; +7 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
237
+ npm test # Vitest (150 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
217
238
  npm run typecheck # tsc --noEmit
218
239
  ```
219
240
 
package/dist/index.js CHANGED
@@ -175,9 +175,17 @@ function configDelete(path) {
175
175
  if (bad) return Promise.resolve(bad);
176
176
  return caddyRequest("DELETE", `/config/${normalized}`);
177
177
  }
178
- var LOAD_TIMEOUT = 6e4;
178
+ function getLoadTimeout() {
179
+ const raw = process.env.CADDY_LOAD_TIMEOUT;
180
+ if (raw === void 0) return 6e4;
181
+ const n = Number(raw);
182
+ if (!Number.isFinite(n)) return 6e4;
183
+ const floored = Math.floor(n);
184
+ if (floored < 1) return 6e4;
185
+ return floored;
186
+ }
179
187
  async function loadConfig(config, contentType) {
180
- const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
188
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
181
189
  if (res.ok) etagCache.clear();
182
190
  return res;
183
191
  }
@@ -369,6 +377,9 @@ function getSnapshot(index) {
369
377
  }
370
378
 
371
379
  // src/tools/config.ts
380
+ function isSnapshotableConfig(data) {
381
+ return data !== null && typeof data === "object" && !Array.isArray(data);
382
+ }
372
383
  function registerConfigTools(server) {
373
384
  server.tool(
374
385
  "caddy_config_get",
@@ -411,7 +422,7 @@ function registerConfigTools(server) {
411
422
  async ({ config, format }) => {
412
423
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
413
424
  const current = await configGet();
414
- if (current.ok && current.data !== void 0) {
425
+ if (current.ok && isSnapshotableConfig(current.data)) {
415
426
  saveSnapshot(current.data, "caddy_load");
416
427
  }
417
428
  return formatResult(await loadConfig(config, contentType));
@@ -443,10 +454,15 @@ ${lines.join("\n")}` }] };
443
454
  if (action === "save") {
444
455
  const current2 = await configGet();
445
456
  if (!current2.ok) return formatResult(current2);
446
- if (current2.data === void 0) {
457
+ if (!isSnapshotableConfig(current2.data)) {
447
458
  return {
448
459
  isError: true,
449
- content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
460
+ content: [
461
+ {
462
+ type: "text",
463
+ text: "Error: cannot snapshot -- config response is empty or not a JSON object"
464
+ }
465
+ ]
450
466
  };
451
467
  }
452
468
  saveSnapshot(current2.data, "manual");
@@ -476,11 +492,11 @@ ${lines.join("\n")}` }] };
476
492
  };
477
493
  }
478
494
  const current = await configGet();
479
- if (current.ok && current.data !== void 0) {
480
- saveSnapshot(current.data, "caddy_revert");
481
- }
482
495
  const res = await loadConfig(snap.config, "application/json");
483
496
  if (!res.ok) return formatResult(res);
497
+ if (current.ok && isSnapshotableConfig(current.data)) {
498
+ saveSnapshot(current.data, "caddy_revert");
499
+ }
484
500
  const when = new Date(snap.timestamp).toISOString();
485
501
  return {
486
502
  content: [
@@ -552,6 +568,7 @@ function applyMetricsControls(raw, filter, maxLines) {
552
568
  let filtered;
553
569
  if (filter && filter.length > 0) {
554
570
  filtered = lines.filter((line) => {
571
+ if (line.trim() === "# EOF") return true;
555
572
  const name = metricNameFromLine(line);
556
573
  return name?.includes(filter) ?? false;
557
574
  });
@@ -565,18 +582,15 @@ function applyMetricsControls(raw, filter, maxLines) {
565
582
  return kept.join("\n");
566
583
  }
567
584
  function findAcmeEmail(policies) {
568
- if (!Array.isArray(policies)) return void 0;
569
- for (const rawPolicy of policies) {
570
- if (!rawPolicy || typeof rawPolicy !== "object") continue;
571
- const policy = rawPolicy;
572
- if (!Array.isArray(policy.issuers)) continue;
573
- for (const rawIssuer of policy.issuers) {
574
- if (!rawIssuer || typeof rawIssuer !== "object") continue;
575
- const issuer = rawIssuer;
576
- if (typeof issuer.email === "string") return issuer.email;
577
- }
578
- }
579
- return void 0;
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;
580
594
  }
581
595
  function registerOperationalTools(server) {
582
596
  server.tool(
@@ -649,7 +663,7 @@ ${lines.join("\n")}` }]
649
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.",
650
664
  {
651
665
  filter: z3.string().optional().describe(
652
- "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."
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."
653
667
  ),
654
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.")
655
669
  },
package/dist/server.js CHANGED
@@ -173,9 +173,17 @@ function configDelete(path) {
173
173
  if (bad) return Promise.resolve(bad);
174
174
  return caddyRequest("DELETE", `/config/${normalized}`);
175
175
  }
176
- var LOAD_TIMEOUT = 6e4;
176
+ function getLoadTimeout() {
177
+ const raw = process.env.CADDY_LOAD_TIMEOUT;
178
+ if (raw === void 0) return 6e4;
179
+ const n = Number(raw);
180
+ if (!Number.isFinite(n)) return 6e4;
181
+ const floored = Math.floor(n);
182
+ if (floored < 1) return 6e4;
183
+ return floored;
184
+ }
177
185
  async function loadConfig(config, contentType) {
178
- const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
186
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
179
187
  if (res.ok) etagCache.clear();
180
188
  return res;
181
189
  }
@@ -367,6 +375,9 @@ function getSnapshot(index) {
367
375
  }
368
376
 
369
377
  // src/tools/config.ts
378
+ function isSnapshotableConfig(data) {
379
+ return data !== null && typeof data === "object" && !Array.isArray(data);
380
+ }
370
381
  function registerConfigTools(server) {
371
382
  server.tool(
372
383
  "caddy_config_get",
@@ -409,7 +420,7 @@ function registerConfigTools(server) {
409
420
  async ({ config, format }) => {
410
421
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
411
422
  const current = await configGet();
412
- if (current.ok && current.data !== void 0) {
423
+ if (current.ok && isSnapshotableConfig(current.data)) {
413
424
  saveSnapshot(current.data, "caddy_load");
414
425
  }
415
426
  return formatResult(await loadConfig(config, contentType));
@@ -441,10 +452,15 @@ ${lines.join("\n")}` }] };
441
452
  if (action === "save") {
442
453
  const current2 = await configGet();
443
454
  if (!current2.ok) return formatResult(current2);
444
- if (current2.data === void 0) {
455
+ if (!isSnapshotableConfig(current2.data)) {
445
456
  return {
446
457
  isError: true,
447
- content: [{ type: "text", text: "Error: no config loaded to snapshot" }]
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: "Error: cannot snapshot -- config response is empty or not a JSON object"
462
+ }
463
+ ]
448
464
  };
449
465
  }
450
466
  saveSnapshot(current2.data, "manual");
@@ -474,11 +490,11 @@ ${lines.join("\n")}` }] };
474
490
  };
475
491
  }
476
492
  const current = await configGet();
477
- if (current.ok && current.data !== void 0) {
478
- saveSnapshot(current.data, "caddy_revert");
479
- }
480
493
  const res = await loadConfig(snap.config, "application/json");
481
494
  if (!res.ok) return formatResult(res);
495
+ if (current.ok && isSnapshotableConfig(current.data)) {
496
+ saveSnapshot(current.data, "caddy_revert");
497
+ }
482
498
  const when = new Date(snap.timestamp).toISOString();
483
499
  return {
484
500
  content: [
@@ -550,6 +566,7 @@ function applyMetricsControls(raw, filter, maxLines) {
550
566
  let filtered;
551
567
  if (filter && filter.length > 0) {
552
568
  filtered = lines.filter((line) => {
569
+ if (line.trim() === "# EOF") return true;
553
570
  const name = metricNameFromLine(line);
554
571
  return name?.includes(filter) ?? false;
555
572
  });
@@ -563,18 +580,15 @@ function applyMetricsControls(raw, filter, maxLines) {
563
580
  return kept.join("\n");
564
581
  }
565
582
  function findAcmeEmail(policies) {
566
- if (!Array.isArray(policies)) return void 0;
567
- for (const rawPolicy of policies) {
568
- if (!rawPolicy || typeof rawPolicy !== "object") continue;
569
- const policy = rawPolicy;
570
- if (!Array.isArray(policy.issuers)) continue;
571
- for (const rawIssuer of policy.issuers) {
572
- if (!rawIssuer || typeof rawIssuer !== "object") continue;
573
- const issuer = rawIssuer;
574
- if (typeof issuer.email === "string") return issuer.email;
575
- }
576
- }
577
- return void 0;
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;
578
592
  }
579
593
  function registerOperationalTools(server) {
580
594
  server.tool(
@@ -647,7 +661,7 @@ ${lines.join("\n")}` }]
647
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.",
648
662
  {
649
663
  filter: z3.string().optional().describe(
650
- "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."
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."
651
665
  ),
652
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.")
653
667
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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)",
@@ -39,9 +39,11 @@
39
39
  "zod": "^4.3.6"
40
40
  },
41
41
  "overrides": {
42
- "hono": "^4.12.14",
42
+ "hono": "^4.12.18",
43
43
  "@hono/node-server": "^1.19.13",
44
- "postcss": "^8.5.10"
44
+ "postcss": "^8.5.10",
45
+ "ip-address": "^10.1.1",
46
+ "fast-uri": "^3.1.2"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@biomejs/biome": "^2.4.11",