@yawlabs/caddy-mcp 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,6 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@yawlabs/caddy-mcp)](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/caddy-mcp)](https://github.com/YawLabs/caddy-mcp/stargazers)
6
- [![CI](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
7
6
 
8
7
  **Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
9
8
 
@@ -108,14 +107,14 @@ Use the same JSON block shown above in any of these.
108
107
 
109
108
  ### Route operations (4)
110
109
 
111
- - **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`.
110
+ - **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.
112
111
  - **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
113
112
  - **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
114
113
  - **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
115
114
 
116
115
  ### TLS & config conversion (2)
117
116
 
118
- - **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. Falls back gracefully when paths don't yet exist.
117
+ - **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
118
  - **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
120
119
 
121
120
  ### Server operations (6)
@@ -123,7 +122,7 @@ Use the same JSON block shown above in any of these.
123
122
  - **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
124
123
  - **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
125
124
  - **caddy_upstreams** — Reverse proxy backend health.
126
- - **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes).
125
+ - **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.
127
126
  - **caddy_pki** — CA info and certificate chains (default CA: `local`).
128
127
  - **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
129
128
 
@@ -145,6 +144,26 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
145
144
  → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
146
145
  ```
147
146
 
147
+ ### Idempotent reverse proxy (safe to re-run from automation)
148
+
149
+ ```
150
+ > "Make sure api.example.com points at localhost:3000, with a stable id"
151
+ → caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"], id: "api-prod" })
152
+ # First call creates the route under @id="api-prod".
153
+ # Subsequent calls with the same id REPLACE in place — no duplicate routes.
154
+ # Refuses with a clear error if "api-prod" is already in use by a non-route
155
+ # config object (TLS issuer, server, etc.) — @ids are config-global in Caddy.
156
+ ```
157
+
158
+ ### Filter Prometheus metrics
159
+
160
+ ```
161
+ > "Just the HTTP request metrics, please"
162
+ → caddy_metrics({ filter: "http_requests" })
163
+ # Keeps sample lines whose metric name contains "http_requests",
164
+ # plus their `# HELP` / `# TYPE` lines. Drops the rest.
165
+ ```
166
+
148
167
  ### Preview a Caddyfile before applying it
149
168
 
150
169
  ```
@@ -214,7 +233,7 @@ npm install
214
233
  npm run lint # Biome check
215
234
  npm run lint:fix # Auto-fix
216
235
  npm run build # tsup bundle
217
- npm test # Vitest (106 unit tests; +7 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
236
+ npm test # Vitest (150 unit tests; +8 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
218
237
  npm run typecheck # tsc --noEmit
219
238
  ```
220
239
 
package/dist/index.js CHANGED
@@ -97,7 +97,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
97
97
  setEtag(path, etag);
98
98
  }
99
99
  if (isWrite && res.ok && isConfigPath) {
100
- etagCache.delete(path);
100
+ if (method === "PATCH" || method === "PUT") {
101
+ if (etag) {
102
+ setEtag(path, etag);
103
+ } else {
104
+ etagCache.delete(path);
105
+ }
106
+ } else {
107
+ etagCache.delete(path);
108
+ }
101
109
  }
102
110
  if (!res.ok) {
103
111
  if (res.status === 412) {
@@ -167,9 +175,17 @@ function configDelete(path) {
167
175
  if (bad) return Promise.resolve(bad);
168
176
  return caddyRequest("DELETE", `/config/${normalized}`);
169
177
  }
170
- 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
+ }
171
187
  async function loadConfig(config, contentType) {
172
- const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
188
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
173
189
  if (res.ok) etagCache.clear();
174
190
  return res;
175
191
  }
@@ -183,24 +199,34 @@ function getUpstreams() {
183
199
  return caddyRequest("GET", "/reverse_proxy/upstreams");
184
200
  }
185
201
  function getPki(ca = "local") {
202
+ const bad = rejectTraversal(ca);
203
+ if (bad) return Promise.resolve(bad);
186
204
  return caddyRequest("GET", `/pki/ca/${ca}`);
187
205
  }
188
206
  function getPkiCertificates(ca = "local") {
207
+ const bad = rejectTraversal(ca);
208
+ if (bad) return Promise.resolve(bad);
189
209
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
190
210
  }
191
211
  function configByIdGet(id, subpath = "") {
212
+ const badId = rejectTraversal(id);
213
+ if (badId) return Promise.resolve(badId);
192
214
  const bad = rejectTraversal(subpath);
193
215
  if (bad) return Promise.resolve(bad);
194
216
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
195
217
  return caddyRequest("GET", path);
196
218
  }
197
219
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
220
+ const badId = rejectTraversal(id);
221
+ if (badId) return Promise.resolve(badId);
198
222
  const bad = rejectTraversal(subpath);
199
223
  if (bad) return Promise.resolve(bad);
200
224
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
201
225
  return caddyRequest(method, path, value);
202
226
  }
203
227
  function configByIdDelete(id, subpath = "") {
228
+ const badId = rejectTraversal(id);
229
+ if (badId) return Promise.resolve(badId);
204
230
  const bad = rejectTraversal(subpath);
205
231
  if (bad) return Promise.resolve(bad);
206
232
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
@@ -351,6 +377,9 @@ function getSnapshot(index) {
351
377
  }
352
378
 
353
379
  // src/tools/config.ts
380
+ function isSnapshotableConfig(data) {
381
+ return data !== null && typeof data === "object" && !Array.isArray(data);
382
+ }
354
383
  function registerConfigTools(server) {
355
384
  server.tool(
356
385
  "caddy_config_get",
@@ -393,7 +422,7 @@ function registerConfigTools(server) {
393
422
  async ({ config, format }) => {
394
423
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
395
424
  const current = await configGet();
396
- if (current.ok && current.data !== void 0) {
425
+ if (current.ok && isSnapshotableConfig(current.data)) {
397
426
  saveSnapshot(current.data, "caddy_load");
398
427
  }
399
428
  return formatResult(await loadConfig(config, contentType));
@@ -425,10 +454,15 @@ ${lines.join("\n")}` }] };
425
454
  if (action === "save") {
426
455
  const current2 = await configGet();
427
456
  if (!current2.ok) return formatResult(current2);
428
- if (current2.data === void 0) {
457
+ if (!isSnapshotableConfig(current2.data)) {
429
458
  return {
430
459
  isError: true,
431
- 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
+ ]
432
466
  };
433
467
  }
434
468
  saveSnapshot(current2.data, "manual");
@@ -458,11 +492,11 @@ ${lines.join("\n")}` }] };
458
492
  };
459
493
  }
460
494
  const current = await configGet();
461
- if (current.ok && current.data !== void 0) {
462
- saveSnapshot(current.data, "caddy_revert");
463
- }
464
495
  const res = await loadConfig(snap.config, "application/json");
465
496
  if (!res.ok) return formatResult(res);
497
+ if (current.ok && isSnapshotableConfig(current.data)) {
498
+ saveSnapshot(current.data, "caddy_revert");
499
+ }
466
500
  const when = new Date(snap.timestamp).toISOString();
467
501
  return {
468
502
  content: [
@@ -517,19 +551,46 @@ function describeServer(raw) {
517
551
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
518
552
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
519
553
  }
520
- function findAcmeEmail(policies) {
521
- if (!Array.isArray(policies)) return void 0;
522
- for (const rawPolicy of policies) {
523
- if (!rawPolicy || typeof rawPolicy !== "object") continue;
524
- const policy = rawPolicy;
525
- if (!Array.isArray(policy.issuers)) continue;
526
- for (const rawIssuer of policy.issuers) {
527
- if (!rawIssuer || typeof rawIssuer !== "object") continue;
528
- const issuer = rawIssuer;
529
- if (typeof issuer.email === "string") return issuer.email;
530
- }
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;
531
577
  }
532
- return void 0;
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;
533
594
  }
534
595
  function registerOperationalTools(server) {
535
596
  server.tool(
@@ -599,10 +660,22 @@ ${lines.join("\n")}` }]
599
660
  );
600
661
  server.tool(
601
662
  "caddy_metrics",
602
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
603
- {},
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
+ },
604
670
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
605
- async () => formatResult(await getMetrics())
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
+ }
606
679
  );
607
680
  server.tool(
608
681
  "caddy_stop",
@@ -633,7 +706,10 @@ function parseFrom(from) {
633
706
  const slashIdx = cleaned.indexOf("/");
634
707
  if (slashIdx > 0) {
635
708
  match.host = [cleaned.substring(0, slashIdx)];
636
- match.path = [cleaned.substring(slashIdx)];
709
+ const path = cleaned.substring(slashIdx);
710
+ if (path !== "/") {
711
+ match.path = [path];
712
+ }
637
713
  } else if (cleaned.startsWith("/")) {
638
714
  match.path = [cleaned];
639
715
  } else {
@@ -644,6 +720,21 @@ function parseFrom(from) {
644
720
  function cleanUpstreamAddr(addr) {
645
721
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
646
722
  }
723
+ function isParentMissing(res) {
724
+ if (res.ok) return false;
725
+ if (res.status === 404) return true;
726
+ return res.error?.includes("key does not exist") ?? false;
727
+ }
728
+ function isUnknownId(res) {
729
+ if (res.ok) return false;
730
+ if (res.status === 404) return true;
731
+ const body = (res.error ?? "").toLowerCase();
732
+ return body.includes("unknown object id") || body.includes("no id found");
733
+ }
734
+ function isRouteShape(obj) {
735
+ if (!obj || typeof obj !== "object") return false;
736
+ return Array.isArray(obj.handle);
737
+ }
647
738
  function serverNotFoundError(srv) {
648
739
  return {
649
740
  isError: true,
@@ -658,14 +749,17 @@ function serverNotFoundError(srv) {
658
749
  function registerRouteTools(server) {
659
750
  server.tool(
660
751
  "caddy_reverse_proxy",
661
- "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000'].",
752
+ "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
662
753
  {
663
754
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
664
755
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
665
- server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
756
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
757
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
758
+ "Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
759
+ )
666
760
  },
667
761
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
668
- async ({ from, to, server: srv }) => {
762
+ async ({ from, to, server: srv, id }) => {
669
763
  const match = parseFrom(from);
670
764
  const cleanedTo = to.map(cleanUpstreamAddr);
671
765
  const route = {
@@ -678,11 +772,58 @@ function registerRouteTools(server) {
678
772
  ],
679
773
  terminal: true
680
774
  };
775
+ if (id) {
776
+ route["@id"] = id;
777
+ const existing = await configByIdGet(id);
778
+ if (existing.ok) {
779
+ if (!isRouteShape(existing.data)) {
780
+ return {
781
+ isError: true,
782
+ content: [
783
+ {
784
+ type: "text",
785
+ text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
786
+ }
787
+ ]
788
+ };
789
+ }
790
+ const putRes = await configByIdSet(id, route, "PUT");
791
+ if (putRes.ok) {
792
+ return {
793
+ content: [
794
+ {
795
+ type: "text",
796
+ text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
797
+ }
798
+ ]
799
+ };
800
+ }
801
+ return formatResult(putRes);
802
+ }
803
+ if (!isUnknownId(existing)) {
804
+ return formatResult(existing);
805
+ }
806
+ const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
807
+ if (postRes.ok) {
808
+ return {
809
+ content: [
810
+ {
811
+ type: "text",
812
+ text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
813
+ }
814
+ ]
815
+ };
816
+ }
817
+ if (isParentMissing(postRes)) {
818
+ return serverNotFoundError(srv);
819
+ }
820
+ return formatResult(postRes);
821
+ }
681
822
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
682
823
  if (res.ok) {
683
824
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
684
825
  }
685
- if (!res.ok && res.error?.includes("key does not exist")) {
826
+ if (isParentMissing(res)) {
686
827
  return serverNotFoundError(srv);
687
828
  }
688
829
  return formatResult(res);
@@ -701,7 +842,7 @@ function registerRouteTools(server) {
701
842
  async ({ match, handle, server: srv, terminal }) => {
702
843
  const route = { match, handle, terminal };
703
844
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
704
- if (!res.ok && res.error?.includes("key does not exist")) {
845
+ if (isParentMissing(res)) {
705
846
  return serverNotFoundError(srv);
706
847
  }
707
848
  return formatResult(res);
@@ -914,9 +1055,9 @@ function buildTlsConfig(fields) {
914
1055
  }
915
1056
  };
916
1057
  }
917
- function bothErrors(label, patchRes, postRes) {
1058
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
918
1059
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
919
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1060
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
920
1061
  return {
921
1062
  isError: true,
922
1063
  content: [
@@ -924,11 +1065,97 @@ function bothErrors(label, patchRes, postRes) {
924
1065
  type: "text",
925
1066
  text: `Error: Failed to set ${label}.
926
1067
  PATCH attempt: ${patchErr}
927
- POST fallback: ${postErr}`
1068
+ ${writeLabel} fallback: ${writeErr}`
928
1069
  }
929
1070
  ]
930
1071
  };
931
1072
  }
1073
+ function isPlainObject(v) {
1074
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1075
+ }
1076
+ function deepClone(v) {
1077
+ return JSON.parse(JSON.stringify(v));
1078
+ }
1079
+ function mergeIssuerFields(existing, fields) {
1080
+ const merged = deepClone(existing);
1081
+ const automation = merged.automation;
1082
+ const issuer = automation.policies[0].issuers[0];
1083
+ if (fields.email !== void 0) issuer.email = fields.email;
1084
+ if (fields.ca !== void 0) issuer.ca = fields.ca;
1085
+ return merged;
1086
+ }
1087
+ function validateIssuerShape(tls) {
1088
+ const automation = tls.automation;
1089
+ if (!isPlainObject(automation)) {
1090
+ return "apps/tls.automation is missing or not an object";
1091
+ }
1092
+ const policies = automation.policies;
1093
+ if (!Array.isArray(policies) || policies.length === 0) {
1094
+ return "apps/tls.automation.policies is missing, not an array, or empty";
1095
+ }
1096
+ const policy0 = policies[0];
1097
+ if (!isPlainObject(policy0)) {
1098
+ return "apps/tls.automation.policies[0] is not an object";
1099
+ }
1100
+ const issuers = policy0.issuers;
1101
+ if (!Array.isArray(issuers) || issuers.length === 0) {
1102
+ return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
1103
+ }
1104
+ const issuer0 = issuers[0];
1105
+ if (!isPlainObject(issuer0)) {
1106
+ return "apps/tls.automation.policies[0].issuers[0] is not an object";
1107
+ }
1108
+ return null;
1109
+ }
1110
+ async function safeFallback(label, patchRes, fields) {
1111
+ const getRes = await configGet("apps/tls");
1112
+ const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1113
+ if (absent) {
1114
+ const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1115
+ if (postRes.ok) return { kind: "ok" };
1116
+ return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1117
+ }
1118
+ if (!getRes.ok) {
1119
+ return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1120
+ }
1121
+ if (!isPlainObject(getRes.data)) {
1122
+ return {
1123
+ kind: "tool-error",
1124
+ result: {
1125
+ isError: true,
1126
+ content: [
1127
+ {
1128
+ type: "text",
1129
+ text: `Error: Failed to set ${label}.
1130
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1131
+ Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1132
+ }
1133
+ ]
1134
+ }
1135
+ };
1136
+ }
1137
+ const shapeError = validateIssuerShape(getRes.data);
1138
+ if (shapeError) {
1139
+ return {
1140
+ kind: "tool-error",
1141
+ result: {
1142
+ isError: true,
1143
+ content: [
1144
+ {
1145
+ type: "text",
1146
+ text: `Error: Failed to set ${label}.
1147
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1148
+ Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
1149
+ }
1150
+ ]
1151
+ }
1152
+ };
1153
+ }
1154
+ const merged = mergeIssuerFields(getRes.data, fields);
1155
+ const putRes = await configPut("apps/tls", merged);
1156
+ if (putRes.ok) return { kind: "ok" };
1157
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1158
+ }
932
1159
  function registerTlsTools(server) {
933
1160
  server.tool(
934
1161
  "caddy_tls",
@@ -951,9 +1178,9 @@ function registerTlsTools(server) {
951
1178
  };
952
1179
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
953
1180
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
954
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
955
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
956
- return bothErrors("ACME email", patchRes, postRes);
1181
+ const outcome = await safeFallback("ACME email", patchRes, { email });
1182
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1183
+ return outcome.result;
957
1184
  }
958
1185
  if (action === "set_acme_ca") {
959
1186
  if (!ca)
@@ -963,9 +1190,9 @@ function registerTlsTools(server) {
963
1190
  };
964
1191
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
965
1192
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
966
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
967
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
968
- return bothErrors("ACME CA", patchRes, postRes);
1193
+ const outcome = await safeFallback("ACME CA", patchRes, { ca });
1194
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1195
+ return outcome.result;
969
1196
  }
970
1197
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
971
1198
  }
package/dist/server.js CHANGED
@@ -95,7 +95,15 @@ async function attemptRequest(method, path, body, contentType, timeout) {
95
95
  setEtag(path, etag);
96
96
  }
97
97
  if (isWrite && res.ok && isConfigPath) {
98
- etagCache.delete(path);
98
+ if (method === "PATCH" || method === "PUT") {
99
+ if (etag) {
100
+ setEtag(path, etag);
101
+ } else {
102
+ etagCache.delete(path);
103
+ }
104
+ } else {
105
+ etagCache.delete(path);
106
+ }
99
107
  }
100
108
  if (!res.ok) {
101
109
  if (res.status === 412) {
@@ -165,9 +173,17 @@ function configDelete(path) {
165
173
  if (bad) return Promise.resolve(bad);
166
174
  return caddyRequest("DELETE", `/config/${normalized}`);
167
175
  }
168
- 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
+ }
169
185
  async function loadConfig(config, contentType) {
170
- const res = await caddyRequest("POST", "/load", config, contentType, LOAD_TIMEOUT);
186
+ const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
171
187
  if (res.ok) etagCache.clear();
172
188
  return res;
173
189
  }
@@ -181,24 +197,34 @@ function getUpstreams() {
181
197
  return caddyRequest("GET", "/reverse_proxy/upstreams");
182
198
  }
183
199
  function getPki(ca = "local") {
200
+ const bad = rejectTraversal(ca);
201
+ if (bad) return Promise.resolve(bad);
184
202
  return caddyRequest("GET", `/pki/ca/${ca}`);
185
203
  }
186
204
  function getPkiCertificates(ca = "local") {
205
+ const bad = rejectTraversal(ca);
206
+ if (bad) return Promise.resolve(bad);
187
207
  return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
188
208
  }
189
209
  function configByIdGet(id, subpath = "") {
210
+ const badId = rejectTraversal(id);
211
+ if (badId) return Promise.resolve(badId);
190
212
  const bad = rejectTraversal(subpath);
191
213
  if (bad) return Promise.resolve(bad);
192
214
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
193
215
  return caddyRequest("GET", path);
194
216
  }
195
217
  function configByIdSet(id, value, method = "PATCH", subpath = "") {
218
+ const badId = rejectTraversal(id);
219
+ if (badId) return Promise.resolve(badId);
196
220
  const bad = rejectTraversal(subpath);
197
221
  if (bad) return Promise.resolve(bad);
198
222
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
199
223
  return caddyRequest(method, path, value);
200
224
  }
201
225
  function configByIdDelete(id, subpath = "") {
226
+ const badId = rejectTraversal(id);
227
+ if (badId) return Promise.resolve(badId);
202
228
  const bad = rejectTraversal(subpath);
203
229
  if (bad) return Promise.resolve(bad);
204
230
  const path = subpath ? `/id/${id}/${subpath}` : `/id/${id}`;
@@ -349,6 +375,9 @@ function getSnapshot(index) {
349
375
  }
350
376
 
351
377
  // src/tools/config.ts
378
+ function isSnapshotableConfig(data) {
379
+ return data !== null && typeof data === "object" && !Array.isArray(data);
380
+ }
352
381
  function registerConfigTools(server) {
353
382
  server.tool(
354
383
  "caddy_config_get",
@@ -391,7 +420,7 @@ function registerConfigTools(server) {
391
420
  async ({ config, format }) => {
392
421
  const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
393
422
  const current = await configGet();
394
- if (current.ok && current.data !== void 0) {
423
+ if (current.ok && isSnapshotableConfig(current.data)) {
395
424
  saveSnapshot(current.data, "caddy_load");
396
425
  }
397
426
  return formatResult(await loadConfig(config, contentType));
@@ -423,10 +452,15 @@ ${lines.join("\n")}` }] };
423
452
  if (action === "save") {
424
453
  const current2 = await configGet();
425
454
  if (!current2.ok) return formatResult(current2);
426
- if (current2.data === void 0) {
455
+ if (!isSnapshotableConfig(current2.data)) {
427
456
  return {
428
457
  isError: true,
429
- 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
+ ]
430
464
  };
431
465
  }
432
466
  saveSnapshot(current2.data, "manual");
@@ -456,11 +490,11 @@ ${lines.join("\n")}` }] };
456
490
  };
457
491
  }
458
492
  const current = await configGet();
459
- if (current.ok && current.data !== void 0) {
460
- saveSnapshot(current.data, "caddy_revert");
461
- }
462
493
  const res = await loadConfig(snap.config, "application/json");
463
494
  if (!res.ok) return formatResult(res);
495
+ if (current.ok && isSnapshotableConfig(current.data)) {
496
+ saveSnapshot(current.data, "caddy_revert");
497
+ }
464
498
  const when = new Date(snap.timestamp).toISOString();
465
499
  return {
466
500
  content: [
@@ -515,19 +549,46 @@ function describeServer(raw) {
515
549
  const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
516
550
  return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
517
551
  }
518
- function findAcmeEmail(policies) {
519
- if (!Array.isArray(policies)) return void 0;
520
- for (const rawPolicy of policies) {
521
- if (!rawPolicy || typeof rawPolicy !== "object") continue;
522
- const policy = rawPolicy;
523
- if (!Array.isArray(policy.issuers)) continue;
524
- for (const rawIssuer of policy.issuers) {
525
- if (!rawIssuer || typeof rawIssuer !== "object") continue;
526
- const issuer = rawIssuer;
527
- if (typeof issuer.email === "string") return issuer.email;
528
- }
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;
529
575
  }
530
- return void 0;
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;
531
592
  }
532
593
  function registerOperationalTools(server) {
533
594
  server.tool(
@@ -597,10 +658,22 @@ ${lines.join("\n")}` }]
597
658
  );
598
659
  server.tool(
599
660
  "caddy_metrics",
600
- "Get Prometheus metrics from Caddy. Shows request counts, durations, TLS handshake stats, active connections, and more.",
601
- {},
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
+ },
602
668
  { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
603
- async () => formatResult(await getMetrics())
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
+ }
604
677
  );
605
678
  server.tool(
606
679
  "caddy_stop",
@@ -631,7 +704,10 @@ function parseFrom(from) {
631
704
  const slashIdx = cleaned.indexOf("/");
632
705
  if (slashIdx > 0) {
633
706
  match.host = [cleaned.substring(0, slashIdx)];
634
- match.path = [cleaned.substring(slashIdx)];
707
+ const path = cleaned.substring(slashIdx);
708
+ if (path !== "/") {
709
+ match.path = [path];
710
+ }
635
711
  } else if (cleaned.startsWith("/")) {
636
712
  match.path = [cleaned];
637
713
  } else {
@@ -642,6 +718,21 @@ function parseFrom(from) {
642
718
  function cleanUpstreamAddr(addr) {
643
719
  return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
644
720
  }
721
+ function isParentMissing(res) {
722
+ if (res.ok) return false;
723
+ if (res.status === 404) return true;
724
+ return res.error?.includes("key does not exist") ?? false;
725
+ }
726
+ function isUnknownId(res) {
727
+ if (res.ok) return false;
728
+ if (res.status === 404) return true;
729
+ const body = (res.error ?? "").toLowerCase();
730
+ return body.includes("unknown object id") || body.includes("no id found");
731
+ }
732
+ function isRouteShape(obj) {
733
+ if (!obj || typeof obj !== "object") return false;
734
+ return Array.isArray(obj.handle);
735
+ }
645
736
  function serverNotFoundError(srv) {
646
737
  return {
647
738
  isError: true,
@@ -656,14 +747,17 @@ function serverNotFoundError(srv) {
656
747
  function registerRouteTools(server) {
657
748
  server.tool(
658
749
  "caddy_reverse_proxy",
659
- "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000'].",
750
+ "Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PUT under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
660
751
  {
661
752
  from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
662
753
  to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
663
- server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
754
+ server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
755
+ id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
756
+ "Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
757
+ )
664
758
  },
665
759
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
666
- async ({ from, to, server: srv }) => {
760
+ async ({ from, to, server: srv, id }) => {
667
761
  const match = parseFrom(from);
668
762
  const cleanedTo = to.map(cleanUpstreamAddr);
669
763
  const route = {
@@ -676,11 +770,58 @@ function registerRouteTools(server) {
676
770
  ],
677
771
  terminal: true
678
772
  };
773
+ if (id) {
774
+ route["@id"] = id;
775
+ const existing = await configByIdGet(id);
776
+ if (existing.ok) {
777
+ if (!isRouteShape(existing.data)) {
778
+ return {
779
+ isError: true,
780
+ content: [
781
+ {
782
+ type: "text",
783
+ text: `Error: @id "${id}" is already in use by a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- pick a different id, or remove the existing object first with caddy_config_by_id { id: "${id}", action: "delete" }.`
784
+ }
785
+ ]
786
+ };
787
+ }
788
+ const putRes = await configByIdSet(id, route, "PUT");
789
+ if (putRes.ok) {
790
+ return {
791
+ content: [
792
+ {
793
+ type: "text",
794
+ text: `Route set @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
795
+ }
796
+ ]
797
+ };
798
+ }
799
+ return formatResult(putRes);
800
+ }
801
+ if (!isUnknownId(existing)) {
802
+ return formatResult(existing);
803
+ }
804
+ const postRes = await configPost(`apps/http/servers/${srv}/routes`, route);
805
+ if (postRes.ok) {
806
+ return {
807
+ content: [
808
+ {
809
+ type: "text",
810
+ text: `Route created @id="${id}": ${from} \u2192 ${cleanedTo.join(", ")}`
811
+ }
812
+ ]
813
+ };
814
+ }
815
+ if (isParentMissing(postRes)) {
816
+ return serverNotFoundError(srv);
817
+ }
818
+ return formatResult(postRes);
819
+ }
679
820
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
680
821
  if (res.ok) {
681
822
  return { content: [{ type: "text", text: `Route added: ${from} \u2192 ${cleanedTo.join(", ")}` }] };
682
823
  }
683
- if (!res.ok && res.error?.includes("key does not exist")) {
824
+ if (isParentMissing(res)) {
684
825
  return serverNotFoundError(srv);
685
826
  }
686
827
  return formatResult(res);
@@ -699,7 +840,7 @@ function registerRouteTools(server) {
699
840
  async ({ match, handle, server: srv, terminal }) => {
700
841
  const route = { match, handle, terminal };
701
842
  const res = await configPost(`apps/http/servers/${srv}/routes`, route);
702
- if (!res.ok && res.error?.includes("key does not exist")) {
843
+ if (isParentMissing(res)) {
703
844
  return serverNotFoundError(srv);
704
845
  }
705
846
  return formatResult(res);
@@ -912,9 +1053,9 @@ function buildTlsConfig(fields) {
912
1053
  }
913
1054
  };
914
1055
  }
915
- function bothErrors(label, patchRes, postRes) {
1056
+ function bothErrors(label, patchRes, writeRes, writeLabel) {
916
1057
  const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
917
- const postErr = postRes.error || `HTTP ${postRes.status}`;
1058
+ const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
918
1059
  return {
919
1060
  isError: true,
920
1061
  content: [
@@ -922,11 +1063,97 @@ function bothErrors(label, patchRes, postRes) {
922
1063
  type: "text",
923
1064
  text: `Error: Failed to set ${label}.
924
1065
  PATCH attempt: ${patchErr}
925
- POST fallback: ${postErr}`
1066
+ ${writeLabel} fallback: ${writeErr}`
926
1067
  }
927
1068
  ]
928
1069
  };
929
1070
  }
1071
+ function isPlainObject(v) {
1072
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1073
+ }
1074
+ function deepClone(v) {
1075
+ return JSON.parse(JSON.stringify(v));
1076
+ }
1077
+ function mergeIssuerFields(existing, fields) {
1078
+ const merged = deepClone(existing);
1079
+ const automation = merged.automation;
1080
+ const issuer = automation.policies[0].issuers[0];
1081
+ if (fields.email !== void 0) issuer.email = fields.email;
1082
+ if (fields.ca !== void 0) issuer.ca = fields.ca;
1083
+ return merged;
1084
+ }
1085
+ function validateIssuerShape(tls) {
1086
+ const automation = tls.automation;
1087
+ if (!isPlainObject(automation)) {
1088
+ return "apps/tls.automation is missing or not an object";
1089
+ }
1090
+ const policies = automation.policies;
1091
+ if (!Array.isArray(policies) || policies.length === 0) {
1092
+ return "apps/tls.automation.policies is missing, not an array, or empty";
1093
+ }
1094
+ const policy0 = policies[0];
1095
+ if (!isPlainObject(policy0)) {
1096
+ return "apps/tls.automation.policies[0] is not an object";
1097
+ }
1098
+ const issuers = policy0.issuers;
1099
+ if (!Array.isArray(issuers) || issuers.length === 0) {
1100
+ return "apps/tls.automation.policies[0].issuers is missing, not an array, or empty";
1101
+ }
1102
+ const issuer0 = issuers[0];
1103
+ if (!isPlainObject(issuer0)) {
1104
+ return "apps/tls.automation.policies[0].issuers[0] is not an object";
1105
+ }
1106
+ return null;
1107
+ }
1108
+ async function safeFallback(label, patchRes, fields) {
1109
+ const getRes = await configGet("apps/tls");
1110
+ const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
1111
+ if (absent) {
1112
+ const postRes = await configPost("apps/tls", buildTlsConfig(fields));
1113
+ if (postRes.ok) return { kind: "ok" };
1114
+ return { kind: "tool-error", result: bothErrors(label, patchRes, postRes, "POST") };
1115
+ }
1116
+ if (!getRes.ok) {
1117
+ return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
1118
+ }
1119
+ if (!isPlainObject(getRes.data)) {
1120
+ return {
1121
+ kind: "tool-error",
1122
+ result: {
1123
+ isError: true,
1124
+ content: [
1125
+ {
1126
+ type: "text",
1127
+ text: `Error: Failed to set ${label}.
1128
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1129
+ Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
1130
+ }
1131
+ ]
1132
+ }
1133
+ };
1134
+ }
1135
+ const shapeError = validateIssuerShape(getRes.data);
1136
+ if (shapeError) {
1137
+ return {
1138
+ kind: "tool-error",
1139
+ result: {
1140
+ isError: true,
1141
+ content: [
1142
+ {
1143
+ type: "text",
1144
+ text: `Error: Failed to set ${label}.
1145
+ PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
1146
+ Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
1147
+ }
1148
+ ]
1149
+ }
1150
+ };
1151
+ }
1152
+ const merged = mergeIssuerFields(getRes.data, fields);
1153
+ const putRes = await configPut("apps/tls", merged);
1154
+ if (putRes.ok) return { kind: "ok" };
1155
+ return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT") };
1156
+ }
930
1157
  function registerTlsTools(server) {
931
1158
  server.tool(
932
1159
  "caddy_tls",
@@ -949,9 +1176,9 @@ function registerTlsTools(server) {
949
1176
  };
950
1177
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
951
1178
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
952
- const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
953
- if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
954
- return bothErrors("ACME email", patchRes, postRes);
1179
+ const outcome = await safeFallback("ACME email", patchRes, { email });
1180
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
1181
+ return outcome.result;
955
1182
  }
956
1183
  if (action === "set_acme_ca") {
957
1184
  if (!ca)
@@ -961,9 +1188,9 @@ function registerTlsTools(server) {
961
1188
  };
962
1189
  const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
963
1190
  if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
964
- const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
965
- if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
966
- return bothErrors("ACME CA", patchRes, postRes);
1191
+ const outcome = await safeFallback("ACME CA", patchRes, { ca });
1192
+ if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
1193
+ return outcome.result;
967
1194
  }
968
1195
  return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
969
1196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
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)",
@@ -40,7 +40,9 @@
40
40
  },
41
41
  "overrides": {
42
42
  "hono": "^4.12.14",
43
- "@hono/node-server": "^1.19.13"
43
+ "@hono/node-server": "^1.19.13",
44
+ "postcss": "^8.5.10",
45
+ "ip-address": "^10.1.1"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@biomejs/biome": "^2.4.11",